add. Closes #8
All checks were successful
Deploy Nuxt App / deploy (push) Successful in 3m59s
All checks were successful
Deploy Nuxt App / deploy (push) Successful in 3m59s
This commit is contained in:
16
.env
Normal file
16
.env
Normal file
@ -0,0 +1,16 @@
|
||||
DB_USER=
|
||||
DB_HOST=
|
||||
DB_NAME=
|
||||
DB_PASSWORD=
|
||||
DB_PORT=
|
||||
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
|
||||
APP_URL=
|
||||
|
||||
|
||||
# SMTP Configuration for Yandex
|
||||
SMTP_USER=levishub@yandex.com
|
||||
SMTP_PASS=avhpihoudpyvibtx
|
||||
DEFAULT_TO_EMAIL=miduway@yandex.ru
|
18
.env.example
Normal file
18
.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
DB_USER=
|
||||
DB_HOST=
|
||||
DB_NAME=
|
||||
DB_PASSWORD=
|
||||
DB_PORT=
|
||||
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
|
||||
APP_URL=
|
||||
|
||||
|
||||
|
||||
|
||||
# SMTP Configuration for Yandex
|
||||
SMTP_USER=levishub@yandex.ru
|
||||
SMTP_PASS=avhpihoudpyvibtx
|
||||
DEFAULT_TO_EMAIL=miduway@yandex.ru
|
@ -8,6 +8,7 @@ Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
|
78
components/EmailForm.vue
Normal file
78
components/EmailForm.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto p-6 rounded-lg shadow-md text-gray-900">
|
||||
<h2 class="text-2xl font-bold mb-6 text-primary text-center">Заполните контактные данные</h2>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label for="to" class="block text-sm font-medium text-primary mb-2"> Ваш e-mail: </label>
|
||||
<input
|
||||
id="to"
|
||||
v-model="to"
|
||||
type="email"
|
||||
required
|
||||
placeholder="email@example.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UiButton type="submit" :disabled="isLoading" class="w-[400px]">
|
||||
<span v-if="isLoading">Перехожу...</span>
|
||||
<span v-else>Перейти к оплате</span>
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
<div v-if="status" class="mt-4 p-3 rounded-md" :class="statusClass">
|
||||
{{ status }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mail = useMail()
|
||||
|
||||
const to = ref('')
|
||||
const status = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (status.value.includes('Ошибка')) {
|
||||
return 'bg-red-100 text-red-700 border border-red-200'
|
||||
}
|
||||
if (status.value.includes('отправлено') || status.value.includes('подписка')) {
|
||||
return 'bg-green-100 text-green-700 border border-green-200'
|
||||
}
|
||||
return 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||
})
|
||||
|
||||
async function onSubmit() {
|
||||
if (!to.value) {
|
||||
status.value = 'Пожалуйста, введите ваш e-mail'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
status.value = ''
|
||||
|
||||
try {
|
||||
const emailData = {
|
||||
to: to.value,
|
||||
subject: 'Новая подписка на рассылку книги!',
|
||||
text: `Пользователь с e-mail: ${to.value} подписался на рассылку книги.`,
|
||||
}
|
||||
|
||||
await mail.send(emailData)
|
||||
|
||||
status.value = 'Успешная подписка! Проверьте вашу почту.'
|
||||
|
||||
// Очищаем форму
|
||||
to.value = ''
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки письма:', error)
|
||||
status.value = `Ошибка отправки: ${error.message || 'Неизвестная ошибка'}`
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
@ -2,6 +2,8 @@
|
||||
<component
|
||||
:is="tag"
|
||||
:type="tag === 'button' ? 'button' : ''"
|
||||
:to="path"
|
||||
:href="path"
|
||||
class="px-14 py-4 rounded-[20px] text-[30px] cursor-pointer shadow-[0px_16px_50px_-16px_rgba(229,30,125,1)]"
|
||||
:class="[baseStyle, size]"
|
||||
data-ui="ui-button"
|
||||
@ -11,36 +13,40 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs, computed } from "vue";
|
||||
import { colorVariants } from "./UiButton.params.js";
|
||||
import { toRefs, computed } from 'vue'
|
||||
import { colorVariants } from './UiButton.params.js'
|
||||
|
||||
const props = defineProps({
|
||||
tag: {
|
||||
type: String,
|
||||
default: "button",
|
||||
default: 'button',
|
||||
},
|
||||
variants: {
|
||||
type: String as () => "primary" | "secondary",
|
||||
default: "primary",
|
||||
type: String as () => 'primary' | 'secondary',
|
||||
default: 'primary',
|
||||
validator: (value) => {
|
||||
return ["primary", "secondary"].includes(value as string);
|
||||
return ['primary', 'secondary'].includes(value as string)
|
||||
},
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "font-bold",
|
||||
default: 'font-bold',
|
||||
},
|
||||
});
|
||||
path: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { tag, variants, size } = toRefs(props);
|
||||
const { tag, variants, size } = toRefs(props)
|
||||
|
||||
const colorClasses: Record<"primary" | "secondary", string[]> = {
|
||||
const colorClasses: Record<'primary' | 'secondary', string[]> = {
|
||||
primary: colorVariants.primary,
|
||||
secondary: colorVariants.secondary,
|
||||
};
|
||||
}
|
||||
|
||||
const baseStyle = computed(() => {
|
||||
const variant = variants.value as "primary" | "secondary";
|
||||
return colorClasses[variant]?.join(" ") || "";
|
||||
});
|
||||
const variant = variants.value as 'primary' | 'secondary'
|
||||
return colorClasses[variant]?.join(' ') || ''
|
||||
})
|
||||
</script>
|
||||
|
@ -1,2 +0,0 @@
|
||||
VITE_MODE=DEVELOP
|
||||
VITE_API_BASE_PATH=http://localhost:5173/
|
@ -25,11 +25,13 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['@/assets/css/tailwind.css'],
|
||||
...config,
|
||||
|
||||
htmlValidator: {
|
||||
usePrettier: true,
|
||||
logLevel: 'error',
|
||||
failOnError: false,
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'Vino Galante',
|
||||
@ -39,4 +41,25 @@ export default defineNuxtConfig({
|
||||
...head,
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
...config.modules,
|
||||
[
|
||||
'nuxt-mail',
|
||||
{
|
||||
smtp: {
|
||||
host: 'smtp.yandex.ru',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'levishub@yandex.com',
|
||||
pass: process.env.SMTP_PASS || 'avhpihoudpyvibtx',
|
||||
},
|
||||
},
|
||||
message: {
|
||||
to: process.env.DEFAULT_TO_EMAIL || 'default@example.com',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
})
|
||||
|
@ -13,6 +13,7 @@
|
||||
"format": "node_modules/.bin/prettier --write ./"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@nuxt/content": "^3.6.0",
|
||||
"@nuxt/fonts": "^0.11.4",
|
||||
"@nuxt/icon": "^1.13.0",
|
||||
@ -23,9 +24,15 @@
|
||||
"@pinia/nuxt": "^0.5.5",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.17.5",
|
||||
"nuxt-mail": "^6.0.2",
|
||||
"nuxt-schema-org": "^5.0.5",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.7",
|
||||
"swiper": "^11.2.8",
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
@ -34,6 +41,7 @@
|
||||
"@nuxt/test-utils": "^3.11.3",
|
||||
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.5.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
|
@ -33,7 +33,7 @@
|
||||
</section>
|
||||
</section>
|
||||
<!--средний блок-->
|
||||
<section class="flex flex-row items-center ml-12 gap-24">
|
||||
<section class="flex flex-row items-center ml-12 gap-24 relative z-50">
|
||||
<!--левый-->
|
||||
<div class="flex flex-col items-center min-h-[310px]">
|
||||
<div class="flex flex-row">
|
||||
@ -41,7 +41,12 @@
|
||||
<img src="/img/svg/books/ruble.svg" alt="ruble" />
|
||||
</div>
|
||||
<div class="flex items-center flex-col gap-3">
|
||||
<UiButton class="max-w-[440px] !font-normal !px-2 !py-4 mt-16">
|
||||
<UiButton
|
||||
@click="handleSelect"
|
||||
:id="book.id"
|
||||
variants="primary"
|
||||
class="max-w-[440px] !font-normal !px-2 !py-4 mt-16"
|
||||
>
|
||||
{{ book.buttonText }}
|
||||
</UiButton>
|
||||
<UiParagraph size="200">
|
||||
@ -153,10 +158,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute } from '#app'
|
||||
import { useRoute, useRouter } from '#app'
|
||||
import UiHeading from '@/components/Typography/UiHeading.vue'
|
||||
import UiParagraph from '@/components/Typography/UiParagraph.vue'
|
||||
import UiButton from '@/components/UiButton/UiButton.vue'
|
||||
import { useSelectedBook } from '@/store/useSelectedBook'
|
||||
|
||||
interface BookDetail {
|
||||
id: number
|
||||
@ -182,6 +188,8 @@ interface BookDetail {
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const currentBookData = ref<BookDetail | null>(null)
|
||||
|
||||
const book = computed(() => currentBookData.value)
|
||||
@ -196,6 +204,10 @@ const loadBookData = async (slug: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = () => {
|
||||
router.push('/cart/')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
async (newSlug) => {
|
||||
|
@ -1,17 +1,21 @@
|
||||
{
|
||||
"title": "Корзина",
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Как влюбить в себя любого \n Книга I. \n Откровения бывшего Казановы",
|
||||
"src": "/assets/img/png/book1.png",
|
||||
"buy": "добавить Книгу I"
|
||||
"src": "/img/books/book1.png",
|
||||
"buy": "добавить Книгу I",
|
||||
"price": 520,
|
||||
"message": "💡 Купи обе книги и получи \n скидку 10% - 936 за комплект"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Как влюбить в себя любого \n Книга II. \n Тонкая игра",
|
||||
"src": "/assets/img/png/book2.png",
|
||||
"buy": "добавить Книгу II"
|
||||
"src": "/img/books/book2.png",
|
||||
"buy": "добавить Книгу II",
|
||||
"price": 520,
|
||||
"message": "💡 Купи обе книги и получи \n скидку 10% - 936 за комплект"
|
||||
}
|
||||
],
|
||||
"message": "💡 Купи обе книги и получи скидку 10% - 936 за комплект",
|
||||
"price": "520 ₽"
|
||||
"message": "💡 Купи обе книги и получи скидку 10% - 936 за комплект"
|
||||
}
|
||||
|
@ -1,8 +1,113 @@
|
||||
<template>
|
||||
<section class="relative z-50">
|
||||
<UiHeading tag="h1" size="300"> Корзина </UiHeading>
|
||||
<section class="relative z-50 ml-4">
|
||||
<UiHeading tag="h1" size="300" class="font-bold mb-16"> Корзина </UiHeading>
|
||||
<div class="-ml-6">
|
||||
<div
|
||||
v-for="({ name, src, price, message, buy, id }, index) in cartList.items"
|
||||
:key="index"
|
||||
class="flex items-center mb-24"
|
||||
>
|
||||
<div class="w-40 h-40">
|
||||
<img :src="`${src}`" alt="book" />
|
||||
</div>
|
||||
<UiParagraph size="300" class="whitespace-pre mb-10 mr-12 w-80">{{ name }}</UiParagraph>
|
||||
<UiParagraph size="300" class="whitespace-pre mb-10 mr-20">{{ message }}</UiParagraph>
|
||||
<UiButton
|
||||
:id="id"
|
||||
class="mb-10"
|
||||
v-if="store.getQuantity(id) === 0"
|
||||
@click="handleAddToCart(id)"
|
||||
>{{ buy }}</UiButton
|
||||
>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-8 mr-20">
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full border border-white text-white text-2xl"
|
||||
@click="handleRemove(id)"
|
||||
aria-label="Уменьшить количество"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="text-white text-2xl">{{ store.getQuantity(id) }}</span>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full border border-white text-white text-2xl"
|
||||
@click="handleIncrement(id)"
|
||||
aria-label="Увеличить количество"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span class="text-white text-2xl font-bold mr-20 whitespace-nowrap">
|
||||
{{ Number(price) * store.getQuantity(id) }} ₽
|
||||
</span>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full border border-white text-white text-xl"
|
||||
@click="handleRemove(id)"
|
||||
aria-label="Удалить товар"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center justify-end mt-8">
|
||||
<UiParagraph is="span" size="300">Общая стоимость</UiParagraph>
|
||||
<template v-if="isSpecialPrice">
|
||||
<span class="text-white text-2xl font-bold ml-4 line-through select-none">
|
||||
{{ regularTotalPrice }} ₽
|
||||
</span>
|
||||
<span class="text-primary text-3xl font-bold ml-4">
|
||||
{{ store.getTotalPrice(cartList.items) }} ₽
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-primary text-3xl font-bold ml-4">
|
||||
{{ store.getTotalPrice(cartList.items) }} ₽
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10 flex flex-col items-center justify-center">
|
||||
<UiButton class="w-[660px]"> перейти к оформлению </UiButton>
|
||||
<UiParagraph is="span" size="300" class="mb-10 mt-5"
|
||||
>После оплаты книги сразу будут доступны для скачивания</UiParagraph
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import cartList from './_data/cart.json'
|
||||
import UiHeading from '~/components/Typography/UiHeading.vue'
|
||||
import UiParagraph from '~/components/Typography/UiParagraph.vue'
|
||||
import { useSelectedBook } from '@/store/useSelectedBook'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const store = useSelectedBook()
|
||||
|
||||
function handleIncrement(id: number) {
|
||||
store.increment(id)
|
||||
}
|
||||
|
||||
function handleDecrement(id: number) {
|
||||
store.decrement(id)
|
||||
}
|
||||
|
||||
function handleRemove(id: number) {
|
||||
store.reset(id)
|
||||
}
|
||||
|
||||
function handleAddToCart(id: number) {
|
||||
store.addToCart(id)
|
||||
}
|
||||
|
||||
const selectedBooksCount = computed(() => store.cartQuantities.filter((i) => i.quantity > 0).length)
|
||||
|
||||
const isSpecialPrice = computed(() => selectedBooksCount.value === 2)
|
||||
|
||||
const regularTotalPrice = computed(() => {
|
||||
// Сумма без скидки
|
||||
return store.cartQuantities.reduce((sum, item) => {
|
||||
const book = cartList.items.find((b: any) => b.id === item.id)
|
||||
return sum + (book ? Number(book.price) * item.quantity : 0)
|
||||
}, 0)
|
||||
})
|
||||
</script>
|
||||
|
19
pages/email.vue
Normal file
19
pages/email.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="relative z-50 text-black min-h-screen py-12">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="text-center mb-8">
|
||||
<UiHeading tag="h1" size="500" class="font-bold text-primary mb-4"
|
||||
>Контактные данные</UiHeading
|
||||
>
|
||||
</div>
|
||||
|
||||
<EmailForm />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UiHeading from '~/components/Typography/UiHeading.vue'
|
||||
|
||||
// Страница использует компонент EmailForm
|
||||
</script>
|
@ -1,12 +1,14 @@
|
||||
import UiParagraph from '@/components/Typography/UiParagraph.vue';
|
||||
<template>
|
||||
<div
|
||||
v-for="({ topContent, button, botContent }, index) in content.data"
|
||||
v-for="({ topContent, button, botContent, path }, index) in content.data"
|
||||
:key="index"
|
||||
class="flex flex-col items-center max-w-96"
|
||||
>
|
||||
<UiParagraph size="300" class="mb-12 h-32">{{ topContent }} </UiParagraph>
|
||||
<UiButton variants="primary" class="mb-3 w-full">{{ button }} </UiButton>
|
||||
<UiButton tag="RouterLink" :to="path" variants="primary" class="mb-3 w-full"
|
||||
>{{ button }}
|
||||
</UiButton>
|
||||
<UiParagraph as="span" size="200"> {{ botContent }}</UiParagraph>
|
||||
</div>
|
||||
</template>
|
||||
@ -23,12 +25,14 @@ const content = reactive({
|
||||
'💡 Узнай, как думает мужчина, что его действительно цепляет, и что делает женщину незабываемой.',
|
||||
button: 'КУПИТЬ КНИГУ I',
|
||||
botContent: 'PDF + EPUB сразу после оплаты',
|
||||
path: '/books/1',
|
||||
},
|
||||
{
|
||||
topContent:
|
||||
'💡 Продолжение для тех, кто готов перейти от флирта к глубокому контакту. Как строить притяжение, не теряя себя.',
|
||||
button: 'КУПИТЬ КНИГУ II',
|
||||
botContent: 'PDF + EPUB сразу после оплаты',
|
||||
path: '/books/2',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 208 KiB |
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 218 KiB |
58
store/useSelectedBook.ts
Normal file
58
store/useSelectedBook.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface CartItem {
|
||||
id: number
|
||||
quantity: number
|
||||
summary?: number
|
||||
}
|
||||
|
||||
export const useSelectedBook = defineStore('selectedBook', {
|
||||
state: () => ({
|
||||
cartQuantities: [] as CartItem[],
|
||||
}),
|
||||
actions: {
|
||||
increment(id: number) {
|
||||
const item = this.cartQuantities.find((i) => i.id === id)
|
||||
if (item && item.quantity < 1) {
|
||||
item.quantity++
|
||||
}
|
||||
},
|
||||
decrement(id: number) {
|
||||
const item = this.cartQuantities.find((i) => i.id === id)
|
||||
if (item) {
|
||||
item.quantity--
|
||||
}
|
||||
},
|
||||
reset(id: number) {
|
||||
const idx = this.cartQuantities.findIndex((i) => i.id === id)
|
||||
if (idx !== -1) {
|
||||
this.cartQuantities.splice(idx, 1)
|
||||
}
|
||||
},
|
||||
addToCart(id: number) {
|
||||
if (!this.cartQuantities.find((i) => i.id === id)) {
|
||||
this.cartQuantities.push({ id, quantity: 1 })
|
||||
}
|
||||
},
|
||||
getQuantity(id: number) {
|
||||
return this.cartQuantities.find((i) => i.id === id)?.quantity || 0
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
getTotalPrice: (state) => (books: { id: number; price: number }[]) => {
|
||||
const selected = state.cartQuantities.filter((i) => i.quantity > 0)
|
||||
if (selected.length === 2) {
|
||||
// Проверяем, что это две разные книги
|
||||
const ids = selected.map((i) => i.id)
|
||||
if (ids[0] !== ids[1]) {
|
||||
return 936
|
||||
}
|
||||
}
|
||||
// Обычная сумма
|
||||
return selected.reduce((sum, item) => {
|
||||
const book = books.find((b) => b.id === item.id)
|
||||
return sum + (book ? book.price * item.quantity : 0)
|
||||
}, 0)
|
||||
},
|
||||
},
|
||||
})
|
Reference in New Issue
Block a user