add. Closes #8
All checks were successful
Deploy Nuxt App / deploy (push) Successful in 3m59s

This commit is contained in:
2025-06-21 10:21:05 +04:00
parent 5c97679188
commit 925b6197f2
16 changed files with 380 additions and 30 deletions

16
.env Normal file
View 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
View 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

View File

@ -8,6 +8,7 @@ Make sure to install dependencies:
```bash
# npm
npm install
# pnpm

78
components/EmailForm.vue Normal file
View 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>

View File

@ -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>

View File

@ -1,2 +0,0 @@
VITE_MODE=DEVELOP
VITE_API_BASE_PATH=http://localhost:5173/

View File

@ -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',
},
},
],
],
})

View File

@ -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",

View File

@ -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) => {

View File

@ -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 за комплект"
}

View File

@ -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
View 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>

View File

@ -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',
},
],
})

View File

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View File

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

58
store/useSelectedBook.ts Normal file
View 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)
},
},
})