stuff done
This commit is contained in:
@@ -127,3 +127,5 @@ dist
|
||||
/messages/*.d.json.ts
|
||||
/public/uploads/
|
||||
/public/main-fallback.jpg
|
||||
/sv.js
|
||||
/package-lock.json
|
||||
|
||||
Generated
-12
@@ -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>
|
||||
Generated
-9
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export const onProductCreateAction = async (
|
||||
}
|
||||
})
|
||||
|
||||
return {success: 'JSON.stringify(newProduct, null, 2)'}
|
||||
return {success: JSON.stringify(newProduct, null, 2)}
|
||||
} catch (error) {
|
||||
return dbErrorHandling(error)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {auth} from '@/auth'
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
import {CreateForm} from '@/components/(protected)/admin/category/create-form'
|
||||
import ProductCreateEditForm from '@/components/(protected)/admin/product/create-edit-form'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
export default async function Page({
|
||||
@@ -12,7 +14,12 @@ export default async function Page({
|
||||
|
||||
switch ((slug || [])[0]) {
|
||||
case 'create':
|
||||
return <CreateForm />
|
||||
return (
|
||||
<>
|
||||
<AdminPermission />
|
||||
<CreateForm />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{dump(slug)}</div>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {getProductById} from '@/lib/data/models/product'
|
||||
import {dump} from '@/lib/utils'
|
||||
@@ -21,9 +23,19 @@ export default async function Page({
|
||||
|
||||
switch (method) {
|
||||
case 'create':
|
||||
return <ProductCreateEditForm />
|
||||
return (
|
||||
<>
|
||||
<AdminPermission />
|
||||
<ProductCreateEditForm />
|
||||
</>
|
||||
)
|
||||
case 'update':
|
||||
return <ProductCreateEditForm data={data} />
|
||||
return (
|
||||
<>
|
||||
<AdminPermission />
|
||||
<ProductCreateEditForm data={data} />
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return <div>{dump(slug)}</div>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
import useCartStore from '@/store/cart-store'
|
||||
const {user} = session
|
||||
|
||||
export default function Cart() {
|
||||
const t = useTranslations('cart')
|
||||
const {cartItems} = useCartStore()
|
||||
const totalSum = cartItems.reduce(
|
||||
(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>
|
||||
return session ? (
|
||||
<Cart user={user as unknown as SessionUser} />
|
||||
) : (
|
||||
<Cart user={null} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import {getProductByIdWitData} from '@prisma/client/sql'
|
||||
import {notFound} from 'next/navigation'
|
||||
|
||||
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({
|
||||
params
|
||||
@@ -12,5 +15,9 @@ export default async function Products({
|
||||
const id = (uri || '').match(/^(\d+)-./)
|
||||
if (!id) notFound()
|
||||
|
||||
return <ProductPageIndex id={id[1]} />
|
||||
const data: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getProductByIdWitData(id[1])
|
||||
)
|
||||
|
||||
return <ProductPageIndex data={data} id={id[1]} />
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {getCatalogIndexData} from '@prisma/client/sql'
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import Image from 'next/image'
|
||||
import React from 'react'
|
||||
|
||||
import FeatureCards from '@/components/shared/home/feature-cards'
|
||||
import {HomeCarousel} from '@/components/shared/home/home-carousel'
|
||||
import AppCatalog from '@/components/shared/sidebar/app-catalog'
|
||||
import FeatureCardFront from '@/components/shared/store/feature-card-front'
|
||||
import Terms from '@/components/shared/terms'
|
||||
import {carousels} from '@/lib/data'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
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() {
|
||||
const loc = await getLocale()
|
||||
const locale = await getLocale()
|
||||
const catalog: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getCatalogIndexData(loc)
|
||||
getCatalogIndexData(locale)
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -51,8 +53,18 @@ export default async function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className='container mb-4 mt-[128px]'>
|
||||
<section className='container mb-4 mt-8'>
|
||||
<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'>
|
||||
<Image
|
||||
alt={''}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+119
-7
@@ -4,10 +4,8 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: scroll;
|
||||
html, body {
|
||||
@apply h-full m-0 p-0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -115,11 +113,11 @@ body {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@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{
|
||||
@@ -134,7 +132,7 @@ body {
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -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 {
|
||||
/* since nested groupes are not supported we have to use
|
||||
regular css for the nested dropdowns
|
||||
@@ -165,6 +233,40 @@ body {
|
||||
.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 > * {
|
||||
all: revert;
|
||||
@@ -180,6 +282,7 @@ body {
|
||||
}*/
|
||||
}
|
||||
|
||||
|
||||
#admin-bw-panel form {
|
||||
input {
|
||||
@apply bg-white outline-0 text-[16px] leading-none;
|
||||
@@ -265,3 +368,12 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
+39
-2
@@ -1,4 +1,5 @@
|
||||
import type {Metadata} from 'next'
|
||||
import localFont from 'next/font/local'
|
||||
import {headers} from 'next/headers'
|
||||
import {ReactNode} from 'react'
|
||||
import {Toaster} from 'react-hot-toast'
|
||||
@@ -16,6 +17,42 @@ export const metadata: Metadata = {
|
||||
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({
|
||||
children
|
||||
}: Readonly<{children: ReactNode}>) {
|
||||
@@ -23,8 +60,8 @@ export default async function RootLayout({
|
||||
const locale = headersList.get('x-site-locale') ?? routing.defaultLocale
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className='min-h-screen antialiased'>
|
||||
<html lang={locale} suppressHydrationWarning className={Myriad.variable}>
|
||||
<body className={`min-h-screen antialiased`}>
|
||||
{children}
|
||||
{/*<Toaster />*/}
|
||||
<Toaster position='top-right' reverseOrder={false} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Home,
|
||||
Inbox,
|
||||
LayoutList,
|
||||
List,
|
||||
Newspaper,
|
||||
Plus,
|
||||
ScanBarcode,
|
||||
Search,
|
||||
@@ -44,6 +46,16 @@ const items = [
|
||||
title: 'Товари',
|
||||
url: `${ADMIN_DASHBOARD_PATH}/product`,
|
||||
icon: ScanBarcode
|
||||
},
|
||||
{
|
||||
title: 'Сутність',
|
||||
url: `${ADMIN_DASHBOARD_PATH}/entity`,
|
||||
icon: Newspaper
|
||||
},
|
||||
{
|
||||
title: 'Замовлення',
|
||||
url: `${ADMIN_DASHBOARD_PATH}/order`,
|
||||
icon: List
|
||||
}
|
||||
// {
|
||||
// title: 'Search',
|
||||
@@ -70,7 +82,7 @@ export function AdminSidebar() {
|
||||
<SidebarGroupContent>SidebarGroupAction</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
{/*<SidebarGroupLabel>Application</SidebarGroupLabel>*/}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map(item => (
|
||||
@@ -86,7 +98,7 @@ export function AdminSidebar() {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Collapsible defaultOpen className='group/collapsible'>
|
||||
<Collapsible defaultOpen className='group/collapsible' hidden={true}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {Order} from '@prisma/client'
|
||||
import {useTranslations} from 'next-intl'
|
||||
|
||||
import {getOrdersByUserId} from '@/actions/admin/order'
|
||||
import {SignOutButton} from '@/components/auth/forms/sign-out-button'
|
||||
import CabinetButton from '@/components/shared/header/cabinet-button'
|
||||
import {type SingedInSession} from '@/lib/permission'
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
} from '@/ui/collapsible'
|
||||
import {Separator} from '@/ui/separator'
|
||||
|
||||
export default function CabinetIndex({
|
||||
export default async function CabinetIndex({
|
||||
slug,
|
||||
session
|
||||
}: {
|
||||
@@ -19,6 +21,7 @@ export default function CabinetIndex({
|
||||
session: SingedInSession | null
|
||||
}) {
|
||||
const t = useTranslations('cabinet')
|
||||
const orders = await getOrdersByUserId(parseInt(session?.user.id as string))
|
||||
|
||||
return (
|
||||
<div className='my-8'>
|
||||
@@ -40,10 +43,24 @@ export default function CabinetIndex({
|
||||
{t('personal-information.title')}
|
||||
</h1>
|
||||
<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'} />*/}
|
||||
{/*<Separator className='my-4' />*/}
|
||||
{slug ? <code>{slug[0]}</code> : <pre>{dump(session)}</pre>}
|
||||
<Collapsible>
|
||||
{/*{slug ? <code>{slug[0]}</code> : <pre>{dump(session)}</pre>}*/}
|
||||
{/*<Collapsible>
|
||||
<CollapsibleTrigger>
|
||||
Can I use this in my project?
|
||||
</CollapsibleTrigger>
|
||||
@@ -51,7 +68,7 @@ export default function CabinetIndex({
|
||||
Yes. Free to use for personal and commercial projects. No
|
||||
attribution required.
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Collapsible>*/}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,48 @@
|
||||
input.bw-cart-item-counter{
|
||||
font-size: 36px;
|
||||
background: chocolate;
|
||||
.bwOrderForm {
|
||||
& > h2, & > h3 {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// 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 {Link} from '@/i18n/routing'
|
||||
import useCartStore, {CartItem} from '@/store/cart-store'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function CartItems() {
|
||||
const {cartItems} = useCartStore()
|
||||
|
||||
export default function CartItems({cartItems}: {cartItems: CartItem[]}) {
|
||||
const {increaseQuantity, decreaseQuantity, removeItemFromCart} =
|
||||
useCartStore()
|
||||
|
||||
const onIncreaseQuantity = (productId: number) => {
|
||||
increaseQuantity(productId)
|
||||
}
|
||||
@@ -23,22 +21,33 @@ export default function CartItems() {
|
||||
removeItemFromCart(productId)
|
||||
}
|
||||
|
||||
if (cartItems && cartItems.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{cartItems?.map((item: CartItem, i: number) => (
|
||||
<div className='my-4 flex items-center' key={i}>
|
||||
<article key={i} className='bxg-emerald-200 mb-6'>
|
||||
<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'>
|
||||
{item.title}
|
||||
<Image
|
||||
src={(item?.image || '').replace('.jpg', '-thumb.jpg')}
|
||||
alt=''
|
||||
width={96}
|
||||
height={96}
|
||||
className='rounded-md border'
|
||||
width={64}
|
||||
height={64}
|
||||
style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
@@ -47,17 +56,17 @@ export default function CartItems() {
|
||||
<div className='flex w-16 flex-none items-center justify-center'>
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
<Minus />
|
||||
</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}
|
||||
</div>
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
<Plus />
|
||||
@@ -68,19 +77,8 @@ export default function CartItems() {
|
||||
{(item.price * item.quantity).toFixed(2)} грн
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -25,22 +25,23 @@ import {Link} from '@/i18n/routing'
|
||||
import {getMetaOfFile} from '@/lib/config/resources'
|
||||
import {getProductResources} from '@/lib/data/models/product'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {dump, thisLocale, toPrice} from '@/lib/utils'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Separator} from '@/ui/separator'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
|
||||
|
||||
export default async function ProductPageIndex({id}: {id: string}) {
|
||||
const t = await getTranslations('Common')
|
||||
|
||||
const data: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getProductByIdWitData(id)
|
||||
)
|
||||
|
||||
export default async function ProductPageIndex({
|
||||
data,
|
||||
id
|
||||
}: {
|
||||
data: CategoryPageSqlSchema[]
|
||||
id: string
|
||||
}) {
|
||||
const locale = await thisLocale(data)
|
||||
if (!locale) notFound()
|
||||
|
||||
const t = await getTranslations('Common')
|
||||
|
||||
const resources: ProductResource[] | null = await getProductResources(
|
||||
parseInt(id)
|
||||
)
|
||||
@@ -70,11 +71,11 @@ export default async function ProductPageIndex({id}: {id: string}) {
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className='flex w-[82%] items-center justify-between'>
|
||||
<h1 className='my-4 text-3xl font-bold text-brand-violet-950'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<h1 className='font-heading mt-4 text-3xl font-semibold'>
|
||||
{locale.title}
|
||||
</h1>
|
||||
<AddCartButton
|
||||
{/*<AddCartButton
|
||||
product={{
|
||||
id: locale.productId,
|
||||
quantity: 1,
|
||||
@@ -82,9 +83,9 @@ export default async function ProductPageIndex({id}: {id: string}) {
|
||||
price: toPrice(locale.price),
|
||||
image: locale.image
|
||||
}}
|
||||
/>
|
||||
/>*/}
|
||||
</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} />
|
||||
<Tabs defaultValue='article' className=''>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function Above() {
|
||||
const t = useTranslations('Banner.Above')
|
||||
|
||||
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'>
|
||||
<Image
|
||||
width={72.79}
|
||||
|
||||
@@ -2,26 +2,40 @@
|
||||
|
||||
// 31:47
|
||||
import {ChevronUp} from 'lucide-react'
|
||||
import {useLocale} from 'next-intl'
|
||||
|
||||
import SocialMediaPanel from '@/components/shared/social-media-panel'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function Footer() {
|
||||
const locale = useLocale()
|
||||
|
||||
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>Політика конфіденційності</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>
|
||||
<SocialMediaPanel color='#fff' />
|
||||
<Button
|
||||
{/*<Button
|
||||
variant='ghost'
|
||||
className='bg-brand-violet rounded-none'
|
||||
className='rounded-none bg-brand-violet'
|
||||
onClick={() => window.scrollTo({top: 0, behavior: 'smooth'})}
|
||||
>
|
||||
<ChevronUp className='mr-2 h-4 w-4' />
|
||||
</Button>
|
||||
</Button>*/}
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -9,13 +9,13 @@ export default function HeaderControls() {
|
||||
const t = useTranslations('cart')
|
||||
|
||||
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 />
|
||||
|
||||
<Link href={'#' as never} className='header-button' aria-label='Вибране'>
|
||||
<button className='flex flex-col items-center' role='button'>
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -4,21 +4,30 @@ import {ShoppingCartIcon} from 'lucide-react'
|
||||
import {useTranslations} from 'next-intl'
|
||||
|
||||
import {Link} from '@/i18n/routing'
|
||||
import useCartStore from '@/store/cart-store'
|
||||
import useCartStore, {CartItem} from '@/store/cart-store'
|
||||
|
||||
export default function HeaderShoppingCartIcon() {
|
||||
const t = useTranslations('cart')
|
||||
const t = useTranslations('Common')
|
||||
const {cartItems} = useCartStore()
|
||||
const cartCount = cartItems.length
|
||||
const cartCount = cartItems.reduce(
|
||||
(accumulator: number, item: CartItem) => accumulator + item.quantity,
|
||||
0
|
||||
)
|
||||
|
||||
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'>
|
||||
<ShoppingCartIcon className='h-[21px] w-[21px]' />
|
||||
|
||||
<span className='font1-bold text-sm'>
|
||||
{t('basket')} [{cartCount}]
|
||||
</span>
|
||||
{cartCount > 0 && (
|
||||
<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]'>
|
||||
{cartCount}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import {useLocale, useTranslations} from 'next-intl'
|
||||
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 logoImg from '@/public/images/logo.svg'
|
||||
|
||||
export default function Logo() {
|
||||
const t = useTranslations('Common')
|
||||
const locale = useLocale()
|
||||
const ar = 121 / 192
|
||||
const w = 112
|
||||
|
||||
return (
|
||||
<div className='mt-0.5 flex items-center justify-center'>
|
||||
<Link
|
||||
href='/'
|
||||
<a
|
||||
href={locale !== 'ru' ? '/' : '/ru'}
|
||||
className='m-1 flex cursor-pointer items-center pt-[7px] text-2xl font-extrabold outline-0'
|
||||
aria-label={t('home')}
|
||||
>
|
||||
<Image
|
||||
src={logoImg}
|
||||
@@ -23,7 +28,7 @@ export default function Logo() {
|
||||
alt={`${APP_NAME} logo`}
|
||||
className='w-[131]'
|
||||
/>
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export default function LocaleSwitcher() {
|
||||
path: '/',
|
||||
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 (
|
||||
|
||||
@@ -22,7 +22,11 @@ export default function NavbarMenu() {
|
||||
<div className='flex items-center justify-between'>
|
||||
{data[locale === 'uk' ? 'headerMenus' : 'headerMenusRus'].map(
|
||||
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}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function AppCatalogRender(data: {items: Category[]}) {
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<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>
|
||||
@@ -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'>
|
||||
{data?.items.map((item: any) => (
|
||||
<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}
|
||||
>
|
||||
<button className='flex w-full items-center text-left outline-none focus:outline-none'>
|
||||
<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}`}
|
||||
>
|
||||
{item.locales[locale === 'uk' ? 0 : 1].title}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function CardBuyButton({
|
||||
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
{isIcon ? (
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import {ProductResource} from '@prisma/client'
|
||||
import {Star, StarHalf} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import CardBuyButton from '@/components/shared/store/card-buy-button'
|
||||
import RateStars from '@/components/shared/store/stars'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Card, CardContent, CardFooter} from '@/ui/card'
|
||||
import {CarouselItem} from '@/ui/carousel'
|
||||
|
||||
export default function FeatureCardFront({
|
||||
card
|
||||
@@ -18,7 +14,7 @@ export default function FeatureCardFront({
|
||||
return (
|
||||
<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}`}>
|
||||
<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
|
||||
className='transition duration-300 hover:scale-110'
|
||||
@@ -39,12 +35,12 @@ export default function FeatureCardFront({
|
||||
{/*</CarouselItem>*/}
|
||||
</CardContent>
|
||||
</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=''>
|
||||
<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}
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -55,17 +51,9 @@ export default function FeatureCardFront({
|
||||
title: card.title,
|
||||
price: parseFloat(card.price as string).toFixed(2)
|
||||
}}
|
||||
isIcon={true}
|
||||
isIcon={false}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// id: number
|
||||
// quantity: number
|
||||
// title: string
|
||||
// price: string | any
|
||||
// image?: string | null
|
||||
// imageWidth?: number | null
|
||||
// imageHeight?: number | null
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import {Star, StarHalf} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import CardBuyButton from '@/components/shared/store/card-buy-button'
|
||||
import RateStars from '@/components/shared/store/stars'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Card, CardContent, CardFooter} from '@/ui/card'
|
||||
|
||||
//import {CarouselItem} from '@/ui/carousel'
|
||||
|
||||
export default function FeatureCard({card}: {card: any}) {
|
||||
return (
|
||||
<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}`}>
|
||||
<CardContent className='relative flex h-[81%] flex-col justify-between overflow-hidden pt-4'>
|
||||
{/*<CarouselItem>*/}
|
||||
<CardContent className='relative flex h-[76%] flex-col justify-between overflow-hidden pt-4'>
|
||||
<Image
|
||||
className='transition duration-300 hover:scale-110'
|
||||
src={card.image}
|
||||
@@ -33,12 +28,12 @@ export default function FeatureCard({card}: {card: any}) {
|
||||
<RateStars />
|
||||
</CardContent>
|
||||
</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=''>
|
||||
<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}
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,16 @@ import {Star} from 'lucide-react'
|
||||
|
||||
const startStroke = 1.5
|
||||
const color = '#ffd139'
|
||||
const size = 16
|
||||
|
||||
export default function RateStars() {
|
||||
return (
|
||||
<div className='bw-rating absolute bottom-2 left-4 inline-flex h-[32px] items-center gap-1'>
|
||||
<Star strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star strokeWidth={startStroke} color={color} />
|
||||
<Star strokeWidth={startStroke} color={color} />
|
||||
<div className='bw-rating absolute bottom-1 left-3 inline-flex h-[32px] items-center gap-1'>
|
||||
<Star size={size} strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star size={size} strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star size={size} strokeWidth={startStroke} color={color} fill={color} />
|
||||
<Star size={size} strokeWidth={startStroke} color={color} />
|
||||
<Star size={size} strokeWidth={startStroke} color={color} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -1,6 +1,6 @@
|
||||
export const BaseEditorConfig = {
|
||||
readonly: false, // all options from https://xdsoft.net/jodit/docs/,
|
||||
placeholder: 'Start typings...',
|
||||
placeholder: 'Почніть введення...',
|
||||
spellcheck: true,
|
||||
language: 'ua',
|
||||
//toolbarAdaptive: false,
|
||||
@@ -10,9 +10,9 @@ export const BaseEditorConfig = {
|
||||
//defaultActionOnPaste: 'insert_as_text',
|
||||
//defaultActionOnPaste: 'insert_only_text',
|
||||
//disablePlugins: 'ai-assistant,mobile,print,speech-recognize,table,table-keyboard-navigation,powered-by-jodit,iframe',
|
||||
minHeight: 240,
|
||||
maxHeight: 640,
|
||||
maxWidth: 890,
|
||||
minHeight: '240',
|
||||
maxHeight: '640',
|
||||
maxWidth: '890',
|
||||
uploader: {
|
||||
insertImageAsBase64URI: true,
|
||||
imagesExtensions: ['jpg', 'png', 'jpeg', 'gif', 'svg', 'webp']
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
enum DeliveryOption {
|
||||
NP
|
||||
PICKUP
|
||||
COURIER
|
||||
}
|
||||
|
||||
enum Lang {
|
||||
uk
|
||||
ru
|
||||
}
|
||||
|
||||
enum EntityType {
|
||||
article
|
||||
block
|
||||
page
|
||||
}
|
||||
|
||||
enum Unit {
|
||||
mkg
|
||||
mg
|
||||
|
||||
@@ -23,6 +23,7 @@ model Meta {
|
||||
openGraph OpenGraph?
|
||||
storeLocale StoreLocale[]
|
||||
productLocale ProductLocale[]
|
||||
entityLocale EntityLocale[]
|
||||
//vendorLocale VendorLocale[]
|
||||
|
||||
@@map("meta")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -27,6 +27,7 @@ model User {
|
||||
extendedData Json? @map("extended_data") @db.Json
|
||||
// orders Order[]
|
||||
favorites UserFavouriteProduct[]
|
||||
orders Order[]
|
||||
reviews UserProductReview[]
|
||||
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)
|
||||
|
||||
@@ -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} обл.)`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {z} from 'zod'
|
||||
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const categoryLocaleSchema = z.object({
|
||||
lang: z.enum(['uk', 'ru']),
|
||||
title: z.string().trim().min(1).max(384),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs'
|
||||
import {type ClassValue, clsx} from 'clsx'
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import slugify from 'slugify'
|
||||
import striptags from 'striptags'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
import {i18nDefaultLocale} from '@/i18n-config'
|
||||
@@ -157,3 +158,40 @@ export const dbErrorHandling = (e: unknown, message?: string | null) => {
|
||||
*/
|
||||
export const isEmptyObj = (obj: object): boolean =>
|
||||
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
|
||||
}
|
||||
|
||||
+12
-2
@@ -6,7 +6,8 @@
|
||||
"Common": {
|
||||
"home": "Главная",
|
||||
"price": "Цена",
|
||||
"buy": "Купить"
|
||||
"buy": "Купить",
|
||||
"basket": "Корзина"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Произошла ошибка",
|
||||
@@ -34,11 +35,20 @@
|
||||
"favorites": "Избранное",
|
||||
"empty": "Корзина пуста",
|
||||
"continue": "Продолжить покупки",
|
||||
"do_purchase": "Перейти к каталогу товаров",
|
||||
"checkout": "Оформить заказ",
|
||||
"title": "Название",
|
||||
"quantity": "Количество",
|
||||
"amount": "Стоимость",
|
||||
"total": "Всего"
|
||||
"total": "Всего",
|
||||
"clear_cart": "Очистить корзину",
|
||||
"post": {
|
||||
"findSettlement": "Выберите населенный пункт",
|
||||
"startSearchSettlement": "Название населенного пункта",
|
||||
"selectWarehouse": "Выберите отделение",
|
||||
"startSearchWarehouse": "Поиск",
|
||||
"notFount": "Ничего не найдено"
|
||||
}
|
||||
},
|
||||
"cabinet": {
|
||||
"personal-information": {
|
||||
|
||||
+17
-4
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"HomePage": {
|
||||
"title": "Привіт світ!",
|
||||
"about": "Go to the about page"
|
||||
"about": "Go to the about page",
|
||||
"headers":{
|
||||
"about_heath": "Цікаво про здоров'я"
|
||||
}
|
||||
},
|
||||
"Common": {
|
||||
"home": "Головна",
|
||||
"price": "Ціна"
|
||||
"price": "Ціна",
|
||||
"buy": "Купити",
|
||||
"basket": "Кошик"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Сталася помилка",
|
||||
@@ -36,13 +41,21 @@
|
||||
"favorites": "Обрані",
|
||||
"empty": "Кошик порожній",
|
||||
"continue": "Продовжити покупки",
|
||||
"do_purchase": "Перейти до каталогу товарів",
|
||||
"checkout": "Оформити замовлення",
|
||||
"title": "Назва",
|
||||
"quantity": "Кількість",
|
||||
"amount": "Вартість",
|
||||
"total": "Всього"
|
||||
"total": "Всього",
|
||||
"clear_cart": "Очистити кошик",
|
||||
"post": {
|
||||
"findSettlement": "Оберіть населенний пункт",
|
||||
"startSearchSettlement": "Назва населенного пункту",
|
||||
"selectWarehouse": "Оберіть відділення",
|
||||
"startSearchWarehouse": "Пошук",
|
||||
"notFount": "Нічого не знайдено"
|
||||
}
|
||||
},
|
||||
|
||||
"cabinet": {
|
||||
"personal-information": {
|
||||
"title": "Особисті дані",
|
||||
|
||||
+25
-1
@@ -1,5 +1,6 @@
|
||||
import type {NextConfig} from 'next'
|
||||
import createNextIntlPlugin from 'next-intl/plugin'
|
||||
import {headers} from 'next/headers'
|
||||
|
||||
const withNextIntl = createNextIntlPlugin({
|
||||
experimental: {
|
||||
@@ -7,7 +8,22 @@ const withNextIntl = createNextIntlPlugin({
|
||||
}
|
||||
})
|
||||
|
||||
export const routesToRewrite = ['about-us', 'privacy-policy', 'offer-contract']
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// async headers() {
|
||||
// return [
|
||||
// {
|
||||
// source: '/(.*)',
|
||||
// headers: [
|
||||
// {
|
||||
// key: 'X-Frame-Options',
|
||||
// value: 'allow-from bw.amok.space'
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
experimental: {
|
||||
authInterrupts: true
|
||||
// serverActions: {
|
||||
@@ -18,7 +34,6 @@ const nextConfig: NextConfig = {
|
||||
// static: 180
|
||||
// }
|
||||
},
|
||||
|
||||
images: {
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
dangerouslyAllowSVG: true,
|
||||
@@ -29,6 +44,15 @@ const nextConfig: NextConfig = {
|
||||
hostname: '*'
|
||||
}
|
||||
]
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: `/:locale/:slug(${routesToRewrite.join('|')})`,
|
||||
|
||||
destination: '/:locale/pages/:slug'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
-9089
File diff suppressed because it is too large
Load Diff
+36
-27
@@ -33,15 +33,18 @@
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.7.4",
|
||||
"@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-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-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-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-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
@@ -50,57 +53,63 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"imagemagick": "^0.1.3",
|
||||
"jodit-react": "^5.2.4",
|
||||
"jodit-react": "^5.2.15",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.471.1",
|
||||
"next": "^15.1.6",
|
||||
"lucide-react": "^0.476.0",
|
||||
"next": "^15.2.1",
|
||||
"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-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"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",
|
||||
"sanitize-html": "^2.14.0",
|
||||
"server-only": "^0.0.1",
|
||||
"slugify": "^1.6.6",
|
||||
"striptags": "^3.2.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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/imagemagick": "^0.0.35",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19.0.9",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.0-canary.16",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-config-next": "15.2.1",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-validate-filename": "^1.0.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"prisma": "^6.3.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prisma": "^6.4.1",
|
||||
"prisma-json-types-generator": "^3.2.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
"sass": "^1.85.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"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.
@@ -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 |
@@ -18,6 +18,7 @@ interface CartState {
|
||||
increaseQuantity: (productId: number) => void
|
||||
decreaseQuantity: (productId: number) => void
|
||||
removeItemFromCart: (productId: number) => void
|
||||
clearCart: () => void
|
||||
}
|
||||
|
||||
const useCartStore = create(
|
||||
@@ -88,6 +89,10 @@ const useCartStore = create(
|
||||
set({cartItems: updatedCartItems})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clearCart: () => {
|
||||
set({cartItems: []})
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}`
|
||||
);
|
||||
});
|
||||
+41
-16
@@ -13,56 +13,57 @@ export default {
|
||||
theme: {
|
||||
screens: {
|
||||
xs: '475px',
|
||||
sm: '768px',
|
||||
md: '1024px',
|
||||
lg: '1280px',
|
||||
xl: '1440px'
|
||||
sm: '576px',
|
||||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px'
|
||||
},
|
||||
extend: {
|
||||
container: {
|
||||
center: true,
|
||||
screens: {
|
||||
DEFAULT: '100%',
|
||||
xl: '1400px'
|
||||
xl: '1200px'
|
||||
},
|
||||
padding: {
|
||||
DEFAULT: '15px',
|
||||
/*lg: 'unset',*/
|
||||
xl: 'unset'
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
heading: ['var(--font-myriad)']
|
||||
},
|
||||
colors: {
|
||||
stone: {
|
||||
DEFAULT: '#666666',
|
||||
'50': '#f6f6f6',
|
||||
'100': '#e7e7e7',
|
||||
'200': '#d1d1d1',
|
||||
'300': '#b0b0b0',
|
||||
'400': '#888888',
|
||||
'500': '#666666', // Storm Dust
|
||||
'500': '#666666',
|
||||
'600': '#5d5d5d',
|
||||
'700': '#4f4f4f',
|
||||
'800': '#454545',
|
||||
'900': '#3d3d3d',
|
||||
'950': '#262626'
|
||||
'950': '#262626',
|
||||
DEFAULT: '#666666'
|
||||
},
|
||||
brand: {
|
||||
yellow: {
|
||||
DEFAULT: BRAND_COLOR_YELLOW,
|
||||
'50': '#fffbeb',
|
||||
'100': '#fff4c6',
|
||||
'200': '#ffe788',
|
||||
'300': '#ffd139', // minsk
|
||||
'300': '#ffd139',
|
||||
'400': '#ffc120',
|
||||
'500': '#f99f07',
|
||||
'600': '#dd7702',
|
||||
'700': '#b75306',
|
||||
'800': '#943f0c',
|
||||
'900': '#7a350d',
|
||||
'950': '#461a02'
|
||||
'950': '#461a02',
|
||||
DEFAULT: BRAND_COLOR_YELLOW
|
||||
},
|
||||
violet: {
|
||||
DEFAULT: BRAND_COLOR_VIOLET,
|
||||
'50': '#edeeff',
|
||||
'100': '#dfdfff',
|
||||
'200': '#c6c5ff',
|
||||
@@ -72,8 +73,9 @@ export default {
|
||||
'600': '#7041ea',
|
||||
'700': '#6034ce',
|
||||
'800': '#4e2da6',
|
||||
'900': '#442d88', // bright-sun
|
||||
'950': '#281a4c'
|
||||
'900': '#442d88',
|
||||
'950': '#281a4c',
|
||||
DEFAULT: BRAND_COLOR_VIOLET
|
||||
}
|
||||
},
|
||||
background: 'hsl(var(--background))',
|
||||
@@ -137,7 +139,30 @@ export default {
|
||||
},
|
||||
aspectRatio: {
|
||||
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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user