stuff done

This commit is contained in:
2025-03-11 02:54:09 +02:00
parent 58e7ed2f06
commit 516b45fad9
90 changed files with 2950 additions and 9458 deletions

2
.gitignore vendored
View File

@@ -127,3 +127,5 @@ dist
/messages/*.d.json.ts /messages/*.d.json.ts
/public/uploads/ /public/uploads/
/public/main-fallback.jpg /public/main-fallback.jpg
/sv.js
/package-lock.json

12
.idea/dataSources.xml generated
View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@10.14.88.14" uuid="44d6c739-7506-4f97-b907-615219cb4f21">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://10.14.88.14:3306</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

9
.idea/sqldialects.xml generated
View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/app/[locale]/(root)/(cabinet)/cabinet/[[...slug]]/page.tsx" dialect="GenericSQL" />
<file url="file://$PROJECT_DIR$/lib/db/prisma/sql/getCategoryBySlugWitData.sql" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/lib/db/prisma/sql/getUserWithAccount.sql" dialect="MySQL" />
<file url="PROJECT" dialect="MySQL" />
</component>
</project>

107
actions/admin/entity.ts Normal file
View File

@@ -0,0 +1,107 @@
'use server'
import {EntityLocale, EntityType, Meta} from '@prisma/client'
import {z} from 'zod'
import {i18nLocalesCodes} from '@/i18n-config'
import {STORE_ID} from '@/lib/config/constants'
import {db} from '@/lib/db/prisma/client'
import {createEntityFormSchema} from '@/lib/schemas/admin/entity'
import {
cleanEmptyParams,
dbErrorHandling,
slug as slugger,
toEmptyParams
} from '@/lib/utils'
export const onEntityCreateEditAction = async (
formData: z.infer<typeof createEntityFormSchema>
) => {
const validatedData = createEntityFormSchema.parse(formData)
if (!validatedData) return {error: 'Недійсні вхідні дані'}
if (validatedData.locales.length < i18nLocalesCodes.length) {
return {error: 'Заповніть всі мови'}
}
const {published, media, type, slug, scopes} = validatedData
const meta: Meta[] = []
for (const i in validatedData.meta) {
const normalizedMeta: any = cleanEmptyParams(validatedData.meta[i])
meta.push(normalizedMeta)
}
const locales: EntityLocale[] = []
for (const i in validatedData.locales) {
const locale = validatedData.locales[i]
const {title, lang} = locale
const slug = slugger(title, lang)
//const result = await getProductBySlug({slug, lang})
const result = null
if (!result) {
const normalized: any = cleanEmptyParams({slug, ...locale})
locales.push(normalized)
} else {
return {error: `Сутність з такою назвою ${title} вже існує`}
}
}
try {
const newEntity = await db.entity.create({
data: {
published,
scopes: scopes ? JSON.parse(scopes) : null,
type: type as EntityType,
slug: slug || null,
media: media || null,
locales: {
create: locales
}
}
})
return {success: JSON.stringify(newEntity, null, 2)}
} catch (error) {
return dbErrorHandling(error)
}
}
export const getBlockEntity = async (scope: string) => {
return db.entity.findMany({
where: {
published: true,
storeId: STORE_ID,
type: 'block',
scopes: {
array_contains: [scope]
}
},
include: {
locales: true
},
orderBy: {
position: 'asc'
}
})
}
export const getPageEntityBySlug = async (slug: string) => {
return db.entity.findFirst({
where: {
published: true,
storeId: STORE_ID,
type: 'page',
slug
},
include: {
locales: true
}
})
}

44
actions/admin/mailer.ts Normal file
View File

@@ -0,0 +1,44 @@
'use server'
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 465,
secure: true,
auth: {
user: 'vista@ugmail.org',
pass: 'hqhowacppifsefxl'
}
})
type SendMailProps = {
email: string
subject: string
text: string
html: string
}
export async function sendMail({email, subject, text, html}: SendMailProps) {
try {
const info = await transporter.sendMail({
from: `"BeWell" <vista@ugmail.org>`,
to: email,
bcc: [
'yevhen.odynets@gmail.com',
'shopping@amok.space',
{
name: 'Actus Septem',
address: 'actus.septem@ukr.net'
}
],
subject,
text,
html
})
return {ok: true, messageId: info.messageId}
} catch (e) {
return {ok: false, message: JSON.stringify(e)}
}
}

24
actions/admin/order.tsx Normal file
View File

@@ -0,0 +1,24 @@
import {db} from '@/lib/db/prisma/client'
export const getAllOrders = async () => {
return db.order.findMany({
orderBy: [
{
createdAt: 'desc'
}
]
})
}
export const getOrdersByUserId = async (userId: number) => {
return db.order.findMany({
where: {
userId
},
orderBy: [
{
createdAt: 'desc'
}
]
})
}

View File

@@ -0,0 +1,77 @@
'use server'
import {DeliveryOption, Lang, Order} from '@prisma/client'
import {z} from 'zod'
import {sendMail} from '@/actions/admin/mailer'
import {STORE_ID} from '@/lib/config/constants'
import dayjs from '@/lib/config/dayjs'
import {db} from '@/lib/db/prisma/client'
import {createOrderFormSchema} from '@/lib/schemas/admin/order'
import {dbErrorHandling} from '@/lib/utils'
const generateOrderNo = (): string => {
const hex = Math.floor(Math.random() * 16777215)
.toString(16)
.slice(0, 3)
.toUpperCase()
return `${dayjs().format('YYMM')}-${hex}`
}
export const onPlacingOrder = async (
formData: z.infer<typeof createOrderFormSchema>
) => {
const fields = createOrderFormSchema.parse(formData)
if (!fields) return {error: 'Недійсні вхідні дані'}
const orderNo = generateOrderNo()
try {
const newOrder: Order = await db.order.create({
data: {
storeId: STORE_ID,
lang: fields.lang as Lang,
orderNo,
isQuick: fields.is_quick,
userId: fields.user_id ? parseInt(fields.user_id) : null,
firstName: fields.first_name,
surname: fields.surname,
deliveryOption: fields.delivery_option as DeliveryOption,
phone: fields.phone,
email: fields.email,
address: fields.address.length > 10 ? JSON.parse(fields.address) : null,
notes: fields.notes?.toString().trim() !== '' ? fields.notes : null,
details: fields.details ? JSON.parse(fields.details) : null
}
})
const text = JSON.stringify(newOrder, null, 2)
const result = await sendMail({
email: `${newOrder.firstName} ${newOrder.surname} <${newOrder.email as string}>`,
subject: `Замовлення № ${orderNo}`,
text,
html: `<pre>${text}</pre>`
})
const updated = await db.order.update({
where: {
id: newOrder.id
},
data: {
emailSent: result.ok
}
})
if (result.ok) {
return {success: newOrder.orderNo}
} else {
return {
error: result.message
}
}
} catch (error) {
return dbErrorHandling(error)
}
}

View File

@@ -75,7 +75,7 @@ export const onProductCreateAction = async (
} }
}) })
return {success: 'JSON.stringify(newProduct, null, 2)'} return {success: JSON.stringify(newProduct, null, 2)}
} catch (error) { } catch (error) {
return dbErrorHandling(error) return dbErrorHandling(error)
} }

View File

@@ -1,5 +1,7 @@
import {auth} from '@/auth' import {auth} from '@/auth'
import AdminPermission from '@/components/(protected)/admin/auth/permission'
import {CreateForm} from '@/components/(protected)/admin/category/create-form' import {CreateForm} from '@/components/(protected)/admin/category/create-form'
import ProductCreateEditForm from '@/components/(protected)/admin/product/create-edit-form'
import {dump} from '@/lib/utils' import {dump} from '@/lib/utils'
export default async function Page({ export default async function Page({
@@ -12,7 +14,12 @@ export default async function Page({
switch ((slug || [])[0]) { switch ((slug || [])[0]) {
case 'create': case 'create':
return <CreateForm /> return (
<>
<AdminPermission />
<CreateForm />
</>
)
} }
return <div>{dump(slug)}</div> return <div>{dump(slug)}</div>

View File

@@ -0,0 +1,23 @@
import AdminPermission from '@/components/(protected)/admin/auth/permission'
import {EntityCrudForm} from '@/components/(protected)/admin/entity/crud-form'
import {dump} from '@/lib/utils'
export default async function Page({
params
}: {
params: Promise<{slug?: string[]}>
}) {
const {slug} = await params
switch ((slug || [])[0]) {
case 'create':
return (
<>
<AdminPermission />
<EntityCrudForm />
</>
)
}
return <div>{dump(slug)}</div>
}

View File

@@ -0,0 +1,16 @@
import Link from 'next/link'
import AdminPermission from '@/components/(protected)/admin/auth/permission'
export default function AdminEntityPage() {
return (
<div>
<AdminPermission />
<p>
<Link href='/admin/entity/create'>
Створити блок / статтю / сторінку
</Link>
</p>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import {Order} from '@prisma/client'
import Link from 'next/link'
import {getAllOrders} from '@/actions/admin/order'
import AdminPermission from '@/components/(protected)/admin/auth/permission'
import {dump} from '@/lib/utils'
export default async function AdminOrderPage() {
const orders = await getAllOrders()
return (
<div>
<AdminPermission />
{orders.map((order: Order) => (
<div
key={order.id}
className='flex items-center justify-start gap-x-9 gap-y-6'
>
<div>{order.orderNo}</div>
<div>
{order.firstName} {order.surname}
</div>
<div>{order.phone}</div>
<div>{order.email}</div>
<div>{order.notes}</div>
</div>
))}
</div>
)
}

View File

@@ -1,3 +1,5 @@
import AdminPermission from '@/components/(protected)/admin/auth/permission'
import {EntityCrudForm} from '@/components/(protected)/admin/entity/crud-form'
import ProductCreateEditForm from '@/components/(protected)/admin/product/create-edit-form' import ProductCreateEditForm from '@/components/(protected)/admin/product/create-edit-form'
import {getProductById} from '@/lib/data/models/product' import {getProductById} from '@/lib/data/models/product'
import {dump} from '@/lib/utils' import {dump} from '@/lib/utils'
@@ -21,9 +23,19 @@ export default async function Page({
switch (method) { switch (method) {
case 'create': case 'create':
return <ProductCreateEditForm /> return (
<>
<AdminPermission />
<ProductCreateEditForm />
</>
)
case 'update': case 'update':
return <ProductCreateEditForm data={data} /> return (
<>
<AdminPermission />
<ProductCreateEditForm data={data} />
</>
)
default: default:
return <div>{dump(slug)}</div> return <div>{dump(slug)}</div>
} }

View File

@@ -1,100 +0,0 @@
import React from 'react'
export default function AboutUsPage() {
return (
<div className='mb-12 mt-8'>
<div className='container max-w-[922px] text-lg text-brand-violet-950'>
<h1 className='text-3xl font-bold text-brand-violet'>
Bewell: здоровий спосіб життя для всіх
</h1>
<div className='py-4'>
<strong>Інтернет-магазин біологічних добавок Bewell</strong> це
зручна і надійна крамниця для всіх, хто піклується про здоровя. У нас
ви знайдете якісні дієтичні добавки від європейських виробників і
зможете подбати про себе і про рідних без зайвих клопотів.
</div>
<h2 className='my-4 text-2xl font-bold text-brand-violet'>
Асортимент магазину <strong>Bewell</strong>
</h2>
<p>
У нас є все, що потрібно для профілактики різноманітних захворювань та
підтримки здоровя. Це комплексні препарати, у складі яких
переважають:
</p>
<ul className='my-4 ml-12 list-disc'>
<li>екстракти рослин;</li>
<li>мікро- та макроелементи;</li>
<li>вітаміни.</li>
</ul>
<p>
Усі ці компоненти необхідно споживати щоденно, щоб отримати добову
норму вітамінів, мікро- та макроелементів. Дієтичні добавки просте
доповнення до харчування, яке забезпечує організм необхідними
поживними речовинами.
</p>
<p>
Щоб визначитися, яка з добавок підійде саме вам, пропонуємо зануритися
в наш каталог і вибрати відповідний розділ:
</p>
<ul className='my-4 ml-12 list-decimal'>
<li>
Комплексні препарати. Універсальні добавки для поліпшення здоровя.
Це вітаміни та мікроелементи для волосся, шкіри, підтримки
імунітету, серця, нервової системи, травлення тощо.
</li>
<li>
Добавки для підтримки жіночого здоровя (
<strong>вітаміни для жінок</strong>). Це зокрема препарати для
відновлення менструального циклу, для полегшення симптомів
менопаузи.
</li>
<li>
Добавки для підтримки здоровя чоловіків (
<strong>вітаміни для чоловіків</strong>). Препарати для покращення
статевої функції, здоровя передміхурової залози.
</li>
<li>
Препарати для зміцнення імунітету. Такі добавки корисні не лише для
відновлення після захворювання та лікування, а також для
профілактики захворювань.
</li>
<li>
Добавки для відновлення енергії. Ці засоби допомагають боротися з
втомою, покращують обмін речовин, допомагають почуватися енергійніше
та краще спати.{' '}
</li>
</ul>
<h2 className='my-4 text-2xl font-bold text-brand-violet'>
Як замовити якісні дієтичні добавки?
</h2>
<p>
В <strong>Bewell</strong> можна замовити продукцію відомих
європейських виробників. Дієтичні добавки не є лікарськими засобами,
але це хороша профілактика захворювань та підтримки здоровя.
Препарати, представлені в нашому магазині, можна побачити в
асортименті аптек, адже це перевірена продукція, яка успішно
використовується на лише в Україні, а й в Європі. Щоб зробити
замовлення, виберіть потрібний препарат, додайте до кошика та зазначте
умови відправки та оплати.
</p>
<h2 className='my-4 text-2xl font-bold text-brand-violet'>
Bewell: наша філософія та принцип роботи
</h2>
<p>
Головний пріоритет <strong>Bewell</strong> підтримка здорового
способу життя. Ми віримо, що ключ до гарного самопочуття та довголіття
можна знайти в природі, збалансованому харчуванні та усвідомлений
підтримці організму. Саме тому ми прагнемо допомогти кожному клієнту
знайти найкращі добавки для підтримки організму та профілактики
захворювань.
</p>
<p>
Ми дбаємо про чесність і прозорість пропонуємо лише сертифіковані,
перевірені добавки, які сприяють зміцненню імунітету, відновленню
енергії та покращенню сну, а також внутрішній гармонії.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import {Trash2} from 'lucide-react'
import {useTranslations} from 'next-intl'
import Image from 'next/image'
import {useState} from 'react'
import CartPostSubmit from '@/app/[locale]/(root)/(shop)/cart/post-submit'
import styles from '@/components/pages/cart/cart.module.scss'
import CartItems from '@/components/pages/cart/items'
import RegisteredOrderForm from '@/components/pages/cart/registered-order-form'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {Link} from '@/i18n/routing'
import {dump} from '@/lib/utils'
import EmptyCartImage from '@/public/images/empty-cart.svg'
import useCartStore from '@/store/cart-store'
import {SessionUser} from '@/types/auth'
import {Button} from '@/ui/button'
export default function Cart({user}: {user?: SessionUser | null}) {
const t = useTranslations('cart')
const [submitResult, setSubmitResult] = useState({})
const {cartItems, clearCart} = useCartStore()
const totalSum = cartItems.reduce(
(total, product) => total + parseFloat(product.price) * product.quantity,
0
)
const resultSubmit = (result: any) => {
setSubmitResult(result)
}
return (
<div className='mt-1'>
<div className='container'>
<section className='bw-cart-wrapper mx-auto my-8 max-w-[640px] text-brand-violet'>
{cartItems && cartItems.length > 0 ? (
<>
<div className='mb-6 flex items-center justify-between border-b border-b-brand-violet pb-2'>
<h1 className='text-3xl font-bold'>{t('basket')}</h1>
<Button
variant={'ghost'}
className='rounded-0 h-[3rem] w-[3rem] px-0 text-brand-violet'
onClick={() => clearCart()}
title={t('clear_cart')}
>
<Trash2 size={24} />
</Button>
</div>
<header className='flex text-xl'>
<div className='col'>{t('title')}</div>
<div className='flex-none'>{t('quantity')}</div>
<div className='col text-right'>{t('amount')}</div>
</header>
<CartItems cartItems={cartItems} />
<footer className='my-8 flex py-4 text-xl'>
<div className='col'></div>
<div className='flex-none'>{t('total')}:</div>
<div className='col text-right font-bold'>
{totalSum.toFixed(2)} грн
</div>
</footer>
<section className={styles.bwOrderForm}>
<h2 className='pb-9'>Оформлення замовлення</h2>
<Tabs defaultValue='registered' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='registered'>
Постійний клієнт
</TabsTrigger>
<TabsTrigger value='quick-order'>
Швидке замовлення
</TabsTrigger>
</TabsList>
<TabsContent value='registered' className='mt-5'>
<RegisteredOrderForm
styles={styles.registeredForm}
user={user}
onSubmitHandler={resultSubmit}
/>
</TabsContent>
<TabsContent value='quick-order'>quick-order</TabsContent>
</Tabs>
</section>
</>
) : Object.keys(submitResult).length === 0 ? (
<div className='flex flex-col items-center justify-center gap-y-8'>
<Image
src={EmptyCartImage}
sizes='88vw'
alt={t('empty')}
unoptimized={true}
style={{
width: '88%',
height: 'auto',
margin: '1rem auto'
}}
/>
<Link href={'/catalog'} className='px-6 py-2'>
<button className='rounded border border-brand-violet bg-transparent px-4 py-2 font-semibold text-brand-violet hover:border-transparent hover:bg-brand-yellow-300 hover:text-brand-violet-700'>
{t('do_purchase')}
</button>
</Link>
</div>
) : (
<CartPostSubmit result={submitResult} />
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,236 @@
'use client'
import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from 'cmdk'
import {
Check,
ChevronsUpDown,
MapPinCheck,
MapPinPlus,
Warehouse
} from 'lucide-react'
import {useLocale, useTranslations} from 'next-intl'
import {useState} from 'react'
import {useDebouncedCallback} from 'use-debounce'
import {
type Settlement,
type Warehouse as WarehouseType,
formatSettlement,
getApi
} from '@/lib/nova-post-helper'
import {cn} from '@/lib/utils'
import {dump} from '@/lib/utils'
import {Button} from '@/ui/button'
import {Command} from '@/ui/command'
import {Popover, PopoverContent, PopoverTrigger} from '@/ui/popover'
const url = '/api/nova-post'
export default function NovaPost({onSelectHandler}: {onSelectHandler: any}) {
const t = useTranslations('cart.post')
const [citiesOpen, setCitiesOpen] = useState(false)
const [warehousesOpen, setWarehousesOpen] = useState(false)
const [citiesValue, setCitiesValue] = useState('')
const [cityRef, setCityRef] = useState('')
//const [warehouseRef, setWarehouseRef] = useState('')
const [warehousesValue, setWarehousesValue] = useState('')
const [cities, setCities] = useState([])
const [warehouses, setWarehouses] = useState([])
const locale = useLocale()
const handleCitySearch = useDebouncedCallback(
async (e: string): Promise<void> => {
if (e.length < 3) {
setCities([])
return
}
const response = await getApi(url + `?scope=cities&q=` + encodeURI(e))
if (response.ok) {
let json = await response.json()
setCities(json)
} else {
setCities([])
}
},
1000
)
const handleWarehouseSearch = async (e: string): Promise<void> => {
const response = await getApi(`${url}?scope=warehouses&q=${e}`)
if (response.ok) {
let json = await response.json()
setWarehouses(json)
} else {
setWarehouses([])
}
}
const cityDescription = (citiesValue: string): string => {
const city: Settlement | undefined = cities.find(
(city: Settlement) => city.Description === citiesValue
)
if (!city) {
return ''
}
return formatSettlement(city, locale)
}
return (
<div className='py-2'>
<div>
<Popover open={citiesOpen} onOpenChange={setCitiesOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
/*aria-expanded={open}*/
className='w-full justify-between border border-brand-violet'
>
<span className='inline-flex items-center gap-x-3'>
{citiesValue ? (
<>
<MapPinCheck />
{cityDescription(citiesValue)}
</>
) : (
<>
<MapPinPlus />
{t('findSettlement')}
</>
)}
</span>
<ChevronsUpDown className='opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[640px] p-2'>
<Command>
<CommandInput
className='border border-brand-violet p-2'
placeholder={t('startSearchSettlement')}
onValueChange={(e: string) => handleCitySearch(e)}
/>
<CommandList>
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
{cities.map((city: Settlement) => (
<CommandItem
className='my-2 flex'
key={city?.Ref}
value={city?.Description}
onSelect={(currentValue: string) => {
setCitiesValue(
currentValue === citiesValue ? '' : currentValue
)
setCityRef(
currentValue === citiesValue ? '' : city?.Ref
)
handleWarehouseSearch(
currentValue === citiesValue ? '' : city?.Ref
).then(console.log)
setCitiesOpen(false)
}}
>
{formatSettlement(city, locale)}
<Check
className={cn(
'ml-auto',
citiesValue === city?.Description
? 'opacity-100'
: 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{cityRef !== '' && (
<div className='pt-3'>
<Popover open={warehousesOpen} onOpenChange={setWarehousesOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
/*aria-expanded={open}*/
className='w-full justify-between'
>
<span className='inline-flex items-center gap-x-3'>
<Warehouse />
{warehousesValue ? warehousesValue : t('selectWarehouse')}
</span>
<ChevronsUpDown className='opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[640px] p-2'>
<Command>
<CommandInput
className='p-2'
placeholder={t('startSearchWarehouse')}
/*onValueChange={(e: string) => handleCitySearch(e)}*/
/>
<CommandList>
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
{warehouses.map((warehouse: WarehouseType) => (
<CommandItem
className='my-2 flex'
key={warehouse.Ref}
value={warehouse.Description}
onSelect={(currentValue: string) => {
setWarehousesValue(
currentValue === warehousesValue ? '' : currentValue
)
/*setWarehouseRef(
currentValue === warehousesValue
? ''
: warehouse.Ref
)*/
onSelectHandler(
currentValue === warehousesValue
? {}
: {
Ref: warehouse.Ref,
Description: warehouse.Description,
DescriptionRu: warehouse.DescriptionRu
}
)
setWarehousesOpen(false)
}}
>
{warehouse.Description}
<Check
className={cn(
'ml-auto',
warehousesValue === warehouse.Description
? 'opacity-100'
: 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}

View File

@@ -1,46 +1,18 @@
'use client' import Cart from '@/app/[locale]/(root)/(shop)/cart/cart'
import {auth} from '@/auth'
import {SessionUser} from '@/types/auth'
import {useTranslations} from 'next-intl' export default async function Page() {
const session = await auth()
if (!session) {
return <Cart />
}
import CartItems from '@/components/pages/cart/items' const {user} = session
import useCartStore from '@/store/cart-store'
export default function Cart() { return session ? (
const t = useTranslations('cart') <Cart user={user as unknown as SessionUser} />
const {cartItems} = useCartStore() ) : (
const totalSum = cartItems.reduce( <Cart user={null} />
(total, product) => total + parseFloat(product.price) * product.quantity,
0
)
// const subtotal = items.reduce(
// (total, item) => total + item.price * item.quantity,
// 0
// )
// const total = subtotal
return (
<div className='mt-1'>
<div className='container'>
<section className='bw-cart-wrapper mx-auto my-8 max-w-[640px] text-brand-violet'>
<h1 className='mb-6 border-b border-b-brand-violet pb-6 text-3xl font-bold'>
{t('basket')}
</h1>
<header className='flex text-xl'>
<div className='col'>{t('title')}</div>
<div className='flex-none'>{t('quantity')}</div>
<div className='col text-right'>{t('amount')}</div>
</header>
<CartItems />
<footer className='my-8 flex border-y border-y-brand-violet py-4 text-xl'>
<div className='col'></div>
<div className='flex-none'>{t('total')}:</div>
<div className='col text-right font-bold'>
{totalSum.toFixed(2)} грн
</div>
</footer>
</section>
</div>
</div>
) )
} }

View File

@@ -0,0 +1,23 @@
export default function CartPostSubmit({result}: any) {
if (result?.success) {
return (
<div>
<h1 className='text-2xl'>
Номер Вашого замовлення:{' '}
<span className='font-semibold text-brand-violet-950'>
{result?.success}
</span>{' '}
</h1>
</div>
)
}
return (
<div>
<h1 className='text-2xl'>
Сталася помилка:{' '}
<span className='font-semibold text-red-800'>{result?.error}</span>{' '}
</h1>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import {EntityLocale} from '@prisma/client'
import type {Metadata} from 'next'
import {notFound} from 'next/navigation'
import {Suspense} from 'react'
import {getPageEntityBySlug} from '@/actions/admin/entity'
import YoutubeComponent from '@/components/shared/youtube-component'
import {dump, normalizeData, thisLocale} from '@/lib/utils'
import {Skeleton} from '@/ui/skeleton'
type Props = {
params: Promise<{slug?: string}>
}
export const generateMetadata = async ({params}: Props): Promise<Metadata> => {
const {slug} = await params
const page = await getPageEntityBySlug(slug || '')
if (!page) {
notFound()
}
const {locales} = page
const locale: EntityLocale = await thisLocale(locales)
const {title, annotation} = locale
return {
title,
description: normalizeData(annotation, {
stripTags: true
})
}
}
export default async function Pages({params}: Props) {
const {slug} = await params
const page = await getPageEntityBySlug(slug || '')
if (!page) {
notFound()
}
const {locales} = page
const locale: EntityLocale = await thisLocale(locales)
const {title, annotation, body} = locale
return (
<div className='mb-12 mt-8'>
<div className='bw-page container max-w-[800px] text-lg text-brand-violet-950'>
<h1>{title}</h1>
<section className='min-h-[450px]'>
<Suspense fallback={<Skeleton className='h-full w-full' />}>
<YoutubeComponent id='qfg2UlQk__M' />
</Suspense>
</section>
<article
className='mt-6'
dangerouslySetInnerHTML={{
__html: ((annotation ?? '') + body) as string
}}
></article>
{/*{dump(locale)}*/}
</div>
</div>
)
}

View File

@@ -1,6 +1,9 @@
import {getProductByIdWitData} from '@prisma/client/sql'
import {notFound} from 'next/navigation' import {notFound} from 'next/navigation'
import ProductPageIndex from '@/components/pages/product' import ProductPageIndex from '@/components/pages/product'
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
import {db} from '@/lib/db/prisma/client'
export default async function Products({ export default async function Products({
params params
@@ -12,5 +15,9 @@ export default async function Products({
const id = (uri || '').match(/^(\d+)-./) const id = (uri || '').match(/^(\d+)-./)
if (!id) notFound() if (!id) notFound()
return <ProductPageIndex id={id[1]} /> const data: CategoryPageSqlSchema[] = await db.$queryRawTyped(
getProductByIdWitData(id[1])
)
return <ProductPageIndex data={data} id={id[1]} />
} }

View File

@@ -1,11 +1,13 @@
import {getCatalogIndexData} from '@prisma/client/sql' import {getCatalogIndexData} from '@prisma/client/sql'
import {getLocale} from 'next-intl/server' import {getLocale} from 'next-intl/server'
import Image from 'next/image' import Image from 'next/image'
import React from 'react'
import FeatureCards from '@/components/shared/home/feature-cards' import FeatureCards from '@/components/shared/home/feature-cards'
import {HomeCarousel} from '@/components/shared/home/home-carousel' import {HomeCarousel} from '@/components/shared/home/home-carousel'
import AppCatalog from '@/components/shared/sidebar/app-catalog' import AppCatalog from '@/components/shared/sidebar/app-catalog'
import FeatureCardFront from '@/components/shared/store/feature-card-front' import FeatureCardFront from '@/components/shared/store/feature-card-front'
import Terms from '@/components/shared/terms'
import {carousels} from '@/lib/data' import {carousels} from '@/lib/data'
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas' import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
import {db} from '@/lib/db/prisma/client' import {db} from '@/lib/db/prisma/client'
@@ -31,9 +33,9 @@ import image from '@/public/uploads/products/IMG_6572.jpg'
// } // }
export default async function HomePage() { export default async function HomePage() {
const loc = await getLocale() const locale = await getLocale()
const catalog: CategoryPageSqlSchema[] = await db.$queryRawTyped( const catalog: CategoryPageSqlSchema[] = await db.$queryRawTyped(
getCatalogIndexData(loc) getCatalogIndexData(locale)
) )
return ( return (
@@ -51,8 +53,18 @@ export default async function HomePage() {
</div> </div>
</div> </div>
<section className='container mb-4 mt-[128px]'> <section className='container mb-4 mt-8'>
<FeatureCards items={catalog} /> <FeatureCards items={catalog} />
</section>
<Terms />
<section className='container mb-4 mt-12'>
<h2 className='font-heading text-center text-3xl font-bold uppercase tracking-tight text-brand-violet'>
{locale !== 'ru' ? "Цікаво про здоров'я" : 'Интересно о здоровье'}
</h2>
</section>
<section className='container mb-4 mt-8'>
<div className='re relative my-12 overflow-hidden'> <div className='re relative my-12 overflow-hidden'>
<Image <Image
alt={''} alt={''}

150
app/api/nova-post/route.ts Normal file
View File

@@ -0,0 +1,150 @@
'use server'
import {NextRequest} from 'next/server'
import {json} from 'node:stream/consumers'
import {type Warehouse} from '@/lib/nova-post-helper'
// , res: Response
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const scope = searchParams.get('scope')
let response: any = []
switch (scope) {
case 'cities':
response = await getCities(searchParams.get('q') || '...')
break
case 'warehouses':
response = await getWarehouses(searchParams.get('q') || '...')
break
case 'warehouse':
response = await getWarehouse(searchParams.get('q') || '...')
break
case 'streets':
response = await getStreet(searchParams.get('q') || '...')
break
}
return new Response(
JSON.stringify(response.success ? response.data : [], null, 2),
{
status: 200,
headers: {'Content-Type': 'application/json; charset=utf-8'}
}
)
}
async function fetchApi(init: RequestInit): Promise<Response> {
return await fetch(process.env.NOVA_POST_API_EP || '', init)
}
async function getWarehouses(CityRef: string, Page: number = 1) {
// const branches = []
let c = 0
// let n = 0
const Limit = 500
/*do {
const response = await fetchApi({
method: 'POST',
body: JSON.stringify({
apiKey: process.env.NOVA_POST_API_KEY || '',
modelName: 'AddressGeneral',
calledMethod: 'getWarehouses',
methodProperties: {
CityRef,
Page: ++c,
Limit,
FindByString: 'відд'
}
})
})
list = await response.json()
n = Math.ceil(list.info.totalCount / Limit)
for (const i in list.data) {
if (list.data[i].Description.trim().match(/^відді/iu)) {
branches.push(list.data[i])
}
}
} while (c < n)
list.data = branches*/
const response = await fetchApi({
method: 'POST',
body: JSON.stringify({
apiKey: process.env.NOVA_POST_API_KEY || '',
modelName: 'AddressGeneral',
calledMethod: 'getWarehouses',
methodProperties: {
CityRef,
Page: ++c,
Limit,
FindByString: 'відд'
}
})
})
const list = await response.json()
list.data = list.data.filter((item: Warehouse) =>
item.Description.trim().match(/^відді/iu)
)
//console.log(Math.ceil(list.info.totalCount / Limit), list.data.length)
return list
}
async function getWarehouse(Ref: string) {
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
method: 'POST',
body: JSON.stringify({
apiKey: process.env.NOVA_POST_API_KEY || '',
modelName: 'AddressGeneral',
calledMethod: 'getWarehouses',
methodProperties: {
Ref,
Limit: 1
}
})
})
return await response.json()
}
async function getCities(searchString: string) {
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
method: 'POST',
body: JSON.stringify({
apiKey: process.env.NOVA_POST_API_KEY || '',
modelName: 'AddressGeneral',
calledMethod: 'getCities',
methodProperties: {
FindByString: searchString,
Limit: 500
}
})
})
//console.log(searchString)
return await response.json()
}
async function getStreet(StreetName: string) {
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
method: 'POST',
body: JSON.stringify({
apiKey: process.env.NOVA_POST_API_KEY || '',
modelName: 'AddressGeneral',
calledMethod: 'searchSettlementStreets',
methodProperties: {
SettlementRef: 'e718a680-4b33-11e4-ab6d-005056801329',
Page: 1,
Limit: 50,
StreetName
}
})
})
return await response.json()
}

View File

@@ -4,10 +4,8 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { html, body {
margin: 0; @apply h-full m-0 p-0;
padding: 0;
overflow-y: scroll;
} }
@layer base { @layer base {
@@ -115,11 +113,11 @@ body {
} }
.bw-layout-col-left { .bw-layout-col-left {
@apply flex-1 sm:w-7/12 md:w-5/12 xl:w-4/12 lg:flex-col @apply flex-1 sm:w-7/12 md:w-5/12 xl:w-5/12 lg:flex-col
} }
.bw-layout-col-right { .bw-layout-col-right {
@apply sm:w-5/12 md:w-7/12 xl:w-8/12 flex-1 sm:flex-auto sm:pl-4 md:pl-7 xl:pl-9 @apply sm:w-5/12 md:w-7/12 xl:w-7/12 flex-1 sm:flex-auto sm:pl-4 md:pl-7 xl:pl-9
} }
.bw-product-col-left{ .bw-product-col-left{
@@ -134,7 +132,7 @@ body {
} }
.bw-header-col-right { .bw-header-col-right {
@apply flex-grow-0 flex-shrink-0 md:basis-[272px] @apply flex-grow-0 flex-shrink-0 md:basis-[142px]
} }
.bw-border-color { .bw-border-color {
@@ -146,6 +144,76 @@ body {
} }
} }
.bw-terms-section {
.bw-accordion-item {
@apply w-[100%] flex-none overflow-hidden border-b-0 md:w-[46.75%] lg:w-[29.25%]; /*shadow-md*/
/*&:hover {
@apply bg-brand-violet-800/25;
}*/
&[data-state="open"]{
@apply border-brand-violet-800/25 border-[2px] rounded-lg; /*shadow-md shadow-brand-violet-900/30*/
.bw-accordion-content {
@apply rounded-br-lg rounded-bl-lg bg-brand-violet-50/15; /*border-r-[2px] border-b-[2px] border-l-[2px]*/
}/*bg-brand-violet-50/50*/
}
button {
&[data-state="closed"] {
@apply border-[2px] rounded-lg; /*shadow-lg border-brand-violet-900/10*/
}
&[data-state="open"] {
@apply border-[2px] rounded-none py-2;
}
}
}
.bw-accordion-trigger {
@apply text-center text-base text-brand-violet antialiased font-semibold font-heading;
}
button {
padding: 10px 16px;
text-align: center;
font-size: 1rem;
&:hover, &[data-state="open"] {
@apply bg-brand-violet-800/25 text-white border-brand-violet-800/5; /*border-t-2*/
}
}
svg {
@apply w-6 h-6;
}
h3[data-state="open"], h3:hover {
& > button > svg {
@apply stroke-white;
}
}
.bw-accordion-content {
@apply px-4 text-[15px] leading-relaxed tracking-wide text-brand-violet-900;
p {
@apply block my-[1em] mx-0;
}
a {
@apply font-semibold text-brand-violet-600 hover:underline;
}
ul {
@apply block list-disc my-[1em] m-0 pl-10;
}
}
}
.bw-dd-menu { .bw-dd-menu {
/* since nested groupes are not supported we have to use /* since nested groupes are not supported we have to use
regular css for the nested dropdowns regular css for the nested dropdowns
@@ -165,6 +233,40 @@ body {
.group:hover .group-hover\:-rotate-180 { transform: rotate(180deg) } .group:hover .group-hover\:-rotate-180 { transform: rotate(180deg) }
} }
.bw-page {
@apply leading-relaxed tracking-tight mt-10;
h1 {
@apply font-heading my-[0.67em] mx-0 text-3xl font-bold;
}
h2 {
@apply font-heading mt-[0.83em] mb-[0.25em] mx-0 text-xl font-semibold;
}
p {
@apply block mb-[1em] mx-0;
}
/*a {
@apply font-semibold text-brand-violet-600 hover:underline;
}*/
ul {
@apply block list-disc my-[1em] m-0 pl-10;
}
article {
@apply leading-relaxed;
}
/*display: block;
font-size: 2em;
margin-top: 0.67em;
margin-bottom: 0.67em;
margin-left: 0;
margin-right: 0;
font-weight: bold;*/
}
.jodit-wysiwyg > * { .jodit-wysiwyg > * {
all: revert; all: revert;
@@ -180,6 +282,7 @@ body {
}*/ }*/
} }
#admin-bw-panel form { #admin-bw-panel form {
input { input {
@apply bg-white outline-0 text-[16px] leading-none; @apply bg-white outline-0 text-[16px] leading-none;
@@ -265,3 +368,12 @@ body {
flex: 1; flex: 1;
} }
} }
/*
https://www.w3schools.com/cssref/css_default_values.php*/
.bw-yt-video {
@apply aspect-video w-full self-stretch md:min-h-96;
}

View File

@@ -1,4 +1,5 @@
import type {Metadata} from 'next' import type {Metadata} from 'next'
import localFont from 'next/font/local'
import {headers} from 'next/headers' import {headers} from 'next/headers'
import {ReactNode} from 'react' import {ReactNode} from 'react'
import {Toaster} from 'react-hot-toast' import {Toaster} from 'react-hot-toast'
@@ -16,6 +17,42 @@ export const metadata: Metadata = {
description: APP_DESCRIPTION description: APP_DESCRIPTION
} }
const Myriad = localFont({
variable: '--font-myriad',
src: [
/*{
path: '../public/fonts/myriad-light.woff2',
weight: '300',
style: 'normal'
},*/
{
path: '../public/fonts/myriad-regular.woff2',
weight: '400',
style: 'normal'
},
/*{
path: '../public/fonts/myriad-it.woff2',
weight: '400',
style: 'italic'
},*/
{
path: '../public/fonts/myriad-semibold.woff2',
weight: '600',
style: 'normal'
},
{
path: '../public/fonts/myriad-bold.woff2',
weight: '700',
style: 'normal'
}
/*{
path: '../public/fonts/myriad-boldit.woff2',
weight: '700',
style: 'italic'
}*/
]
})
export default async function RootLayout({ export default async function RootLayout({
children children
}: Readonly<{children: ReactNode}>) { }: Readonly<{children: ReactNode}>) {
@@ -23,8 +60,8 @@ export default async function RootLayout({
const locale = headersList.get('x-site-locale') ?? routing.defaultLocale const locale = headersList.get('x-site-locale') ?? routing.defaultLocale
return ( return (
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning className={Myriad.variable}>
<body className='min-h-screen antialiased'> <body className={`min-h-screen antialiased`}>
{children} {children}
{/*<Toaster />*/} {/*<Toaster />*/}
<Toaster position='top-right' reverseOrder={false} /> <Toaster position='top-right' reverseOrder={false} />

View File

@@ -0,0 +1,347 @@
'use client'
import {zodResolver} from '@hookform/resolvers/zod'
import {EntityType} from '@prisma/client'
import dynamic from 'next/dynamic'
import React, {Suspense, useEffect, useMemo, useRef, useState} from 'react'
import {useFieldArray, useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
import {z} from 'zod'
import {onEntityCreateEditAction} from '@/actions/admin/entity'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import {i18nDefaultLocale, i18nLocales} from '@/i18n-config'
import {BaseEditorConfig} from '@/lib/config/editor'
import {
EntityTypeDescription,
createEntityFormSchema
} from '@/lib/schemas/admin/entity'
import {toEmptyParams} from '@/lib/utils'
import {Button} from '@/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/ui/form'
import {Input} from '@/ui/input'
import {Switch} from '@/ui/switch'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
const JoditEditor = dynamic(() => import('jodit-react'), {ssr: false})
let localesValues = {
type: '',
media: '',
title: '',
annotation: '',
body: ''
}
let metaValues = {
title: '',
description: '',
keywords: '',
author: ''
}
export const EntityCrudForm = ({data}: {data?: any}) => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [annotation0, setAnnotation0] = useState(
data?.locales[0].annotation || ''
)
const [annotation1, setAnnotation1] = useState(
data?.locales[1].annotation || ''
)
const [body0, setBody0] = useState(data?.locales[0].body || '')
const [body1, setBody1] = useState(data?.locales[1].body || '')
const editor = useRef(null) //declared a null value
const config = useMemo(() => BaseEditorConfig, [])
config.maxWidth = '100%'
const form = useForm<z.infer<typeof createEntityFormSchema>>({
resolver: zodResolver(createEntityFormSchema),
mode: 'onBlur',
defaultValues: data
? (data => {
const {locales, meta} = data
return {
published: data.published,
image: data.image,
locales: toEmptyParams(locales) as any,
meta: meta
? (toEmptyParams(meta) as any)
: [{...metaValues}, {...metaValues}]
}
})(data)
: {
scopes: '',
published: false,
media: '',
slug: '',
locales: [
{lang: 'uk', ...localesValues},
{lang: 'ru', ...localesValues}
],
meta: [{...metaValues}, {...metaValues}]
}
})
const {register, setValue} = form
useEffect(() => {
register('locales.0.annotation')
register('locales.1.annotation')
register('locales.0.body')
register('locales.1.body')
}, [register])
const {fields: localeFields} = useFieldArray({
name: 'locales',
control: form.control
})
const {fields: metaFields} = useFieldArray({
name: 'meta',
control: form.control
})
console.log(form.formState.errors)
const onSubmit = async (values: z.infer<typeof createEntityFormSchema>) => {
setLoading(true)
onEntityCreateEditAction(values).then((res: any) => {
if (res?.error) {
setError(res?.error)
setSuccess('')
setLoading(false)
toast.error(res?.error)
} else {
setSuccess(res?.success as string)
setError('')
setLoading(false)
toast.success(res?.success)
}
})
}
return (
<Form {...form}>
<form
action=''
onSubmit={form.handleSubmit(onSubmit)}
className='form-horizontal'
>
<div className='mx-auto my-4 w-full space-y-4'>
<h1 className='mb-6 text-center text-2xl font-bold text-brand-violet'>
Створити блок / статтю / сторінку
</h1>
<div className='my-4 space-y-4'>
<FormField
control={form.control}
name='published'
render={({field}) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border bg-gray-50 p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Опублікувати</FormLabel>
<FormDescription>
Відразу після збереження буде розміщено на сайті
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className='flex flex-row items-center justify-between gap-4 rounded-lg border bg-gray-50/25 px-4 pb-4'>
<div className='w-1/3'>
<FormField
control={form.control}
name='type'
render={({field}) => (
<FormItem>
<FormLabel>Тип сутності</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Потрібно обрати тип сутності' />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(EntityType).map(
(entityType: string, index: number) => (
<SelectItem key={index} value={entityType}>
{EntityTypeDescription[entityType] || entityType}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage className='ml-3' />
</FormItem>
)}
/>
</div>
<div className='w-1/3'>
<FormField
control={form.control}
name='slug'
render={({field}) => (
<FormItem>
<FormLabel>Аліас / Slug</FormLabel>
<FormControl>
<Input
type='text'
placeholder='вказіть аліас ресурсу'
{...field}
/>
</FormControl>
<FormMessage className='ml-3' />
</FormItem>
)}
/>
</div>
<div className='w-1/3'>
<FormField
control={form.control}
name='scopes'
render={({field}) => (
<FormItem>
<FormLabel>Область виведення</FormLabel>
<FormControl>
<Input
type='text'
placeholder='Веддіть дані у JSON форматі'
{...field}
/>
</FormControl>
<FormMessage className='ml-3' />
</FormItem>
)}
/>
</div>
</div>
</div>
<div className='flex flex-row items-center justify-between gap-4 rounded-lg border bg-gray-50 px-4 pb-4'>
<FormField
control={form.control}
name='media'
render={({field}) => (
<FormItem className='w-full'>
<FormLabel>
Медіа (файл на диску чи URL посилання на ресурс)
</FormLabel>
<FormControl>
<Input type='text' placeholder='' {...field} />
</FormControl>
<FormMessage className='ml-3' />
</FormItem>
)}
/>
</div>
<Tabs
defaultValue={i18nDefaultLocale}
className='mt-4 min-h-[560px] rounded-lg border p-4'
>
<TabsList className='grid w-full grid-cols-2'>
{i18nLocales.map(locale => (
<TabsTrigger key={locale.icon} value={locale.code}>
{locale.nameUkr}
</TabsTrigger>
))}
</TabsList>
{localeFields.map((_, index) => (
<TabsContent
id={`form-tab-${form.getValues(`locales.${index}.lang`)}`}
value={form.getValues(`locales.${index}.lang`)}
key={index}
className='space-y-4'
>
<FormField
control={form.control}
key={index}
name={`locales.${index}.lang`}
render={({field}) => (
<FormItem className={'w-full'}>
<FormControl>
<Input type='text' placeholder='' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='w-full'>
<FormField
control={form.control}
key={index + 1}
name={`locales.${index}.title`}
render={({field}) => (
<FormItem className={'w-full'}>
<FormLabel>Назва сутності</FormLabel>
<FormControl>
<Input placeholder='' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='w-full'>
<FormLabel>Анотація / Коротка назва</FormLabel>
<JoditEditor
key={index + 4}
ref={editor}
config={config}
value={index === 0 ? annotation0 : annotation1}
className='mt-4 w-full'
onBlur={value => {
index === 0 ? setAnnotation0(value) : setAnnotation1(value)
setValue(`locales.${index}.annotation`, value)
}}
/>
</div>
<div className='w-full'>
<FormLabel>Текст</FormLabel>
<JoditEditor
key={index + 4}
ref={editor}
config={config}
value={index === 0 ? body0 : body1}
className='mt-4 w-full'
onBlur={value => {
index === 0 ? setBody0(value) : setBody1(value)
setValue(`locales.${index}.body`, value)
}}
/>
</div>
</TabsContent>
))}
</Tabs>
<Button type='submit' className='float-right my-4 w-[200px]'>
Створити
</Button>
</form>
</Form>
)
}

View File

@@ -3,6 +3,8 @@ import {
Home, Home,
Inbox, Inbox,
LayoutList, LayoutList,
List,
Newspaper,
Plus, Plus,
ScanBarcode, ScanBarcode,
Search, Search,
@@ -44,6 +46,16 @@ const items = [
title: 'Товари', title: 'Товари',
url: `${ADMIN_DASHBOARD_PATH}/product`, url: `${ADMIN_DASHBOARD_PATH}/product`,
icon: ScanBarcode icon: ScanBarcode
},
{
title: 'Сутність',
url: `${ADMIN_DASHBOARD_PATH}/entity`,
icon: Newspaper
},
{
title: 'Замовлення',
url: `${ADMIN_DASHBOARD_PATH}/order`,
icon: List
} }
// { // {
// title: 'Search', // title: 'Search',
@@ -70,7 +82,7 @@ export function AdminSidebar() {
<SidebarGroupContent>SidebarGroupAction</SidebarGroupContent> <SidebarGroupContent>SidebarGroupAction</SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel> {/*<SidebarGroupLabel>Application</SidebarGroupLabel>*/}
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{items.map(item => ( {items.map(item => (
@@ -86,7 +98,7 @@ export function AdminSidebar() {
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<Collapsible defaultOpen className='group/collapsible'> <Collapsible defaultOpen className='group/collapsible' hidden={true}>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel asChild> <SidebarGroupLabel asChild>
<CollapsibleTrigger> <CollapsibleTrigger>

View File

@@ -1,5 +1,7 @@
import {Order} from '@prisma/client'
import {useTranslations} from 'next-intl' import {useTranslations} from 'next-intl'
import {getOrdersByUserId} from '@/actions/admin/order'
import {SignOutButton} from '@/components/auth/forms/sign-out-button' import {SignOutButton} from '@/components/auth/forms/sign-out-button'
import CabinetButton from '@/components/shared/header/cabinet-button' import CabinetButton from '@/components/shared/header/cabinet-button'
import {type SingedInSession} from '@/lib/permission' import {type SingedInSession} from '@/lib/permission'
@@ -11,7 +13,7 @@ import {
} from '@/ui/collapsible' } from '@/ui/collapsible'
import {Separator} from '@/ui/separator' import {Separator} from '@/ui/separator'
export default function CabinetIndex({ export default async function CabinetIndex({
slug, slug,
session session
}: { }: {
@@ -19,6 +21,7 @@ export default function CabinetIndex({
session: SingedInSession | null session: SingedInSession | null
}) { }) {
const t = useTranslations('cabinet') const t = useTranslations('cabinet')
const orders = await getOrdersByUserId(parseInt(session?.user.id as string))
return ( return (
<div className='my-8'> <div className='my-8'>
@@ -40,10 +43,24 @@ export default function CabinetIndex({
{t('personal-information.title')} {t('personal-information.title')}
</h1> </h1>
<Separator className='my-4' /> <Separator className='my-4' />
{orders.map((order: Order) => (
<div
key={order.id}
className='flex items-center justify-start gap-x-9 gap-y-6'
>
<div>{order.orderNo}</div>
<div>
{order.firstName} {order.surname}
</div>
<div>{order.phone}</div>
<div>{order.email}</div>
<div>{order.notes}</div>
</div>
))}
{/*<BasicEditor placeholder={'type something'} />*/} {/*<BasicEditor placeholder={'type something'} />*/}
{/*<Separator className='my-4' />*/} {/*<Separator className='my-4' />*/}
{slug ? <code>{slug[0]}</code> : <pre>{dump(session)}</pre>} {/*{slug ? <code>{slug[0]}</code> : <pre>{dump(session)}</pre>}*/}
<Collapsible> {/*<Collapsible>
<CollapsibleTrigger> <CollapsibleTrigger>
Can I use this in my project? Can I use this in my project?
</CollapsibleTrigger> </CollapsibleTrigger>
@@ -51,7 +68,7 @@ export default function CabinetIndex({
Yes. Free to use for personal and commercial projects. No Yes. Free to use for personal and commercial projects. No
attribution required. attribution required.
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>*/}
</section> </section>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,48 @@
input.bw-cart-item-counter{ .bwOrderForm {
font-size: 36px; & > h2, & > h3 {
background: chocolate; @apply text-center text-brand-violet text-2xl;
}
[role="tablist"] {
@apply bg-transparent rounded-none shadow-none m-0 pb-5 border-b-2 border-brand-violet h-[unset];
button {
@apply justify-start rounded-none font-normal text-xl pl-0;
}
[data-state=active] {
@apply text-brand-violet shadow-none;
}
[data-state=inactive] {
@apply text-gray-600 ;
}
}
}
.registeredForm{
/*@apply bg-brand-yellow-100;*/
& > h2, & > h3 {
@apply text-brand-violet text-2xl mt-9 mb-2;
}
fieldset {
@apply md:flex md:items-start md:justify-between md:gap-8
}
label {
@apply text-lg font-normal block mt-8 leading-none;
}
input {
@apply border-t-0 border-r-0 border-l-0 border-stone-400 rounded-none text-foreground text-lg p-0;
}
textarea {
@apply min-h-[72px] w-full border-b border-stone-400 p-2 ;
}
[role="combobox"] {
@apply w-full text-lg pl-0 text-foreground border-t-0 h-[unset] pb-2 border-r-0 border-l-0 rounded-none shadow-none border-stone-400;
}
} }

View File

@@ -1,16 +1,14 @@
// import styles from '@/components/pages/cart/cart.module.scss' // import styles from '@/components/pages/cart/cart.module.scss'
import {Minus, Plus} from 'lucide-react' import {Minus, Plus, X} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import {Link} from '@/i18n/routing'
import useCartStore, {CartItem} from '@/store/cart-store' import useCartStore, {CartItem} from '@/store/cart-store'
import {Button} from '@/ui/button' import {Button} from '@/ui/button'
export default function CartItems() { export default function CartItems({cartItems}: {cartItems: CartItem[]}) {
const {cartItems} = useCartStore()
const {increaseQuantity, decreaseQuantity, removeItemFromCart} = const {increaseQuantity, decreaseQuantity, removeItemFromCart} =
useCartStore() useCartStore()
const onIncreaseQuantity = (productId: number) => { const onIncreaseQuantity = (productId: number) => {
increaseQuantity(productId) increaseQuantity(productId)
} }
@@ -23,22 +21,33 @@ export default function CartItems() {
removeItemFromCart(productId) removeItemFromCart(productId)
} }
if (cartItems && cartItems.length > 0) { return (
return ( <>
<> {cartItems?.map((item: CartItem, i: number) => (
{cartItems?.map((item: CartItem, i: number) => ( <article key={i} className='bxg-emerald-200 mb-6'>
<div className='my-4 flex items-center' key={i}> <h3 className='bxg-brand-yellow-300 flex w-full items-center justify-between text-foreground'>
<div className='text-lg font-medium'>{item.title}</div>
<div className='w-16 flex-none text-right'>
<Button
variant={'ghost'}
className='rounded-0 h-[3rem] w-[3rem] px-0 text-brand-violet'
onClick={() => onRemoveItem(item.id)}
/*title={t('clear_cart')}*/
>
<X />
</Button>
</div>
</h3>
<div className='flex items-center'>
<div className='col'> <div className='col'>
{item.title}
<Image <Image
src={(item?.image || '').replace('.jpg', '-thumb.jpg')} src={(item?.image || '').replace('.jpg', '-thumb.jpg')}
alt='' alt=''
width={96} width={64}
height={96} height={64}
className='rounded-md border'
style={{ style={{
width: '96px', width: '64px',
height: '96px', height: '64px',
objectFit: 'cover' objectFit: 'cover'
}} }}
/> />
@@ -47,17 +56,17 @@ export default function CartItems() {
<div className='flex w-16 flex-none items-center justify-center'> <div className='flex w-16 flex-none items-center justify-center'>
<Button <Button
variant={'outline'} variant={'outline'}
className='rounded-0 h-[3rem] w-[3rem] text-2xl leading-none text-brand-violet' className='rounded-0 h-[3rem] w-[3rem] text-brand-violet'
onClick={() => onDecreaseQuantity(item.id)} onClick={() => onDecreaseQuantity(item.id)}
> >
<Minus /> <Minus />
</Button> </Button>
<div className='mx-4 text-xl font-bold leading-none text-brand-violet'> <div className='mx-4 text-xl font-bold text-brand-violet'>
{item.quantity} {item.quantity}
</div> </div>
<Button <Button
variant={'outline'} variant={'outline'}
className='rounded-0 h-[3rem] w-[3rem] text-2xl leading-none text-brand-violet' className='rounded-0 h-[3rem] w-[3rem] text-brand-violet'
onClick={() => onIncreaseQuantity(item.id)} onClick={() => onIncreaseQuantity(item.id)}
> >
<Plus /> <Plus />
@@ -68,19 +77,8 @@ export default function CartItems() {
{(item.price * item.quantity).toFixed(2)} грн {(item.price * item.quantity).toFixed(2)} грн
</div> </div>
</div> </div>
))} </article>
</> ))}
) </>
}
return (
<div className='flex h-72 flex-col items-center justify-center'>
<h2 className='mb-5 mt-10 text-3xl font-bold'>Cart is Empty</h2>
<Link
href={'/catalog'}
className='rounded-md bg-orange-500 px-6 py-2 text-white'
>
Продовжити покупки
</Link>
</div>
) )
} }

View File

@@ -0,0 +1,290 @@
'use client'
import {zodResolver} from '@hookform/resolvers/zod'
import {DeliveryOption} from '@prisma/client'
import {useLocale} from 'next-intl'
import React, {useEffect, useState} from 'react'
import {useForm} from 'react-hook-form'
import toast from 'react-hot-toast'
import {z} from 'zod'
import {onPlacingOrder} from '@/actions/admin/place-order'
import NovaPost from '@/app/[locale]/(root)/(shop)/cart/nova-post'
import SearchAddress from '@/components/pages/cart/search-address'
import {
DeliveryOptionTypeDescription,
createOrderFormSchema
} from '@/lib/schemas/admin/order'
import {dump} from '@/lib/utils'
import useCartStore from '@/store/cart-store'
import {SessionUser} from '@/types/auth'
import {Button} from '@/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/ui/form'
import {Input} from '@/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/ui/select'
export default function RegisteredOrderForm({
styles,
user,
onSubmitHandler
}: {
styles: string
user?: SessionUser | null
onSubmitHandler: any
}) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [deliveryOption, setDeliveryOption] = useState('')
const locale = useLocale()
const [warehouseRef, setWarehouseRef] = useState(JSON.stringify({}))
const warehouseSubmit = (warehouse: any) => {
setWarehouseRef(
Object.keys(warehouse).length > 0
? JSON.stringify(warehouse)
: JSON.stringify({})
)
setValue(
'address',
Object.keys(warehouse).length > 0
? JSON.stringify(warehouse)
: JSON.stringify({})
)
}
const {cartItems, clearCart} = useCartStore()
const form = useForm<z.infer<typeof createOrderFormSchema>>({
resolver: zodResolver(createOrderFormSchema),
mode: 'onBlur',
defaultValues: {
user_id: user ? user.id.toString() : '',
is_quick: false,
lang: locale,
first_name: '',
surname: '',
delivery_option: '',
phone: '',
email: '',
address: warehouseRef,
notes: '',
details: JSON.stringify(cartItems)
}
})
const {register, setValue} = form
useEffect(() => {
register('delivery_option')
register('address')
register('details')
}, [register])
const deliveryOptionHandler = (value: string) => {
setDeliveryOption(value)
setValue('delivery_option', value)
}
const onSubmit = async (values: z.infer<typeof createOrderFormSchema>) => {
setLoading(true)
setValue('details', JSON.stringify(cartItems))
onPlacingOrder(values).then((res: any) => {
if (res?.error) {
setError(res?.error)
setSuccess('')
setLoading(false)
toast.error(res?.error)
} else {
setSuccess(res?.success as string)
setError('')
setLoading(false)
clearCart()
toast.success(res?.success)
}
onSubmitHandler(res)
})
}
return (
<Form {...form}>
<form className={styles} action='' onSubmit={form.handleSubmit(onSubmit)}>
{/*<pre>{dump(user)}</pre>*/}
<h2>1. {locale !== 'ru' ? 'Особисті дані' : 'Личные данные'}</h2>
<fieldset>
<div className='md:w-1/2'>
<FormField
control={form.control}
name='first_name'
render={({field}) => (
<FormItem>
<FormLabel>{locale !== 'ru' ? "Ім'я" : 'Имя'}*</FormLabel>
<FormControl>
<Input type='text' placeholder='' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='md:w-1/2'>
<FormField
control={form.control}
name='surname'
render={({field}) => (
<FormItem>
<FormLabel>
{locale !== 'ru' ? 'Прізвище' : 'Фамилия'}*
</FormLabel>
<FormControl>
<Input type='text' placeholder='' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</fieldset>
<fieldset>
<div className='md:w-1/2'>
<FormField
control={form.control}
name='phone'
render={({field}) => (
<FormItem>
<FormLabel>Телефон*</FormLabel>
<FormControl>
<Input type='text' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='md:w-1/2'>
<FormField
control={form.control}
name='email'
render={({field}) => (
<FormItem>
<FormLabel>E-mail*</FormLabel>
<FormControl>
<Input type='text' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</fieldset>
<h2>
2.{' '}
{locale !== 'ru'
? 'Інформація про доставку'
: 'Информация о доставке'}
</h2>
<fieldset>
<FormField
control={form.control}
name='delivery_option'
render={({field}) => (
<FormItem className='block w-full'>
<FormLabel>
{locale !== 'ru'
? 'Варіанти доставки'
: 'Варианты доставки'}{' '}
</FormLabel>
<Select
/*onValueChange={field.onChange}*/
onValueChange={deliveryOptionHandler}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
locale !== 'ru'
? 'Оберіть варіант доставки'
: 'Выберите вариант доставки'
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(DeliveryOption).map(
(option: string, index: number) => (
<SelectItem key={index} value={option}>
{DeliveryOptionTypeDescription[option] || option}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage className='ml-3' />
</FormItem>
)}
/>
</fieldset>
{deliveryOption === 'NP' && (
<NovaPost onSelectHandler={warehouseSubmit} />
)}
{deliveryOption === 'COURIER' && (
/*<NovaPost onWarehouseSelect={warehouseSubmit} />*/
<SearchAddress onSelectHandler={warehouseSubmit} />
)}
{deliveryOption === 'PICKUP' && (
<div className='py-6 text-lg'>Дані де і коли можна забрати</div>
)}
<fieldset>
<FormField
control={form.control}
name='notes'
render={({field}) => (
<FormItem className='block w-full'>
<FormLabel>
{locale !== 'ru'
? 'Додаткова інформація'
: 'Дополнительная информация'}
</FormLabel>
<FormControl>
<textarea {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<fieldset>
{/*<pre>{warehouseRef}</pre>*/}
<Button
type='submit'
size={'lg'}
className='float-right mx-auto mb-6 mt-16 h-[unset] w-[200px] py-2 text-xl text-brand-violet'
>
{locale !== 'ru' ? 'Оформити замовлення' : 'Оформить заказ'}
</Button>
</fieldset>
</form>
</Form>
)
}

View File

@@ -0,0 +1,152 @@
'use client'
import {
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from 'cmdk'
import {Check, ChevronsUpDown, MapPinCheck, MapPinPlus} from 'lucide-react'
import {useLocale, useTranslations} from 'next-intl'
import {useState} from 'react'
import {useDebouncedCallback} from 'use-debounce'
import {
type Settlement,
Street,
formatSettlement,
getApi
} from '@/lib/nova-post-helper'
import {cn, dump} from '@/lib/utils'
import {Button} from '@/ui/button'
import {Command} from '@/ui/command'
import {Popover, PopoverContent, PopoverTrigger} from '@/ui/popover'
const url = '/api/nova-post'
export default function SearchAddress({
onSelectHandler
}: {
onSelectHandler: any
}) {
const t = useTranslations('cart.post')
const locale = useLocale()
const [streets, setStreets] = useState([])
const [streetsOpen, setStreetsOpen] = useState(false)
const [streetsValue, setStreetsValue] = useState('')
const handleStreetSearch = useDebouncedCallback(
async (e: string): Promise<void> => {
if (e.length < 3) {
setStreets([])
return
}
const response = await getApi(url + `?scope=streets&q=` + encodeURI(e))
if (response.ok) {
let json = JSON.parse(JSON.stringify(await response.json()))
const {Addresses} = json[0]
setStreets(Addresses)
} else {
setStreets([])
}
},
1000
)
const streetDescription = (streetsValue: string): string => {
const street: Street | undefined = streets.find(
(street: Street) => street.Present === streetsValue
)
if (!street) {
return ''
}
return streetsValue
}
return (
<div className='py-2'>
{/*<pre>{dump(streets[0]['Addresses'])}</pre>*/}
<div>
<Popover open={streetsOpen} onOpenChange={setStreetsOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
/*aria-expanded={open}*/
className='w-full justify-between border border-brand-violet'
>
<span className='inline-flex items-center gap-x-3'>
{streetsValue ? (
<>
<MapPinCheck />
{streetDescription(streetsValue)}
</>
) : (
<>
<MapPinPlus />
{locale !== 'ru' ? 'Шукати вулицю' : 'Искать улицу'}
</>
)}
</span>
<ChevronsUpDown className='opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[640px] p-2'>
<Command>
<CommandInput
className='border border-brand-violet p-2'
placeholder={locale !== 'ru' ? 'Почати пошук' : 'Начать поиск'}
onValueChange={(e: string) => handleStreetSearch(e)}
/>
<CommandList>
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
{streets.map((street: Street, index: number) => (
<CommandItem
className='my-2 flex'
key={index}
value={street?.Present}
onSelect={(currentValue: string) => {
setStreetsValue(
currentValue === streetsValue ? '' : currentValue
)
onSelectHandler(
currentValue === streetsValue
? {}
: {
Ref: street.SettlementStreetRef,
Description: street.Present,
DescriptionRu:
street.SettlementStreetDescriptionRu
}
)
setStreetsOpen(false)
}}
>
{street?.Present}
<Check
className={cn(
'ml-auto',
streetsValue === street?.Present
? 'opacity-100'
: 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@@ -25,22 +25,23 @@ import {Link} from '@/i18n/routing'
import {getMetaOfFile} from '@/lib/config/resources' import {getMetaOfFile} from '@/lib/config/resources'
import {getProductResources} from '@/lib/data/models/product' import {getProductResources} from '@/lib/data/models/product'
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas' import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
import {db} from '@/lib/db/prisma/client'
import {dump, thisLocale, toPrice} from '@/lib/utils' import {dump, thisLocale, toPrice} from '@/lib/utils'
import {Button} from '@/ui/button' import {Button} from '@/ui/button'
import {Separator} from '@/ui/separator' import {Separator} from '@/ui/separator'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
export default async function ProductPageIndex({id}: {id: string}) { export default async function ProductPageIndex({
const t = await getTranslations('Common') data,
id
const data: CategoryPageSqlSchema[] = await db.$queryRawTyped( }: {
getProductByIdWitData(id) data: CategoryPageSqlSchema[]
) id: string
}) {
const locale = await thisLocale(data) const locale = await thisLocale(data)
if (!locale) notFound() if (!locale) notFound()
const t = await getTranslations('Common')
const resources: ProductResource[] | null = await getProductResources( const resources: ProductResource[] | null = await getProductResources(
parseInt(id) parseInt(id)
) )
@@ -70,11 +71,11 @@ export default async function ProductPageIndex({id}: {id: string}) {
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className='flex w-[82%] items-center justify-between'> <div className='flex w-full items-center justify-between'>
<h1 className='my-4 text-3xl font-bold text-brand-violet-950'> <h1 className='font-heading mt-4 text-3xl font-semibold'>
{locale.title} {locale.title}
</h1> </h1>
<AddCartButton {/*<AddCartButton
product={{ product={{
id: locale.productId, id: locale.productId,
quantity: 1, quantity: 1,
@@ -82,9 +83,9 @@ export default async function ProductPageIndex({id}: {id: string}) {
price: toPrice(locale.price), price: toPrice(locale.price),
image: locale.image image: locale.image
}} }}
/> />*/}
</div> </div>
<Separator className='my-4 w-[82%] border-b border-brand-violet' /> <Separator className='my-4 h-0 border-b-2 border-brand-violet' />
<ProductCarousel images={resources} title={locale.title} /> <ProductCarousel images={resources} title={locale.title} />
<Tabs defaultValue='article' className=''> <Tabs defaultValue='article' className=''>
<TabsList className='grid w-full grid-cols-2'> <TabsList className='grid w-full grid-cols-2'>

View File

@@ -11,7 +11,7 @@ export default function Above() {
const t = useTranslations('Banner.Above') const t = useTranslations('Banner.Above')
return ( return (
<div className='flex w-full items-end justify-center bg-brand-violet md:min-h-[43px] xl:min-h-[51px]'> <div className='bw-above flex h-[51px] w-full items-end justify-center bg-brand-violet'>
<div className='mx-0 mb-0.5'> <div className='mx-0 mb-0.5'>
<Image <Image
width={72.79} width={72.79}

View File

@@ -2,26 +2,40 @@
// 31:47 // 31:47
import {ChevronUp} from 'lucide-react' import {ChevronUp} from 'lucide-react'
import {useLocale} from 'next-intl'
import SocialMediaPanel from '@/components/shared/social-media-panel' import SocialMediaPanel from '@/components/shared/social-media-panel'
import {Link} from '@/i18n/routing'
import {Button} from '@/ui/button' import {Button} from '@/ui/button'
export default function Footer() { export default function Footer() {
const locale = useLocale()
return ( return (
<footer className='bg-brand-violet w-full py-3 text-white'> <footer className='w-full bg-brand-violet py-3 text-white'>
<div className='container flex items-center justify-between'> <div className='container flex items-center justify-between'>
<div>Політика конфіденційності</div> <div>
<div>Договір оферти</div> <Link href={'/privacy-policy'}>
{locale === 'uk'
? 'Політика конфіденційності'
: 'Политика конфиденциальности'}
</Link>
</div>
<div>
<Link href={'/offer-contract'}>
{locale === 'uk' ? 'Договір оферти' : 'Договор оферты'}
</Link>
</div>
<div>Доставка і повернення</div> <div>Доставка і повернення</div>
<div>Контакти</div> <div>Контакти</div>
<SocialMediaPanel color='#fff' /> <SocialMediaPanel color='#fff' />
<Button {/*<Button
variant='ghost' variant='ghost'
className='bg-brand-violet rounded-none' className='rounded-none bg-brand-violet'
onClick={() => window.scrollTo({top: 0, behavior: 'smooth'})} onClick={() => window.scrollTo({top: 0, behavior: 'smooth'})}
> >
<ChevronUp className='mr-2 h-4 w-4' /> <ChevronUp className='mr-2 h-4 w-4' />
</Button> </Button>*/}
</div> </div>
</footer> </footer>
) )

View File

@@ -9,13 +9,13 @@ export default function HeaderControls() {
const t = useTranslations('cart') const t = useTranslations('cart')
return ( return (
<div className='flex w-full justify-end gap-x-6 text-brand-violet'> <div className='flex w-full items-center justify-end gap-x-2 text-brand-violet'>
<CabinetButton /> <CabinetButton />
<Link href={'#' as never} className='header-button' aria-label='Вибране'> <Link href={'#' as never} className='header-button' aria-label='Вибране'>
<button className='flex flex-col items-center' role='button'> <button className='flex flex-col items-center' role='button'>
<Heart className='h-[21px] w-[21px]' /> <Heart className='h-[21px] w-[21px]' />
<span className='font1-bold text-sm'>{t('favorites')}</span> {/*<span className='font1-bold text-sm'>{t('favorites')}</span>*/}
</button> </button>
</Link> </Link>

View File

@@ -4,21 +4,30 @@ import {ShoppingCartIcon} from 'lucide-react'
import {useTranslations} from 'next-intl' import {useTranslations} from 'next-intl'
import {Link} from '@/i18n/routing' import {Link} from '@/i18n/routing'
import useCartStore from '@/store/cart-store' import useCartStore, {CartItem} from '@/store/cart-store'
export default function HeaderShoppingCartIcon() { export default function HeaderShoppingCartIcon() {
const t = useTranslations('cart') const t = useTranslations('Common')
const {cartItems} = useCartStore() const {cartItems} = useCartStore()
const cartCount = cartItems.length const cartCount = cartItems.reduce(
(accumulator: number, item: CartItem) => accumulator + item.quantity,
0
)
return ( return (
<Link href={'/cart' as never} className='header-button' aria-label='Кошик'> <Link
href={'/cart' as never}
className='header-button relative'
aria-label={t('basket')}
>
<button className='flex flex-col items-center' role='button'> <button className='flex flex-col items-center' role='button'>
<ShoppingCartIcon className='h-[21px] w-[21px]' /> <ShoppingCartIcon className='h-[21px] w-[21px]' />
<span className='font1-bold text-sm'> {cartCount > 0 && (
{t('basket')} [{cartCount}] <div className='absolute -right-1 -top-1 h-[20px] w-[20px] rounded-full border border-brand-violet bg-brand-yellow pl-0 pr-[1px] text-[0.625rem] font-bold leading-[18px]'>
</span> {cartCount}
</div>
)}
</button> </button>
</Link> </Link>
) )

View File

@@ -1,20 +1,25 @@
'use client' 'use client'
import {useLocale, useTranslations} from 'next-intl'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link'
// TODO: Link throwing no connection error in this component; React 19 Bug
// import {Link} from '@/i18n/routing'
import {APP_NAME} from '@/lib/constants' import {APP_NAME} from '@/lib/constants'
import logoImg from '@/public/images/logo.svg' import logoImg from '@/public/images/logo.svg'
export default function Logo() { export default function Logo() {
const t = useTranslations('Common')
const locale = useLocale()
const ar = 121 / 192 const ar = 121 / 192
const w = 112 const w = 112
return ( return (
<div className='mt-0.5 flex items-center justify-center'> <div className='mt-0.5 flex items-center justify-center'>
<Link <a
href='/' href={locale !== 'ru' ? '/' : '/ru'}
className='m-1 flex cursor-pointer items-center pt-[7px] text-2xl font-extrabold outline-0' className='m-1 flex cursor-pointer items-center pt-[7px] text-2xl font-extrabold outline-0'
aria-label={t('home')}
> >
<Image <Image
src={logoImg} src={logoImg}
@@ -23,7 +28,7 @@ export default function Logo() {
alt={`${APP_NAME} logo`} alt={`${APP_NAME} logo`}
className='w-[131]' className='w-[131]'
/> />
</Link> </a>
</div> </div>
) )
} }

View File

@@ -21,7 +21,9 @@ export default function LocaleSwitcher() {
path: '/', path: '/',
sameSite: 'Lax' sameSite: 'Lax'
}) })
router.replace(window.location.pathname.replace(/^\/ru/, ''), {locale}) let path = window.location.pathname.replace(/^\/ru/, '')
if (path === '') path = '/'
router.replace(path, {locale})
} }
return ( return (

View File

@@ -22,7 +22,11 @@ export default function NavbarMenu() {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
{data[locale === 'uk' ? 'headerMenus' : 'headerMenusRus'].map( {data[locale === 'uk' ? 'headerMenus' : 'headerMenusRus'].map(
item => ( item => (
<Link href='/about-us' className='' key={item.name}> <Link
href='/about-us'
className='hover:[text-shadow:_0_1px_2px_rgb(99_102_241_/_0.6)]'
key={item.name}
>
{item.name} {item.name}
</Link> </Link>
) )

View File

@@ -11,7 +11,7 @@ export default function AppCatalogRender(data: {items: Category[]}) {
return ( return (
<div className='flex w-full justify-center'> <div className='flex w-full justify-center'>
<div className='bw-dd-menu group inline-block w-full'> <div className='bw-dd-menu b group inline-block w-full'>
<Link href='/catalog'> <Link href='/catalog'>
<Button className='py-13 flex h-10 w-full items-center rounded-sm border-none bg-brand-yellow-300 px-3 outline-none focus:outline-none'> <Button className='py-13 flex h-10 w-full items-center rounded-sm border-none bg-brand-yellow-300 px-3 outline-none focus:outline-none'>
<span className='flex-1 pr-1 font-semibold'>Каталог</span> <span className='flex-1 pr-1 font-semibold'>Каталог</span>
@@ -29,12 +29,12 @@ export default function AppCatalogRender(data: {items: Category[]}) {
<ul className='-bw-app-catalog-collapse mt-2 w-full min-w-32 origin-top transform rounded-sm border bg-white shadow-xl transition duration-300 ease-in-out group-hover:scale-100 hover:shadow-2xl'> <ul className='-bw-app-catalog-collapse mt-2 w-full min-w-32 origin-top transform rounded-sm border bg-white shadow-xl transition duration-300 ease-in-out group-hover:scale-100 hover:shadow-2xl'>
{data?.items.map((item: any) => ( {data?.items.map((item: any) => (
<li <li
className='cursor-pointer rounded-none py-2.5 pl-3 pr-1.5 text-sm font-medium hover:bg-[#442d88]/10 xl:py-3' className='pay-2.5 cursor-pointer rounded-none pl-3 pr-1.5 text-sm font-medium hover:bg-[#442d88]/10 xl:py-2'
key={item.id} key={item.id}
> >
<button className='flex w-full items-center text-left outline-none focus:outline-none'> <button className='flex w-full items-center text-left outline-none focus:outline-none'>
<Link <Link
className='flex-1 pr-1 leading-none xl:leading-[1.3]' className='flex-1 pr-1 leading-none xl:leading-[1.275]'
href={`/category/${item.locales[locale === 'uk' ? 0 : 1].slug}`} href={`/category/${item.locales[locale === 'uk' ? 0 : 1].slug}`}
> >
{item.locales[locale === 'uk' ? 0 : 1].title} {item.locales[locale === 'uk' ? 0 : 1].title}

View File

@@ -18,7 +18,7 @@ export default function CardBuyButton({
return ( return (
<Button <Button
className={`mr-2 ${isIcon ? '' : 'w-[80px]'} grow-0 shadow-white hover:shadow-md hover:shadow-brand-violet/50`} className={`mr-1.5 ${isIcon ? '' : 'w-[80px]'} z-50 grow-0 shadow-white hover:shadow-md hover:shadow-brand-violet/50`}
onClick={() => addItemToCart(item)} onClick={() => addItemToCart(item)}
> >
{isIcon ? ( {isIcon ? (

View File

@@ -1,14 +1,10 @@
import {ProductResource} from '@prisma/client'
import {Star, StarHalf} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import CardBuyButton from '@/components/shared/store/card-buy-button' import CardBuyButton from '@/components/shared/store/card-buy-button'
import RateStars from '@/components/shared/store/stars' import RateStars from '@/components/shared/store/stars'
import {Link} from '@/i18n/routing' import {Link} from '@/i18n/routing'
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas' import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
import {Button} from '@/ui/button'
import {Card, CardContent, CardFooter} from '@/ui/card' import {Card, CardContent, CardFooter} from '@/ui/card'
import {CarouselItem} from '@/ui/carousel'
export default function FeatureCardFront({ export default function FeatureCardFront({
card card
@@ -18,7 +14,7 @@ export default function FeatureCardFront({
return ( return (
<Card className='relative aspect-card overflow-hidden border-[2px] border-brand-violet transition duration-300 hover:shadow-lg hover:shadow-brand-violet/50'> <Card className='relative aspect-card overflow-hidden border-[2px] border-brand-violet transition duration-300 hover:shadow-lg hover:shadow-brand-violet/50'>
<Link href={`/product/${card.productId}-${card.slug}`}> <Link href={`/product/${card.productId}-${card.slug}`}>
<CardContent className='relative flex h-[81%] flex-col justify-between overflow-hidden pt-4'> <CardContent className='relative flex h-[76%] flex-col justify-between overflow-hidden pt-4'>
{/*<CarouselItem>*/} {/*<CarouselItem>*/}
<Image <Image
className='transition duration-300 hover:scale-110' className='transition duration-300 hover:scale-110'
@@ -39,12 +35,12 @@ export default function FeatureCardFront({
{/*</CarouselItem>*/} {/*</CarouselItem>*/}
</CardContent> </CardContent>
</Link> </Link>
<div className='bw-card-footer flex h-[19%] items-center justify-between border-t-[2px] border-brand-violet px-4'> <div className='bw-card-footer flex h-[24%] items-center justify-between border-t-[2px] border-brand-violet px-2'>
<div className=''> <div className=''>
<p className='ml-2 border-b border-b-brand-violet pl-2 pr-6 text-[16px]'> <p className='font-heading ml-1 border-b border-b-brand-violet pb-0.5 pl-1 pr-3'>
{card.title} {card.title}
</p> </p>
<p className='pl-4 text-[16px] font-bold text-brand-violet'> <p className='pl-2 text-[18px] font-bold text-brand-violet'>
{parseFloat(card.price as string).toFixed(2)} {parseFloat(card.price as string).toFixed(2)}
</p> </p>
</div> </div>
@@ -55,17 +51,9 @@ export default function FeatureCardFront({
title: card.title, title: card.title,
price: parseFloat(card.price as string).toFixed(2) price: parseFloat(card.price as string).toFixed(2)
}} }}
isIcon={true} isIcon={false}
/> />
</div> </div>
</Card> </Card>
) )
} }
// id: number
// quantity: number
// title: string
// price: string | any
// image?: string | null
// imageWidth?: number | null
// imageHeight?: number | null

View File

@@ -1,20 +1,15 @@
import {Star, StarHalf} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import CardBuyButton from '@/components/shared/store/card-buy-button' import CardBuyButton from '@/components/shared/store/card-buy-button'
import RateStars from '@/components/shared/store/stars' import RateStars from '@/components/shared/store/stars'
import {Link} from '@/i18n/routing' import {Link} from '@/i18n/routing'
import {Button} from '@/ui/button'
import {Card, CardContent, CardFooter} from '@/ui/card' import {Card, CardContent, CardFooter} from '@/ui/card'
//import {CarouselItem} from '@/ui/carousel'
export default function FeatureCard({card}: {card: any}) { export default function FeatureCard({card}: {card: any}) {
return ( return (
<Card className='relative aspect-card overflow-hidden border-[2px] border-brand-violet transition duration-300 hover:shadow-lg hover:shadow-brand-violet/50'> <Card className='relative aspect-card overflow-hidden border-[2px] border-brand-violet transition duration-300 hover:shadow-lg hover:shadow-brand-violet/50'>
<Link href={`/product/${card.productId}-${card.slug}`}> <Link href={`/product/${card.productId}-${card.slug}`}>
<CardContent className='relative flex h-[81%] flex-col justify-between overflow-hidden pt-4'> <CardContent className='relative flex h-[76%] flex-col justify-between overflow-hidden pt-4'>
{/*<CarouselItem>*/}
<Image <Image
className='transition duration-300 hover:scale-110' className='transition duration-300 hover:scale-110'
src={card.image} src={card.image}
@@ -33,12 +28,12 @@ export default function FeatureCard({card}: {card: any}) {
<RateStars /> <RateStars />
</CardContent> </CardContent>
</Link> </Link>
<div className='bw-card-footer flex h-[19%] items-center justify-between border-t-[2px] border-brand-violet px-4'> <div className='bw-card-footer flex h-[24%] items-center justify-between border-t-[2px] border-brand-violet px-1'>
<div className=''> <div className=''>
<p className='ml-2 border-b border-b-brand-violet pl-2 pr-6'> <p className='font-heading ml-1 border-b border-b-brand-violet pb-0.5 pl-1 pr-3'>
{card.title} {card.title}
</p> </p>
<p className='pl-4 text-[18px] font-bold text-brand-violet'> <p className='pl-2 text-[18px] font-bold text-brand-violet'>
{parseFloat(card.price).toFixed(2)} {parseFloat(card.price).toFixed(2)}
</p> </p>
</div> </div>

View File

@@ -2,15 +2,16 @@ import {Star} from 'lucide-react'
const startStroke = 1.5 const startStroke = 1.5
const color = '#ffd139' const color = '#ffd139'
const size = 16
export default function RateStars() { export default function RateStars() {
return ( return (
<div className='bw-rating absolute bottom-2 left-4 inline-flex h-[32px] items-center gap-1'> <div className='bw-rating absolute bottom-1 left-3 inline-flex h-[32px] items-center gap-1'>
<Star strokeWidth={startStroke} color={color} fill={color} /> <Star size={size} strokeWidth={startStroke} color={color} fill={color} />
<Star strokeWidth={startStroke} color={color} fill={color} /> <Star size={size} strokeWidth={startStroke} color={color} fill={color} />
<Star strokeWidth={startStroke} color={color} fill={color} /> <Star size={size} strokeWidth={startStroke} color={color} fill={color} />
<Star strokeWidth={startStroke} color={color} /> <Star size={size} strokeWidth={startStroke} color={color} />
<Star strokeWidth={startStroke} color={color} /> <Star size={size} strokeWidth={startStroke} color={color} />
</div> </div>
) )
} }

View File

@@ -0,0 +1,55 @@
import {Entity, EntityLocale, type Lang} from '@prisma/client'
import {useLocale} from 'next-intl'
import {getBlockEntity} from '@/actions/admin/entity'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@/components/ui/accordion'
import {locales} from '@/i18n/routing'
import {EntityTerm} from '@/lib/schemas/admin/entity'
import {dump, thisLocale} from '@/lib/utils'
export default async function Terms() {
const locale = useLocale()
const terms = await getBlockEntity('terms')
return (
<section className='container mb-4 mt-12'>
{/*<pre>{dump(terms)}</pre>*/}
<div className='bw-terms-section mx-auto max-w-[1080px]'>
<Accordion
type='single'
collapsible
className='flex w-full flex-wrap justify-between gap-y-4'
>
{terms.map(async (term: any, index: number) => {
const {locales} = term
const locale: EntityLocale = await thisLocale(locales)
const {title, body} = locale
return (
<AccordionItem
key={index}
value={`item-${index}`}
className='bw-accordion-item'
>
<AccordionTrigger className='bw-accordion-trigger'>
<div className='flex-grow'>{title}</div>
</AccordionTrigger>
<AccordionContent className='bw-accordion-content'>
<span
dangerouslySetInnerHTML={{__html: body as string}}
></span>
</AccordionContent>
</AccordionItem>
)
})}
{/*<div className='bw-accordion-item table-cell'></div>*/}
</Accordion>
</div>
</section>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import YouTube, {YouTubeProps} from 'react-youtube'
export default function YoutubeComponent({id}: {id: string}) {
const onPlayerReady: YouTubeProps['onReady'] = e => {
e.target.pauseVideo()
}
const opts: YouTubeProps['opts'] = {
height: '100%',
width: '100%',
playerVars: {
// https://developers.google.com/youtube/player_parameters
autoplay: 0
}
}
return (
<YouTube
id={`video-yt-${id}`}
videoId={id}
opts={opts}
onReady={onPlayerReady}
iframeClassName='bw-yt-video'
/>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

153
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

33
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -1,6 +1,6 @@
export const BaseEditorConfig = { export const BaseEditorConfig = {
readonly: false, // all options from https://xdsoft.net/jodit/docs/, readonly: false, // all options from https://xdsoft.net/jodit/docs/,
placeholder: 'Start typings...', placeholder: 'Почніть введення...',
spellcheck: true, spellcheck: true,
language: 'ua', language: 'ua',
//toolbarAdaptive: false, //toolbarAdaptive: false,
@@ -10,9 +10,9 @@ export const BaseEditorConfig = {
//defaultActionOnPaste: 'insert_as_text', //defaultActionOnPaste: 'insert_as_text',
//defaultActionOnPaste: 'insert_only_text', //defaultActionOnPaste: 'insert_only_text',
//disablePlugins: 'ai-assistant,mobile,print,speech-recognize,table,table-keyboard-navigation,powered-by-jodit,iframe', //disablePlugins: 'ai-assistant,mobile,print,speech-recognize,table,table-keyboard-navigation,powered-by-jodit,iframe',
minHeight: 240, minHeight: '240',
maxHeight: 640, maxHeight: '640',
maxWidth: 890, maxWidth: '890',
uploader: { uploader: {
insertImageAsBase64URI: true, insertImageAsBase64URI: true,
imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'svg', 'webp'] imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'svg', 'webp']

View File

@@ -0,0 +1,32 @@
model Entity {
id Int @id @default(autoincrement())
type EntityType
published Boolean? @default(false)
scopes Json?
position Int? @default(0) @db.UnsignedSmallInt
slug String? @db.VarChar(512)
media String? @db.VarChar(512)
storeId Int @default(1) @map("store_id")
locales EntityLocale[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@unique([storeId, type, slug])
@@map("entities")
}
model EntityLocale {
id Int @id @default(autoincrement())
lang Lang @default(uk)
slug String? @db.VarChar(512)
title String @db.VarChar(384)
annotation String? @db.MediumText
body String? @db.MediumText
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
entityId Int @map("entity_id")
meta Meta? @relation(fields: [metaId], references: [id], onDelete: Cascade)
metaId Int? @map("meta_id")
@@unique([slug, lang])
@@map("entity_locale")
}

View File

@@ -1,8 +1,20 @@
enum DeliveryOption {
NP
PICKUP
COURIER
}
enum Lang { enum Lang {
uk uk
ru ru
} }
enum EntityType {
article
block
page
}
enum Unit { enum Unit {
mkg mkg
mg mg

View File

@@ -23,6 +23,7 @@ model Meta {
openGraph OpenGraph? openGraph OpenGraph?
storeLocale StoreLocale[] storeLocale StoreLocale[]
productLocale ProductLocale[] productLocale ProductLocale[]
entityLocale EntityLocale[]
//vendorLocale VendorLocale[] //vendorLocale VendorLocale[]
@@map("meta") @@map("meta")

View File

@@ -0,0 +1,26 @@
model Order {
id Int @id @default(autoincrement())
storeId Int @default(1) @map("store_id")
lang Lang
orderNo String @map("order_no") @db.VarChar(45)
isQuick Boolean @map("is_quick")
user User? @relation(fields: [userId], references: [id])
userId Int? @map("user_id")
firstName String @map("first_name") @db.VarChar(255)
patronymic String? @db.VarChar(255)
surname String? @db.VarChar(255)
deliveryOption DeliveryOption? @map("delivery_option")
phone String? @db.Char(24)
email String? @db.VarChar(320)
emailSent Boolean? @map("email_sent")
/// [OrderAddressType]
address Json?
notes String? @db.MediumText
/// [OrderDetailsType]
details Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
@@unique([orderNo, storeId])
@@map("orders")
}

View File

@@ -27,6 +27,7 @@ model User {
extendedData Json? @map("extended_data") @db.Json extendedData Json? @map("extended_data") @db.Json
// orders Order[] // orders Order[]
favorites UserFavouriteProduct[] favorites UserFavouriteProduct[]
orders Order[]
reviews UserProductReview[] reviews UserProductReview[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3) updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)

88
lib/nova-post-helper.ts Normal file
View File

@@ -0,0 +1,88 @@
import cache from 'next/cache'
export const NP_SETTLEMENT_KYIV_REF = 'e718a680-4b33-11e4-ab6d-005056801329'
export type Street = {
SettlementStreetRef: string
Present: string
StreetsTypeDescription: string
SettlementStreetDescription: string
SettlementStreetDescriptionRu: string
}
export type Settlement = {
Ref: string
Description: string
DescriptionRu: string
AreaDescription: string
AreaDescriptionRu: string
SettlementTypeDescription: string
SettlementTypeDescriptionRu: string
}
export type Warehouse = {
Ref: string
Description: string
DescriptionRu: string
CityDescription: string
CityDescriptionRu: string
SettlementDescription: string
AreaDescription: string
SettlementRegionsDescription: string
SettlementTypeDescription: string
SettlementTypeDescriptionRu: string
Longitude: string
Latitude: string
}
export const getApi = async (url: string) => {
//TODO: need implement caching
return await fetch(url)
}
export const formatSettlement = (
city: Settlement,
locale: string = 'uk'
): string => {
if (!city) return ''
if (locale === 'ru') {
const {DescriptionRu, AreaDescriptionRu, SettlementTypeDescriptionRu} = city
// https://www.alta.ru/fias/socrname/
const type = SettlementTypeDescriptionRu.replace(
'поселок городского типа',
'пгт '
)
.replace('поселок', 'п. ')
.replace('село', 'с. ')
.replace('город', 'г. ')
return (
type +
DescriptionRu.replace(`(${AreaDescriptionRu} обл.)`, '')
.replace(`(${AreaDescriptionRu} обл)`, '')
.replace(`${AreaDescriptionRu} обл., `, '')
.trim() +
` (${AreaDescriptionRu} обл.)`
)
} else {
const {Description, AreaDescription, SettlementTypeDescription} = city
const type = SettlementTypeDescription.replace(
'селище міського типу',
'смт '
)
.replace('село', 'с. ')
.replace('селище', 'с-ще ')
.replace('місто', 'м. ')
return (
type +
Description.replace(`(${AreaDescription} обл.)`, '')
.replace(`(${AreaDescription} обл)`, '')
.replace(`(село)`, '')
.replace(`${AreaDescription} обл., `, '')
.trim() +
` (${AreaDescription} обл.)`
)
}
}

View File

@@ -1,7 +1,5 @@
import {z} from 'zod' import {z} from 'zod'
import {db} from '@/lib/db/prisma/client'
export const categoryLocaleSchema = z.object({ export const categoryLocaleSchema = z.object({
lang: z.enum(['uk', 'ru']), lang: z.enum(['uk', 'ru']),
title: z.string().trim().min(1).max(384), title: z.string().trim().min(1).max(384),

View File

@@ -0,0 +1,43 @@
import {Entity, EntityLocale, EntityType} from '@prisma/client'
import {z} from 'zod'
import {i18nLocalesCodes} from '@/i18n-config'
import {metaFormSchema} from '@/lib/schemas/meta'
interface Map {
[key: string]: string | undefined
}
export const EntityTypeDescription: Map = {
article: 'Стаття',
page: 'Сторінка',
block: 'Блок'
}
//
export type EntityTerm = Entity & {locales: EntityLocale}
export const entityLocaleSchema = z.object({
lang: z.enum(i18nLocalesCodes),
title: z.coerce.string().trim().min(1).max(384),
annotation: z.coerce.string().trim().optional(),
body: z.coerce.string().trim().optional()
})
export const createEntityFormSchema = z.object({
type: z.enum(Object.keys(EntityType) as [string, ...string[]], {
message: "Обов'язкове до вибору"
}), // ProductToStore
published: z.coerce.boolean().default(false).optional(), // ProductToStore
scopes: z.coerce.string().trim().optional(),
slug: z.coerce
.string()
.trim()
.max(512)
.regex(/^[a-z0-9-]+$/, {
message: 'тільки латинські символи, цифри та дефіс'
})
.optional(),
media: z.coerce.string().trim().max(512).optional(),
locales: z.array(entityLocaleSchema),
meta: z.array(metaFormSchema)
})

View File

@@ -0,0 +1,38 @@
import {DeliveryOption, Lang, Order} from '@prisma/client'
import {z} from 'zod'
interface Map {
[key: string]: string | undefined
}
export const DeliveryOptionTypeDescription: Map = {
NP: 'До відділення «Нової Пошти»',
PICKUP: 'Самовивіз (для Києва)',
COURIER: "Кур'єр (для Києва)"
}
export const createOrderFormSchema = z.object({
user_id: z.string().optional(),
is_quick: z.boolean(),
lang: z.enum(Object.keys(Lang) as [string, ...string[]]),
first_name: z.coerce.string().trim().min(1).max(255),
//patronymic: z.coerce.string().trim().min(1).max(255),
surname: z.coerce.string().trim().min(1).max(255),
delivery_option: z.enum(
Object.keys(DeliveryOption) as [string, ...string[]],
{
message: "Обов'язкове до вибору"
}
),
phone: z.coerce.string().trim().min(1).max(24),
email: z.coerce
.string()
.trim()
.regex(/^[A-Za-z0-9\._%+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,}$/, {
message: 'Ведуть коректну e-mail адресу'
})
.max(320),
address: z.coerce.string().trim(),
notes: z.coerce.string().trim().max(1024).optional(),
details: z.coerce.string().trim()
})

View File

@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'
import {type ClassValue, clsx} from 'clsx' import {type ClassValue, clsx} from 'clsx'
import {getLocale} from 'next-intl/server' import {getLocale} from 'next-intl/server'
import slugify from 'slugify' import slugify from 'slugify'
import striptags from 'striptags'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import {i18nDefaultLocale} from '@/i18n-config' import {i18nDefaultLocale} from '@/i18n-config'
@@ -157,3 +158,40 @@ export const dbErrorHandling = (e: unknown, message?: string | null) => {
*/ */
export const isEmptyObj = (obj: object): boolean => export const isEmptyObj = (obj: object): boolean =>
Object.keys(obj).length === 0 Object.keys(obj).length === 0
/**
* To convert letter on typing from latin to cyr
* TODO: need to complete
*
* @param term
*/
export const replacerLat2Cyr = (term: string): string => {
const obj = {g: 'п', e: 'у', o: 'щ', f: 'а'}
return term
.split('')
.map((ch: PropertyKey): string => {
return obj.hasOwnProperty(ch as PropertyKey)
? (obj[ch as never] as string)
: (ch as string)
})
.join('')
}
type NormalizeConfigType =
| {
stripTags?: boolean
}
| undefined
| null
export function normalizeData(
data: string | null,
config?: NormalizeConfigType
): string {
if (config?.stripTags) {
data = striptags(data as string)
}
return data as string
}

View File

@@ -6,7 +6,8 @@
"Common": { "Common": {
"home": "Главная", "home": "Главная",
"price": "Цена", "price": "Цена",
"buy": "Купить" "buy": "Купить",
"basket": "Корзина"
}, },
"Error": { "Error": {
"title": "Произошла ошибка", "title": "Произошла ошибка",
@@ -34,11 +35,20 @@
"favorites": "Избранное", "favorites": "Избранное",
"empty": "Корзина пуста", "empty": "Корзина пуста",
"continue": "Продолжить покупки", "continue": "Продолжить покупки",
"do_purchase": "Перейти к каталогу товаров",
"checkout": "Оформить заказ", "checkout": "Оформить заказ",
"title": "Название", "title": "Название",
"quantity": "Количество", "quantity": "Количество",
"amount": "Стоимость", "amount": "Стоимость",
"total": "Всего" "total": "Всего",
"clear_cart": "Очистить корзину",
"post": {
"findSettlement": "Выберите населенный пункт",
"startSearchSettlement": "Название населенного пункта",
"selectWarehouse": "Выберите отделение",
"startSearchWarehouse": "Поиск",
"notFount": "Ничего не найдено"
}
}, },
"cabinet": { "cabinet": {
"personal-information": { "personal-information": {

View File

@@ -1,11 +1,16 @@
{ {
"HomePage": { "HomePage": {
"title": "Привіт світ!", "title": "Привіт світ!",
"about": "Go to the about page" "about": "Go to the about page",
"headers":{
"about_heath": "Цікаво про здоров'я"
}
}, },
"Common": { "Common": {
"home": "Головна", "home": "Головна",
"price": "Ціна" "price": "Ціна",
"buy": "Купити",
"basket": "Кошик"
}, },
"Error": { "Error": {
"title": "Сталася помилка", "title": "Сталася помилка",
@@ -36,13 +41,21 @@
"favorites": "Обрані", "favorites": "Обрані",
"empty": "Кошик порожній", "empty": "Кошик порожній",
"continue": "Продовжити покупки", "continue": "Продовжити покупки",
"do_purchase": "Перейти до каталогу товарів",
"checkout": "Оформити замовлення", "checkout": "Оформити замовлення",
"title": "Назва", "title": "Назва",
"quantity": "Кількість", "quantity": "Кількість",
"amount": "Вартість", "amount": "Вартість",
"total": "Всього" "total": "Всього",
"clear_cart": "Очистити кошик",
"post": {
"findSettlement": "Оберіть населенний пункт",
"startSearchSettlement": "Назва населенного пункту",
"selectWarehouse": "Оберіть відділення",
"startSearchWarehouse": "Пошук",
"notFount": "Нічого не знайдено"
}
}, },
"cabinet": { "cabinet": {
"personal-information": { "personal-information": {
"title": "Особисті дані", "title": "Особисті дані",

View File

@@ -1,5 +1,6 @@
import type {NextConfig} from 'next' import type {NextConfig} from 'next'
import createNextIntlPlugin from 'next-intl/plugin' import createNextIntlPlugin from 'next-intl/plugin'
import {headers} from 'next/headers'
const withNextIntl = createNextIntlPlugin({ const withNextIntl = createNextIntlPlugin({
experimental: { experimental: {
@@ -7,7 +8,22 @@ const withNextIntl = createNextIntlPlugin({
} }
}) })
export const routesToRewrite = ['about-us', 'privacy-policy', 'offer-contract']
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// async headers() {
// return [
// {
// source: '/(.*)',
// headers: [
// {
// key: 'X-Frame-Options',
// value: 'allow-from bw.amok.space'
// }
// ]
// }
// ]
// },
experimental: { experimental: {
authInterrupts: true authInterrupts: true
// serverActions: { // serverActions: {
@@ -18,7 +34,6 @@ const nextConfig: NextConfig = {
// static: 180 // static: 180
// } // }
}, },
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ['image/avif', 'image/webp'],
dangerouslyAllowSVG: true, dangerouslyAllowSVG: true,
@@ -29,6 +44,15 @@ const nextConfig: NextConfig = {
hostname: '*' hostname: '*'
} }
] ]
},
async rewrites() {
return [
{
source: `/:locale/:slug(${routesToRewrite.join('|')})`,
destination: '/:locale/pages/:slug'
}
]
} }
} }

9089
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,15 +33,18 @@
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@prisma/client": "^6.3.1", "@prisma/client": "^6.4.1",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.5", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
@@ -50,57 +53,63 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"embla-carousel-autoplay": "^8.5.2", "embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"imagemagick": "^0.1.3", "imagemagick": "^0.1.3",
"jodit-react": "^5.2.4", "jodit-react": "^5.2.15",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.471.1", "lucide-react": "^0.476.0",
"next": "^15.1.6", "next": "^15.2.1",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.0.0-beta-5ec7f45", "next-intl": "^4.0.0-beta-f511797",
"nodemailer": "^6.10.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.8",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.1", "react-hot-toast": "^2.5.2",
"react-youtube": "^10.1.0",
"redis": "^4.7.0", "redis": "^4.7.0",
"sanitize-html": "^2.14.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"striptags": "^3.2.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwind-scrollbar": "^3.1.0", "tailwind-scrollbar": "^3.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1", "use-debounce": "^10.0.4",
"use-mask-input": "^3.4.2",
"zod": "^3.24.2",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@trivago/prettier-plugin-sort-imports": "^5.2.1", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/imagemagick": "^0.0.35", "@types/imagemagick": "^0.0.35",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^22.10.7", "@types/node": "^22.13.10",
"@types/react": "^19", "@types/nodemailer": "^6.4.17",
"@types/react-dom": "^19", "@types/react": "^19.0.9",
"@types/sanitize-html": "^2.13.0", "@types/react-dom": "^19.0.4",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.2.0-canary.16", "eslint-config-next": "15.2.1",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.1", "eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-validate-filename": "^1.0.0", "eslint-plugin-validate-filename": "^1.0.0",
"postcss": "^8.5.1", "postcss": "^8.5.3",
"prettier": "^3.4.2", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.9", "prettier-plugin-tailwindcss": "^0.6.11",
"prisma": "^6.3.1", "prisma": "^6.4.1",
"prisma-json-types-generator": "^3.2.2", "prisma-json-types-generator": "^3.2.2",
"tailwindcss": "^3.4.1", "sass": "^1.85.1",
"tsx": "^4.19.2", "tailwindcss": "^3.4.17",
"typescript": "^5" "tsx": "^4.19.3",
"typescript": "^5.7.3"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22.14"
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 244.915 108.57" style="enable-background:new 0 0 244.915 108.57;" xml:space="preserve">
<style type="text/css">
<!--.st0{fill: #F4F5F9;}
.st1{fill:#41464B;}
.st2{fill:#D9EAF9;}-->
.st0{fill: rgba(198, 197, 255, .2);}
.st1{fill: rgba(40, 26, 76, .8);}
.st2{fill: rgba(255, 244, 198, 1);}
</style>
<polygon class="st0" points="162.95,108.57 0,108.57 81.966,78.737 244.915,78.737 "/>
<path class="st1" d="M160.604,71.67H92.939c-1.852,0-3.44-1.26-3.859-3.065L74.156,4.443C73.547,1.827,71.246,0,68.56,0H54.896
c-0.989,0-1.791,0.802-1.791,1.791s0.802,1.791,1.791,1.791H68.56c1.011,0,1.878,0.688,2.107,1.673l14.924,64.162
c0.799,3.436,3.821,5.836,7.348,5.836h67.665c0.989,0,1.791-0.802,1.791-1.791C162.395,72.473,161.593,71.67,160.604,71.67z"/>
<path class="st1" d="M148.92,78.503l-0.19,0.002c-2.63,0.049-5.084,1.121-6.909,3.017c-1.825,1.895-2.802,4.388-2.752,7.018
c0.1,5.328,4.517,9.663,9.845,9.663l0.19-0.002c2.63-0.049,5.084-1.121,6.909-3.017c1.825-1.895,2.802-4.388,2.752-7.019
C158.664,82.838,154.247,78.503,148.92,78.503z M148.914,94.369c-3.253,0-5.951-2.648-6.013-5.902
c-0.03-1.607,0.567-3.129,1.682-4.287c1.115-1.158,2.613-1.812,4.336-1.843c3.253,0,5.95,2.647,6.013,5.902
c0.03,1.607-0.567,3.129-1.682,4.287c-1.115,1.158-2.613,1.812-4.219,1.843L148.914,94.369z"/>
<path class="st1" d="M100.324,78.503c-5.432,0-9.85,4.419-9.85,9.85c0,5.432,4.419,9.85,9.85,9.85c5.431,0,9.85-4.419,9.85-9.85
C110.174,82.922,105.755,78.503,100.324,78.503z M100.324,94.369c-3.317,0-6.016-2.699-6.016-6.016c0-3.317,2.699-6.016,6.016-6.016
c3.317,0,6.016,2.698,6.016,6.016C106.34,91.67,103.642,94.369,100.324,94.369z"/>
<path class="st1" d="M171.855,17.899h-7.594h-3.948h-19.895h-4.25H95.772h-4.25h-3.455c-1.257,0-2.429,0.563-3.214,1.546
s-1.076,2.25-0.798,3.478l8.759,38.59c0.734,3.229,3.558,5.483,6.869,5.483h52.563c2.811,0,5.348-1.667,6.463-4.246l16.921-39.104
c0.552-1.276,0.426-2.73-0.336-3.891C174.53,18.593,173.245,17.899,171.855,17.899z"/>
<path class="st2" d="M172.112,22.123L155.19,61.229c-0.508,1.174-1.664,1.934-2.944,1.934H99.683c-1.508,0-2.796-1.028-3.13-2.499
l-8.759-38.589c-0.02-0.087-0.001-0.167,0.054-0.237c0.056-0.069,0.129-0.105,0.218-0.105h83.788c0.1,0,0.178,0.042,0.233,0.126
S172.151,22.031,172.112,22.123z"/>
<g>
<circle class="st1" cx="120.042" cy="36.258" r="3.89"/>
<circle class="st1" cx="135.326" cy="36.258" r="3.89"/>
</g>
<g>
<path class="st1" d="M140.725,55.343c-0.719,0-1.389-0.456-1.627-1.176c-1.376-4.153-6.069-7.054-11.414-7.054
c-5.345,0-10.038,2.901-11.415,7.054c-0.297,0.899-1.27,1.388-2.166,1.088c-0.898-0.298-1.386-1.268-1.088-2.166
c1.835-5.537,7.867-9.404,14.668-9.404c6.802,0,12.834,3.867,14.668,9.404c0.298,0.898-0.189,1.868-1.088,2.166
C141.085,55.314,140.904,55.343,140.725,55.343z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -18,6 +18,7 @@ interface CartState {
increaseQuantity: (productId: number) => void increaseQuantity: (productId: number) => void
decreaseQuantity: (productId: number) => void decreaseQuantity: (productId: number) => void
removeItemFromCart: (productId: number) => void removeItemFromCart: (productId: number) => void
clearCart: () => void
} }
const useCartStore = create( const useCartStore = create(
@@ -88,6 +89,10 @@ const useCartStore = create(
set({cartItems: updatedCartItems}) set({cartItems: updatedCartItems})
} }
} }
},
clearCart: () => {
set({cartItems: []})
} }
}), }),
{ {

21
sv.js
View File

@@ -1,21 +0,0 @@
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const port = parseInt(process.env.PORT || '3000', 10);
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen(port, '127.1.4.78');
console.log(
`> Server listening at http://localhost:${port} as ${
dev ? 'development' : process.env.NODE_ENV
}`
);
});

View File

@@ -13,56 +13,57 @@ export default {
theme: { theme: {
screens: { screens: {
xs: '475px', xs: '475px',
sm: '768px', sm: '576px',
md: '1024px', md: '768px',
lg: '1280px', lg: '992px',
xl: '1440px' xl: '1200px'
}, },
extend: { extend: {
container: { container: {
center: true, center: true,
screens: { screens: {
DEFAULT: '100%', DEFAULT: '100%',
xl: '1400px' xl: '1200px'
}, },
padding: { padding: {
DEFAULT: '15px', DEFAULT: '15px',
/*lg: 'unset',*/
xl: 'unset' xl: 'unset'
} }
}, },
fontFamily: {
heading: ['var(--font-myriad)']
},
colors: { colors: {
stone: { stone: {
DEFAULT: '#666666',
'50': '#f6f6f6', '50': '#f6f6f6',
'100': '#e7e7e7', '100': '#e7e7e7',
'200': '#d1d1d1', '200': '#d1d1d1',
'300': '#b0b0b0', '300': '#b0b0b0',
'400': '#888888', '400': '#888888',
'500': '#666666', // Storm Dust '500': '#666666',
'600': '#5d5d5d', '600': '#5d5d5d',
'700': '#4f4f4f', '700': '#4f4f4f',
'800': '#454545', '800': '#454545',
'900': '#3d3d3d', '900': '#3d3d3d',
'950': '#262626' '950': '#262626',
DEFAULT: '#666666'
}, },
brand: { brand: {
yellow: { yellow: {
DEFAULT: BRAND_COLOR_YELLOW,
'50': '#fffbeb', '50': '#fffbeb',
'100': '#fff4c6', '100': '#fff4c6',
'200': '#ffe788', '200': '#ffe788',
'300': '#ffd139', // minsk '300': '#ffd139',
'400': '#ffc120', '400': '#ffc120',
'500': '#f99f07', '500': '#f99f07',
'600': '#dd7702', '600': '#dd7702',
'700': '#b75306', '700': '#b75306',
'800': '#943f0c', '800': '#943f0c',
'900': '#7a350d', '900': '#7a350d',
'950': '#461a02' '950': '#461a02',
DEFAULT: BRAND_COLOR_YELLOW
}, },
violet: { violet: {
DEFAULT: BRAND_COLOR_VIOLET,
'50': '#edeeff', '50': '#edeeff',
'100': '#dfdfff', '100': '#dfdfff',
'200': '#c6c5ff', '200': '#c6c5ff',
@@ -72,8 +73,9 @@ export default {
'600': '#7041ea', '600': '#7041ea',
'700': '#6034ce', '700': '#6034ce',
'800': '#4e2da6', '800': '#4e2da6',
'900': '#442d88', // bright-sun '900': '#442d88',
'950': '#281a4c' '950': '#281a4c',
DEFAULT: BRAND_COLOR_VIOLET
} }
}, },
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
@@ -137,7 +139,30 @@ export default {
}, },
aspectRatio: { aspectRatio: {
univisium: '2 / 1', univisium: '2 / 1',
card: '850 / 834' card: '850 / 834',
yt: '1903 / 784'
},
keyframes: {
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
} }
} }
}, },