stuff done
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -127,3 +127,5 @@ dist
|
||||
/messages/*.d.json.ts
|
||||
/public/uploads/
|
||||
/public/main-fallback.jpg
|
||||
/sv.js
|
||||
/package-lock.json
|
||||
|
||||
12
.idea/dataSources.xml
generated
12
.idea/dataSources.xml
generated
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="@10.14.88.14" uuid="44d6c739-7506-4f97-b907-615219cb4f21">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://10.14.88.14:3306</jdbc-url>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/sqldialects.xml
generated
9
.idea/sqldialects.xml
generated
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/app/[locale]/(root)/(cabinet)/cabinet/[[...slug]]/page.tsx" dialect="GenericSQL" />
|
||||
<file url="file://$PROJECT_DIR$/lib/db/prisma/sql/getCategoryBySlugWitData.sql" dialect="MySQL" />
|
||||
<file url="file://$PROJECT_DIR$/lib/db/prisma/sql/getUserWithAccount.sql" dialect="MySQL" />
|
||||
<file url="PROJECT" dialect="MySQL" />
|
||||
</component>
|
||||
</project>
|
||||
107
actions/admin/entity.ts
Normal file
107
actions/admin/entity.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
'use server'
|
||||
|
||||
import {EntityLocale, EntityType, Meta} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {createEntityFormSchema} from '@/lib/schemas/admin/entity'
|
||||
import {
|
||||
cleanEmptyParams,
|
||||
dbErrorHandling,
|
||||
slug as slugger,
|
||||
toEmptyParams
|
||||
} from '@/lib/utils'
|
||||
|
||||
export const onEntityCreateEditAction = async (
|
||||
formData: z.infer<typeof createEntityFormSchema>
|
||||
) => {
|
||||
const validatedData = createEntityFormSchema.parse(formData)
|
||||
|
||||
if (!validatedData) return {error: 'Недійсні вхідні дані'}
|
||||
|
||||
if (validatedData.locales.length < i18nLocalesCodes.length) {
|
||||
return {error: 'Заповніть всі мови'}
|
||||
}
|
||||
|
||||
const {published, media, type, slug, scopes} = validatedData
|
||||
|
||||
const meta: Meta[] = []
|
||||
|
||||
for (const i in validatedData.meta) {
|
||||
const normalizedMeta: any = cleanEmptyParams(validatedData.meta[i])
|
||||
|
||||
meta.push(normalizedMeta)
|
||||
}
|
||||
|
||||
const locales: EntityLocale[] = []
|
||||
|
||||
for (const i in validatedData.locales) {
|
||||
const locale = validatedData.locales[i]
|
||||
const {title, lang} = locale
|
||||
const slug = slugger(title, lang)
|
||||
|
||||
//const result = await getProductBySlug({slug, lang})
|
||||
const result = null
|
||||
|
||||
if (!result) {
|
||||
const normalized: any = cleanEmptyParams({slug, ...locale})
|
||||
|
||||
locales.push(normalized)
|
||||
} else {
|
||||
return {error: `Сутність з такою назвою ${title} вже існує`}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const newEntity = await db.entity.create({
|
||||
data: {
|
||||
published,
|
||||
scopes: scopes ? JSON.parse(scopes) : null,
|
||||
type: type as EntityType,
|
||||
slug: slug || null,
|
||||
media: media || null,
|
||||
locales: {
|
||||
create: locales
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {success: JSON.stringify(newEntity, null, 2)}
|
||||
} catch (error) {
|
||||
return dbErrorHandling(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const getBlockEntity = async (scope: string) => {
|
||||
return db.entity.findMany({
|
||||
where: {
|
||||
published: true,
|
||||
storeId: STORE_ID,
|
||||
type: 'block',
|
||||
scopes: {
|
||||
array_contains: [scope]
|
||||
}
|
||||
},
|
||||
include: {
|
||||
locales: true
|
||||
},
|
||||
orderBy: {
|
||||
position: 'asc'
|
||||
}
|
||||
})
|
||||
}
|
||||
export const getPageEntityBySlug = async (slug: string) => {
|
||||
return db.entity.findFirst({
|
||||
where: {
|
||||
published: true,
|
||||
storeId: STORE_ID,
|
||||
type: 'page',
|
||||
slug
|
||||
},
|
||||
include: {
|
||||
locales: true
|
||||
}
|
||||
})
|
||||
}
|
||||
44
actions/admin/mailer.ts
Normal file
44
actions/admin/mailer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use server'
|
||||
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: 'vista@ugmail.org',
|
||||
pass: 'hqhowacppifsefxl'
|
||||
}
|
||||
})
|
||||
|
||||
type SendMailProps = {
|
||||
email: string
|
||||
subject: string
|
||||
text: string
|
||||
html: string
|
||||
}
|
||||
|
||||
export async function sendMail({email, subject, text, html}: SendMailProps) {
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: `"BeWell" <vista@ugmail.org>`,
|
||||
to: email,
|
||||
bcc: [
|
||||
'yevhen.odynets@gmail.com',
|
||||
'shopping@amok.space',
|
||||
{
|
||||
name: 'Actus Septem',
|
||||
address: 'actus.septem@ukr.net'
|
||||
}
|
||||
],
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
})
|
||||
|
||||
return {ok: true, messageId: info.messageId}
|
||||
} catch (e) {
|
||||
return {ok: false, message: JSON.stringify(e)}
|
||||
}
|
||||
}
|
||||
24
actions/admin/order.tsx
Normal file
24
actions/admin/order.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const getAllOrders = async () => {
|
||||
return db.order.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: 'desc'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
export const getOrdersByUserId = async (userId: number) => {
|
||||
return db.order.findMany({
|
||||
where: {
|
||||
userId
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: 'desc'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
77
actions/admin/place-order.ts
Normal file
77
actions/admin/place-order.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
'use server'
|
||||
|
||||
import {DeliveryOption, Lang, Order} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {sendMail} from '@/actions/admin/mailer'
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import dayjs from '@/lib/config/dayjs'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {createOrderFormSchema} from '@/lib/schemas/admin/order'
|
||||
import {dbErrorHandling} from '@/lib/utils'
|
||||
|
||||
const generateOrderNo = (): string => {
|
||||
const hex = Math.floor(Math.random() * 16777215)
|
||||
.toString(16)
|
||||
.slice(0, 3)
|
||||
.toUpperCase()
|
||||
|
||||
return `${dayjs().format('YYMM')}-${hex}`
|
||||
}
|
||||
|
||||
export const onPlacingOrder = async (
|
||||
formData: z.infer<typeof createOrderFormSchema>
|
||||
) => {
|
||||
const fields = createOrderFormSchema.parse(formData)
|
||||
if (!fields) return {error: 'Недійсні вхідні дані'}
|
||||
|
||||
const orderNo = generateOrderNo()
|
||||
|
||||
try {
|
||||
const newOrder: Order = await db.order.create({
|
||||
data: {
|
||||
storeId: STORE_ID,
|
||||
lang: fields.lang as Lang,
|
||||
orderNo,
|
||||
isQuick: fields.is_quick,
|
||||
userId: fields.user_id ? parseInt(fields.user_id) : null,
|
||||
firstName: fields.first_name,
|
||||
surname: fields.surname,
|
||||
deliveryOption: fields.delivery_option as DeliveryOption,
|
||||
phone: fields.phone,
|
||||
email: fields.email,
|
||||
address: fields.address.length > 10 ? JSON.parse(fields.address) : null,
|
||||
notes: fields.notes?.toString().trim() !== '' ? fields.notes : null,
|
||||
details: fields.details ? JSON.parse(fields.details) : null
|
||||
}
|
||||
})
|
||||
|
||||
const text = JSON.stringify(newOrder, null, 2)
|
||||
|
||||
const result = await sendMail({
|
||||
email: `${newOrder.firstName} ${newOrder.surname} <${newOrder.email as string}>`,
|
||||
subject: `Замовлення № ${orderNo}`,
|
||||
text,
|
||||
html: `<pre>${text}</pre>`
|
||||
})
|
||||
|
||||
const updated = await db.order.update({
|
||||
where: {
|
||||
id: newOrder.id
|
||||
},
|
||||
data: {
|
||||
emailSent: result.ok
|
||||
}
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
return {success: newOrder.orderNo}
|
||||
} else {
|
||||
return {
|
||||
error: result.message
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return dbErrorHandling(error)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
23
app/(protected)/admin/entity/[...slug]/page.tsx
Normal file
23
app/(protected)/admin/entity/[...slug]/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
import {EntityCrudForm} from '@/components/(protected)/admin/entity/crud-form'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
export default async function Page({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string[]}>
|
||||
}) {
|
||||
const {slug} = await params
|
||||
|
||||
switch ((slug || [])[0]) {
|
||||
case 'create':
|
||||
return (
|
||||
<>
|
||||
<AdminPermission />
|
||||
<EntityCrudForm />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{dump(slug)}</div>
|
||||
}
|
||||
16
app/(protected)/admin/entity/page.tsx
Normal file
16
app/(protected)/admin/entity/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
|
||||
export default function AdminEntityPage() {
|
||||
return (
|
||||
<div>
|
||||
<AdminPermission />
|
||||
<p>
|
||||
<Link href='/admin/entity/create'>
|
||||
Створити блок / статтю / сторінку
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
app/(protected)/admin/order/page.tsx
Normal file
30
app/(protected)/admin/order/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import {Order} from '@prisma/client'
|
||||
import Link from 'next/link'
|
||||
|
||||
import {getAllOrders} from '@/actions/admin/order'
|
||||
import AdminPermission from '@/components/(protected)/admin/auth/permission'
|
||||
import {dump} from '@/lib/utils'
|
||||
|
||||
export default async function AdminOrderPage() {
|
||||
const orders = await getAllOrders()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPermission />
|
||||
{orders.map((order: Order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className='flex items-center justify-start gap-x-9 gap-y-6'
|
||||
>
|
||||
<div>{order.orderNo}</div>
|
||||
<div>
|
||||
{order.firstName} {order.surname}
|
||||
</div>
|
||||
<div>{order.phone}</div>
|
||||
<div>{order.email}</div>
|
||||
<div>{order.notes}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
114
app/[locale]/(root)/(shop)/cart/cart.tsx
Normal file
114
app/[locale]/(root)/(shop)/cart/cart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import {Trash2} from 'lucide-react'
|
||||
import {useTranslations} from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import {useState} from 'react'
|
||||
|
||||
import CartPostSubmit from '@/app/[locale]/(root)/(shop)/cart/post-submit'
|
||||
import styles from '@/components/pages/cart/cart.module.scss'
|
||||
import CartItems from '@/components/pages/cart/items'
|
||||
import RegisteredOrderForm from '@/components/pages/cart/registered-order-form'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {dump} from '@/lib/utils'
|
||||
import EmptyCartImage from '@/public/images/empty-cart.svg'
|
||||
import useCartStore from '@/store/cart-store'
|
||||
import {SessionUser} from '@/types/auth'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function Cart({user}: {user?: SessionUser | null}) {
|
||||
const t = useTranslations('cart')
|
||||
const [submitResult, setSubmitResult] = useState({})
|
||||
const {cartItems, clearCart} = useCartStore()
|
||||
const totalSum = cartItems.reduce(
|
||||
(total, product) => total + parseFloat(product.price) * product.quantity,
|
||||
0
|
||||
)
|
||||
|
||||
const resultSubmit = (result: any) => {
|
||||
setSubmitResult(result)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-1'>
|
||||
<div className='container'>
|
||||
<section className='bw-cart-wrapper mx-auto my-8 max-w-[640px] text-brand-violet'>
|
||||
{cartItems && cartItems.length > 0 ? (
|
||||
<>
|
||||
<div className='mb-6 flex items-center justify-between border-b border-b-brand-violet pb-2'>
|
||||
<h1 className='text-3xl font-bold'>{t('basket')}</h1>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className='rounded-0 h-[3rem] w-[3rem] px-0 text-brand-violet'
|
||||
onClick={() => clearCart()}
|
||||
title={t('clear_cart')}
|
||||
>
|
||||
<Trash2 size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<header className='flex text-xl'>
|
||||
<div className='col'>{t('title')}</div>
|
||||
<div className='flex-none'>{t('quantity')}</div>
|
||||
<div className='col text-right'>{t('amount')}</div>
|
||||
</header>
|
||||
<CartItems cartItems={cartItems} />
|
||||
|
||||
<footer className='my-8 flex py-4 text-xl'>
|
||||
<div className='col'></div>
|
||||
<div className='flex-none'>{t('total')}:</div>
|
||||
<div className='col text-right font-bold'>
|
||||
{totalSum.toFixed(2)} грн
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section className={styles.bwOrderForm}>
|
||||
<h2 className='pb-9'>Оформлення замовлення</h2>
|
||||
<Tabs defaultValue='registered' className='w-full'>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='registered'>
|
||||
Постійний клієнт
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='quick-order'>
|
||||
Швидке замовлення
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='registered' className='mt-5'>
|
||||
<RegisteredOrderForm
|
||||
styles={styles.registeredForm}
|
||||
user={user}
|
||||
onSubmitHandler={resultSubmit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='quick-order'>quick-order</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</>
|
||||
) : Object.keys(submitResult).length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center gap-y-8'>
|
||||
<Image
|
||||
src={EmptyCartImage}
|
||||
sizes='88vw'
|
||||
alt={t('empty')}
|
||||
unoptimized={true}
|
||||
style={{
|
||||
width: '88%',
|
||||
height: 'auto',
|
||||
margin: '1rem auto'
|
||||
}}
|
||||
/>
|
||||
<Link href={'/catalog'} className='px-6 py-2'>
|
||||
<button className='rounded border border-brand-violet bg-transparent px-4 py-2 font-semibold text-brand-violet hover:border-transparent hover:bg-brand-yellow-300 hover:text-brand-violet-700'>
|
||||
{t('do_purchase')}
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<CartPostSubmit result={submitResult} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
236
app/[locale]/(root)/(shop)/cart/nova-post.tsx
Normal file
236
app/[locale]/(root)/(shop)/cart/nova-post.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from 'cmdk'
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
MapPinCheck,
|
||||
MapPinPlus,
|
||||
Warehouse
|
||||
} from 'lucide-react'
|
||||
import {useLocale, useTranslations} from 'next-intl'
|
||||
import {useState} from 'react'
|
||||
import {useDebouncedCallback} from 'use-debounce'
|
||||
|
||||
import {
|
||||
type Settlement,
|
||||
type Warehouse as WarehouseType,
|
||||
formatSettlement,
|
||||
getApi
|
||||
} from '@/lib/nova-post-helper'
|
||||
import {cn} from '@/lib/utils'
|
||||
import {dump} from '@/lib/utils'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Command} from '@/ui/command'
|
||||
import {Popover, PopoverContent, PopoverTrigger} from '@/ui/popover'
|
||||
|
||||
const url = '/api/nova-post'
|
||||
|
||||
export default function NovaPost({onSelectHandler}: {onSelectHandler: any}) {
|
||||
const t = useTranslations('cart.post')
|
||||
const [citiesOpen, setCitiesOpen] = useState(false)
|
||||
const [warehousesOpen, setWarehousesOpen] = useState(false)
|
||||
const [citiesValue, setCitiesValue] = useState('')
|
||||
const [cityRef, setCityRef] = useState('')
|
||||
//const [warehouseRef, setWarehouseRef] = useState('')
|
||||
const [warehousesValue, setWarehousesValue] = useState('')
|
||||
const [cities, setCities] = useState([])
|
||||
const [warehouses, setWarehouses] = useState([])
|
||||
const locale = useLocale()
|
||||
|
||||
const handleCitySearch = useDebouncedCallback(
|
||||
async (e: string): Promise<void> => {
|
||||
if (e.length < 3) {
|
||||
setCities([])
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getApi(url + `?scope=cities&q=` + encodeURI(e))
|
||||
|
||||
if (response.ok) {
|
||||
let json = await response.json()
|
||||
setCities(json)
|
||||
} else {
|
||||
setCities([])
|
||||
}
|
||||
},
|
||||
1000
|
||||
)
|
||||
|
||||
const handleWarehouseSearch = async (e: string): Promise<void> => {
|
||||
const response = await getApi(`${url}?scope=warehouses&q=${e}`)
|
||||
|
||||
if (response.ok) {
|
||||
let json = await response.json()
|
||||
setWarehouses(json)
|
||||
} else {
|
||||
setWarehouses([])
|
||||
}
|
||||
}
|
||||
|
||||
const cityDescription = (citiesValue: string): string => {
|
||||
const city: Settlement | undefined = cities.find(
|
||||
(city: Settlement) => city.Description === citiesValue
|
||||
)
|
||||
|
||||
if (!city) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatSettlement(city, locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='py-2'>
|
||||
<div>
|
||||
<Popover open={citiesOpen} onOpenChange={setCitiesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
/*aria-expanded={open}*/
|
||||
className='w-full justify-between border border-brand-violet'
|
||||
>
|
||||
<span className='inline-flex items-center gap-x-3'>
|
||||
{citiesValue ? (
|
||||
<>
|
||||
<MapPinCheck />
|
||||
{cityDescription(citiesValue)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinPlus />
|
||||
{t('findSettlement')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className='opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[640px] p-2'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
className='border border-brand-violet p-2'
|
||||
placeholder={t('startSearchSettlement')}
|
||||
onValueChange={(e: string) => handleCitySearch(e)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
|
||||
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
|
||||
{cities.map((city: Settlement) => (
|
||||
<CommandItem
|
||||
className='my-2 flex'
|
||||
key={city?.Ref}
|
||||
value={city?.Description}
|
||||
onSelect={(currentValue: string) => {
|
||||
setCitiesValue(
|
||||
currentValue === citiesValue ? '' : currentValue
|
||||
)
|
||||
setCityRef(
|
||||
currentValue === citiesValue ? '' : city?.Ref
|
||||
)
|
||||
|
||||
handleWarehouseSearch(
|
||||
currentValue === citiesValue ? '' : city?.Ref
|
||||
).then(console.log)
|
||||
|
||||
setCitiesOpen(false)
|
||||
}}
|
||||
>
|
||||
{formatSettlement(city, locale)}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
citiesValue === city?.Description
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{cityRef !== '' && (
|
||||
<div className='pt-3'>
|
||||
<Popover open={warehousesOpen} onOpenChange={setWarehousesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
/*aria-expanded={open}*/
|
||||
className='w-full justify-between'
|
||||
>
|
||||
<span className='inline-flex items-center gap-x-3'>
|
||||
<Warehouse />
|
||||
{warehousesValue ? warehousesValue : t('selectWarehouse')}
|
||||
</span>
|
||||
<ChevronsUpDown className='opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[640px] p-2'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
className='p-2'
|
||||
placeholder={t('startSearchWarehouse')}
|
||||
/*onValueChange={(e: string) => handleCitySearch(e)}*/
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
|
||||
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
|
||||
{warehouses.map((warehouse: WarehouseType) => (
|
||||
<CommandItem
|
||||
className='my-2 flex'
|
||||
key={warehouse.Ref}
|
||||
value={warehouse.Description}
|
||||
onSelect={(currentValue: string) => {
|
||||
setWarehousesValue(
|
||||
currentValue === warehousesValue ? '' : currentValue
|
||||
)
|
||||
/*setWarehouseRef(
|
||||
currentValue === warehousesValue
|
||||
? ''
|
||||
: warehouse.Ref
|
||||
)*/
|
||||
onSelectHandler(
|
||||
currentValue === warehousesValue
|
||||
? {}
|
||||
: {
|
||||
Ref: warehouse.Ref,
|
||||
Description: warehouse.Description,
|
||||
DescriptionRu: warehouse.DescriptionRu
|
||||
}
|
||||
)
|
||||
setWarehousesOpen(false)
|
||||
}}
|
||||
>
|
||||
{warehouse.Description}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
warehousesValue === warehouse.Description
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
23
app/[locale]/(root)/(shop)/cart/post-submit.tsx
Normal file
23
app/[locale]/(root)/(shop)/cart/post-submit.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
export default function CartPostSubmit({result}: any) {
|
||||
if (result?.success) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className='text-2xl'>
|
||||
Номер Вашого замовлення:{' '}
|
||||
<span className='font-semibold text-brand-violet-950'>
|
||||
{result?.success}
|
||||
</span>{' '}
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className='text-2xl'>
|
||||
Сталася помилка:{' '}
|
||||
<span className='font-semibold text-red-800'>{result?.error}</span>{' '}
|
||||
</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
app/[locale]/(root)/(shop)/pages/[slug]/page.tsx
Normal file
65
app/[locale]/(root)/(shop)/pages/[slug]/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {EntityLocale} from '@prisma/client'
|
||||
import type {Metadata} from 'next'
|
||||
import {notFound} from 'next/navigation'
|
||||
import {Suspense} from 'react'
|
||||
|
||||
import {getPageEntityBySlug} from '@/actions/admin/entity'
|
||||
import YoutubeComponent from '@/components/shared/youtube-component'
|
||||
import {dump, normalizeData, thisLocale} from '@/lib/utils'
|
||||
import {Skeleton} from '@/ui/skeleton'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{slug?: string}>
|
||||
}
|
||||
|
||||
export const generateMetadata = async ({params}: Props): Promise<Metadata> => {
|
||||
const {slug} = await params
|
||||
const page = await getPageEntityBySlug(slug || '')
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
const {locales} = page
|
||||
const locale: EntityLocale = await thisLocale(locales)
|
||||
const {title, annotation} = locale
|
||||
return {
|
||||
title,
|
||||
description: normalizeData(annotation, {
|
||||
stripTags: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Pages({params}: Props) {
|
||||
const {slug} = await params
|
||||
const page = await getPageEntityBySlug(slug || '')
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const {locales} = page
|
||||
const locale: EntityLocale = await thisLocale(locales)
|
||||
const {title, annotation, body} = locale
|
||||
|
||||
return (
|
||||
<div className='mb-12 mt-8'>
|
||||
<div className='bw-page container max-w-[800px] text-lg text-brand-violet-950'>
|
||||
<h1>{title}</h1>
|
||||
<section className='min-h-[450px]'>
|
||||
<Suspense fallback={<Skeleton className='h-full w-full' />}>
|
||||
<YoutubeComponent id='qfg2UlQk__M' />
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<article
|
||||
className='mt-6'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: ((annotation ?? '') + body) as string
|
||||
}}
|
||||
></article>
|
||||
|
||||
{/*{dump(locale)}*/}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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={''}
|
||||
|
||||
150
app/api/nova-post/route.ts
Normal file
150
app/api/nova-post/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
'use server'
|
||||
|
||||
import {NextRequest} from 'next/server'
|
||||
import {json} from 'node:stream/consumers'
|
||||
|
||||
import {type Warehouse} from '@/lib/nova-post-helper'
|
||||
|
||||
// , res: Response
|
||||
export async function GET(req: NextRequest) {
|
||||
const searchParams = req.nextUrl.searchParams
|
||||
const scope = searchParams.get('scope')
|
||||
|
||||
let response: any = []
|
||||
switch (scope) {
|
||||
case 'cities':
|
||||
response = await getCities(searchParams.get('q') || '...')
|
||||
break
|
||||
case 'warehouses':
|
||||
response = await getWarehouses(searchParams.get('q') || '...')
|
||||
break
|
||||
case 'warehouse':
|
||||
response = await getWarehouse(searchParams.get('q') || '...')
|
||||
break
|
||||
case 'streets':
|
||||
response = await getStreet(searchParams.get('q') || '...')
|
||||
break
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(response.success ? response.data : [], null, 2),
|
||||
{
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json; charset=utf-8'}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchApi(init: RequestInit): Promise<Response> {
|
||||
return await fetch(process.env.NOVA_POST_API_EP || '', init)
|
||||
}
|
||||
|
||||
async function getWarehouses(CityRef: string, Page: number = 1) {
|
||||
// const branches = []
|
||||
let c = 0
|
||||
// let n = 0
|
||||
const Limit = 500
|
||||
|
||||
/*do {
|
||||
const response = await fetchApi({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey: process.env.NOVA_POST_API_KEY || '',
|
||||
modelName: 'AddressGeneral',
|
||||
calledMethod: 'getWarehouses',
|
||||
methodProperties: {
|
||||
CityRef,
|
||||
Page: ++c,
|
||||
Limit,
|
||||
FindByString: 'відд'
|
||||
}
|
||||
})
|
||||
})
|
||||
list = await response.json()
|
||||
n = Math.ceil(list.info.totalCount / Limit)
|
||||
|
||||
for (const i in list.data) {
|
||||
if (list.data[i].Description.trim().match(/^відді/iu)) {
|
||||
branches.push(list.data[i])
|
||||
}
|
||||
}
|
||||
} while (c < n)
|
||||
|
||||
list.data = branches*/
|
||||
|
||||
const response = await fetchApi({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey: process.env.NOVA_POST_API_KEY || '',
|
||||
modelName: 'AddressGeneral',
|
||||
calledMethod: 'getWarehouses',
|
||||
methodProperties: {
|
||||
CityRef,
|
||||
Page: ++c,
|
||||
Limit,
|
||||
FindByString: 'відд'
|
||||
}
|
||||
})
|
||||
})
|
||||
const list = await response.json()
|
||||
|
||||
list.data = list.data.filter((item: Warehouse) =>
|
||||
item.Description.trim().match(/^відді/iu)
|
||||
)
|
||||
//console.log(Math.ceil(list.info.totalCount / Limit), list.data.length)
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
async function getWarehouse(Ref: string) {
|
||||
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey: process.env.NOVA_POST_API_KEY || '',
|
||||
modelName: 'AddressGeneral',
|
||||
calledMethod: 'getWarehouses',
|
||||
methodProperties: {
|
||||
Ref,
|
||||
Limit: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function getCities(searchString: string) {
|
||||
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey: process.env.NOVA_POST_API_KEY || '',
|
||||
modelName: 'AddressGeneral',
|
||||
calledMethod: 'getCities',
|
||||
methodProperties: {
|
||||
FindByString: searchString,
|
||||
Limit: 500
|
||||
}
|
||||
})
|
||||
})
|
||||
//console.log(searchString)
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function getStreet(StreetName: string) {
|
||||
const response = await fetch(process.env.NOVA_POST_API_EP || '', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
apiKey: process.env.NOVA_POST_API_KEY || '',
|
||||
modelName: 'AddressGeneral',
|
||||
calledMethod: 'searchSettlementStreets',
|
||||
methodProperties: {
|
||||
SettlementRef: 'e718a680-4b33-11e4-ab6d-005056801329',
|
||||
Page: 1,
|
||||
Limit: 50,
|
||||
StreetName
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
126
app/globals.css
126
app/globals.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
347
components/(protected)/admin/entity/crud-form.tsx
Normal file
347
components/(protected)/admin/entity/crud-form.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {EntityType} from '@prisma/client'
|
||||
import dynamic from 'next/dynamic'
|
||||
import React, {Suspense, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {useFieldArray, useForm} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onEntityCreateEditAction} from '@/actions/admin/entity'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import {i18nDefaultLocale, i18nLocales} from '@/i18n-config'
|
||||
import {BaseEditorConfig} from '@/lib/config/editor'
|
||||
import {
|
||||
EntityTypeDescription,
|
||||
createEntityFormSchema
|
||||
} from '@/lib/schemas/admin/entity'
|
||||
import {toEmptyParams} from '@/lib/utils'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/ui/form'
|
||||
import {Input} from '@/ui/input'
|
||||
import {Switch} from '@/ui/switch'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
|
||||
|
||||
const JoditEditor = dynamic(() => import('jodit-react'), {ssr: false})
|
||||
|
||||
let localesValues = {
|
||||
type: '',
|
||||
media: '',
|
||||
title: '',
|
||||
annotation: '',
|
||||
body: ''
|
||||
}
|
||||
|
||||
let metaValues = {
|
||||
title: '',
|
||||
description: '',
|
||||
keywords: '',
|
||||
author: ''
|
||||
}
|
||||
|
||||
export const EntityCrudForm = ({data}: {data?: any}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [annotation0, setAnnotation0] = useState(
|
||||
data?.locales[0].annotation || ''
|
||||
)
|
||||
const [annotation1, setAnnotation1] = useState(
|
||||
data?.locales[1].annotation || ''
|
||||
)
|
||||
const [body0, setBody0] = useState(data?.locales[0].body || '')
|
||||
const [body1, setBody1] = useState(data?.locales[1].body || '')
|
||||
|
||||
const editor = useRef(null) //declared a null value
|
||||
const config = useMemo(() => BaseEditorConfig, [])
|
||||
config.maxWidth = '100%'
|
||||
|
||||
const form = useForm<z.infer<typeof createEntityFormSchema>>({
|
||||
resolver: zodResolver(createEntityFormSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: data
|
||||
? (data => {
|
||||
const {locales, meta} = data
|
||||
|
||||
return {
|
||||
published: data.published,
|
||||
image: data.image,
|
||||
locales: toEmptyParams(locales) as any,
|
||||
meta: meta
|
||||
? (toEmptyParams(meta) as any)
|
||||
: [{...metaValues}, {...metaValues}]
|
||||
}
|
||||
})(data)
|
||||
: {
|
||||
scopes: '',
|
||||
published: false,
|
||||
media: '',
|
||||
slug: '',
|
||||
locales: [
|
||||
{lang: 'uk', ...localesValues},
|
||||
{lang: 'ru', ...localesValues}
|
||||
],
|
||||
meta: [{...metaValues}, {...metaValues}]
|
||||
}
|
||||
})
|
||||
|
||||
const {register, setValue} = form
|
||||
|
||||
useEffect(() => {
|
||||
register('locales.0.annotation')
|
||||
register('locales.1.annotation')
|
||||
register('locales.0.body')
|
||||
register('locales.1.body')
|
||||
}, [register])
|
||||
|
||||
const {fields: localeFields} = useFieldArray({
|
||||
name: 'locales',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
const {fields: metaFields} = useFieldArray({
|
||||
name: 'meta',
|
||||
control: form.control
|
||||
})
|
||||
|
||||
console.log(form.formState.errors)
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createEntityFormSchema>) => {
|
||||
setLoading(true)
|
||||
onEntityCreateEditAction(values).then((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
toast.error(res?.error)
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
toast.success(res?.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
action=''
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='form-horizontal'
|
||||
>
|
||||
<div className='mx-auto my-4 w-full space-y-4'>
|
||||
<h1 className='mb-6 text-center text-2xl font-bold text-brand-violet'>
|
||||
Створити блок / статтю / сторінку
|
||||
</h1>
|
||||
<div className='my-4 space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='published'
|
||||
render={({field}) => (
|
||||
<FormItem className='flex flex-row items-center justify-between rounded-lg border bg-gray-50 p-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>Опублікувати</FormLabel>
|
||||
<FormDescription>
|
||||
Відразу після збереження буде розміщено на сайті
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-row items-center justify-between gap-4 rounded-lg border bg-gray-50/25 px-4 pb-4'>
|
||||
<div className='w-1/3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='type'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Тип сутності</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Потрібно обрати тип сутності' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(EntityType).map(
|
||||
(entityType: string, index: number) => (
|
||||
<SelectItem key={index} value={entityType}>
|
||||
{EntityTypeDescription[entityType] || entityType}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className='ml-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-1/3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='slug'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Аліас / Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='вказіть аліас ресурсу'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='ml-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-1/3'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='scopes'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Область виведення</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='Веддіть дані у JSON форматі'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='ml-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row items-center justify-between gap-4 rounded-lg border bg-gray-50 px-4 pb-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='media'
|
||||
render={({field}) => (
|
||||
<FormItem className='w-full'>
|
||||
<FormLabel>
|
||||
Медіа (файл на диску чи URL посилання на ресурс)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage className='ml-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={i18nDefaultLocale}
|
||||
className='mt-4 min-h-[560px] rounded-lg border p-4'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
{i18nLocales.map(locale => (
|
||||
<TabsTrigger key={locale.icon} value={locale.code}>
|
||||
{locale.nameUkr}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{localeFields.map((_, index) => (
|
||||
<TabsContent
|
||||
id={`form-tab-${form.getValues(`locales.${index}.lang`)}`}
|
||||
value={form.getValues(`locales.${index}.lang`)}
|
||||
key={index}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index}
|
||||
name={`locales.${index}.lang`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className='w-full'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={index + 1}
|
||||
name={`locales.${index}.title`}
|
||||
render={({field}) => (
|
||||
<FormItem className={'w-full'}>
|
||||
<FormLabel>Назва сутності</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<FormLabel>Анотація / Коротка назва</FormLabel>
|
||||
<JoditEditor
|
||||
key={index + 4}
|
||||
ref={editor}
|
||||
config={config}
|
||||
value={index === 0 ? annotation0 : annotation1}
|
||||
className='mt-4 w-full'
|
||||
onBlur={value => {
|
||||
index === 0 ? setAnnotation0(value) : setAnnotation1(value)
|
||||
setValue(`locales.${index}.annotation`, value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='w-full'>
|
||||
<FormLabel>Текст</FormLabel>
|
||||
<JoditEditor
|
||||
key={index + 4}
|
||||
ref={editor}
|
||||
config={config}
|
||||
value={index === 0 ? body0 : body1}
|
||||
className='mt-4 w-full'
|
||||
onBlur={value => {
|
||||
index === 0 ? setBody0(value) : setBody1(value)
|
||||
setValue(`locales.${index}.body`, value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
<Button type='submit' className='float-right my-4 w-[200px]'>
|
||||
Створити
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
return (
|
||||
<>
|
||||
{cartItems?.map((item: CartItem, i: number) => (
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
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>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
290
components/pages/cart/registered-order-form.tsx
Normal file
290
components/pages/cart/registered-order-form.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
'use client'
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {DeliveryOption} from '@prisma/client'
|
||||
import {useLocale} from 'next-intl'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onPlacingOrder} from '@/actions/admin/place-order'
|
||||
import NovaPost from '@/app/[locale]/(root)/(shop)/cart/nova-post'
|
||||
import SearchAddress from '@/components/pages/cart/search-address'
|
||||
import {
|
||||
DeliveryOptionTypeDescription,
|
||||
createOrderFormSchema
|
||||
} from '@/lib/schemas/admin/order'
|
||||
import {dump} from '@/lib/utils'
|
||||
import useCartStore from '@/store/cart-store'
|
||||
import {SessionUser} from '@/types/auth'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/ui/form'
|
||||
import {Input} from '@/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/ui/select'
|
||||
|
||||
export default function RegisteredOrderForm({
|
||||
styles,
|
||||
user,
|
||||
onSubmitHandler
|
||||
}: {
|
||||
styles: string
|
||||
user?: SessionUser | null
|
||||
onSubmitHandler: any
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [deliveryOption, setDeliveryOption] = useState('')
|
||||
|
||||
const locale = useLocale()
|
||||
const [warehouseRef, setWarehouseRef] = useState(JSON.stringify({}))
|
||||
|
||||
const warehouseSubmit = (warehouse: any) => {
|
||||
setWarehouseRef(
|
||||
Object.keys(warehouse).length > 0
|
||||
? JSON.stringify(warehouse)
|
||||
: JSON.stringify({})
|
||||
)
|
||||
|
||||
setValue(
|
||||
'address',
|
||||
Object.keys(warehouse).length > 0
|
||||
? JSON.stringify(warehouse)
|
||||
: JSON.stringify({})
|
||||
)
|
||||
}
|
||||
const {cartItems, clearCart} = useCartStore()
|
||||
const form = useForm<z.infer<typeof createOrderFormSchema>>({
|
||||
resolver: zodResolver(createOrderFormSchema),
|
||||
mode: 'onBlur',
|
||||
defaultValues: {
|
||||
user_id: user ? user.id.toString() : '',
|
||||
is_quick: false,
|
||||
lang: locale,
|
||||
first_name: '',
|
||||
surname: '',
|
||||
delivery_option: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: warehouseRef,
|
||||
notes: '',
|
||||
details: JSON.stringify(cartItems)
|
||||
}
|
||||
})
|
||||
|
||||
const {register, setValue} = form
|
||||
|
||||
useEffect(() => {
|
||||
register('delivery_option')
|
||||
register('address')
|
||||
register('details')
|
||||
}, [register])
|
||||
|
||||
const deliveryOptionHandler = (value: string) => {
|
||||
setDeliveryOption(value)
|
||||
setValue('delivery_option', value)
|
||||
}
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof createOrderFormSchema>) => {
|
||||
setLoading(true)
|
||||
setValue('details', JSON.stringify(cartItems))
|
||||
onPlacingOrder(values).then((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
toast.error(res?.error)
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
clearCart()
|
||||
toast.success(res?.success)
|
||||
}
|
||||
|
||||
onSubmitHandler(res)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form className={styles} action='' onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/*<pre>{dump(user)}</pre>*/}
|
||||
<h2>1. {locale !== 'ru' ? 'Особисті дані' : 'Личные данные'}</h2>
|
||||
<fieldset>
|
||||
<div className='md:w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='first_name'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>{locale !== 'ru' ? "Ім'я" : 'Имя'}*</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='surname'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{locale !== 'ru' ? 'Прізвище' : 'Фамилия'}*
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' placeholder='' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div className='md:w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='phone'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Телефон*</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:w-1/2'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-mail*</FormLabel>
|
||||
<FormControl>
|
||||
<Input type='text' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h2>
|
||||
2.{' '}
|
||||
{locale !== 'ru'
|
||||
? 'Інформація про доставку'
|
||||
: 'Информация о доставке'}
|
||||
</h2>
|
||||
<fieldset>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='delivery_option'
|
||||
render={({field}) => (
|
||||
<FormItem className='block w-full'>
|
||||
<FormLabel>
|
||||
{locale !== 'ru'
|
||||
? 'Варіанти доставки'
|
||||
: 'Варианты доставки'}{' '}
|
||||
</FormLabel>
|
||||
<Select
|
||||
/*onValueChange={field.onChange}*/
|
||||
onValueChange={deliveryOptionHandler}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
locale !== 'ru'
|
||||
? 'Оберіть варіант доставки'
|
||||
: 'Выберите вариант доставки'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.keys(DeliveryOption).map(
|
||||
(option: string, index: number) => (
|
||||
<SelectItem key={index} value={option}>
|
||||
{DeliveryOptionTypeDescription[option] || option}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage className='ml-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{deliveryOption === 'NP' && (
|
||||
<NovaPost onSelectHandler={warehouseSubmit} />
|
||||
)}
|
||||
|
||||
{deliveryOption === 'COURIER' && (
|
||||
/*<NovaPost onWarehouseSelect={warehouseSubmit} />*/
|
||||
<SearchAddress onSelectHandler={warehouseSubmit} />
|
||||
)}
|
||||
|
||||
{deliveryOption === 'PICKUP' && (
|
||||
<div className='py-6 text-lg'>Дані де і коли можна забрати</div>
|
||||
)}
|
||||
|
||||
<fieldset>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='notes'
|
||||
render={({field}) => (
|
||||
<FormItem className='block w-full'>
|
||||
<FormLabel>
|
||||
{locale !== 'ru'
|
||||
? 'Додаткова інформація'
|
||||
: 'Дополнительная информация'}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
{/*<pre>{warehouseRef}</pre>*/}
|
||||
<Button
|
||||
type='submit'
|
||||
size={'lg'}
|
||||
className='float-right mx-auto mb-6 mt-16 h-[unset] w-[200px] py-2 text-xl text-brand-violet'
|
||||
>
|
||||
{locale !== 'ru' ? 'Оформити замовлення' : 'Оформить заказ'}
|
||||
</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
152
components/pages/cart/search-address.tsx
Normal file
152
components/pages/cart/search-address.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from 'cmdk'
|
||||
import {Check, ChevronsUpDown, MapPinCheck, MapPinPlus} from 'lucide-react'
|
||||
import {useLocale, useTranslations} from 'next-intl'
|
||||
import {useState} from 'react'
|
||||
import {useDebouncedCallback} from 'use-debounce'
|
||||
|
||||
import {
|
||||
type Settlement,
|
||||
Street,
|
||||
formatSettlement,
|
||||
getApi
|
||||
} from '@/lib/nova-post-helper'
|
||||
import {cn, dump} from '@/lib/utils'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Command} from '@/ui/command'
|
||||
import {Popover, PopoverContent, PopoverTrigger} from '@/ui/popover'
|
||||
|
||||
const url = '/api/nova-post'
|
||||
|
||||
export default function SearchAddress({
|
||||
onSelectHandler
|
||||
}: {
|
||||
onSelectHandler: any
|
||||
}) {
|
||||
const t = useTranslations('cart.post')
|
||||
const locale = useLocale()
|
||||
const [streets, setStreets] = useState([])
|
||||
const [streetsOpen, setStreetsOpen] = useState(false)
|
||||
const [streetsValue, setStreetsValue] = useState('')
|
||||
|
||||
const handleStreetSearch = useDebouncedCallback(
|
||||
async (e: string): Promise<void> => {
|
||||
if (e.length < 3) {
|
||||
setStreets([])
|
||||
return
|
||||
}
|
||||
|
||||
const response = await getApi(url + `?scope=streets&q=` + encodeURI(e))
|
||||
|
||||
if (response.ok) {
|
||||
let json = JSON.parse(JSON.stringify(await response.json()))
|
||||
const {Addresses} = json[0]
|
||||
setStreets(Addresses)
|
||||
} else {
|
||||
setStreets([])
|
||||
}
|
||||
},
|
||||
1000
|
||||
)
|
||||
|
||||
const streetDescription = (streetsValue: string): string => {
|
||||
const street: Street | undefined = streets.find(
|
||||
(street: Street) => street.Present === streetsValue
|
||||
)
|
||||
|
||||
if (!street) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return streetsValue
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='py-2'>
|
||||
{/*<pre>{dump(streets[0]['Addresses'])}</pre>*/}
|
||||
<div>
|
||||
<Popover open={streetsOpen} onOpenChange={setStreetsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
/*aria-expanded={open}*/
|
||||
className='w-full justify-between border border-brand-violet'
|
||||
>
|
||||
<span className='inline-flex items-center gap-x-3'>
|
||||
{streetsValue ? (
|
||||
<>
|
||||
<MapPinCheck />
|
||||
{streetDescription(streetsValue)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinPlus />
|
||||
{locale !== 'ru' ? 'Шукати вулицю' : 'Искать улицу'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className='opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[640px] p-2'>
|
||||
<Command>
|
||||
<CommandInput
|
||||
className='border border-brand-violet p-2'
|
||||
placeholder={locale !== 'ru' ? 'Почати пошук' : 'Начать поиск'}
|
||||
onValueChange={(e: string) => handleStreetSearch(e)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className='my-1'>{t('notFount')}</CommandEmpty>
|
||||
<CommandGroup className='max-h-[320px] w-full overflow-y-auto'>
|
||||
{streets.map((street: Street, index: number) => (
|
||||
<CommandItem
|
||||
className='my-2 flex'
|
||||
key={index}
|
||||
value={street?.Present}
|
||||
onSelect={(currentValue: string) => {
|
||||
setStreetsValue(
|
||||
currentValue === streetsValue ? '' : currentValue
|
||||
)
|
||||
|
||||
onSelectHandler(
|
||||
currentValue === streetsValue
|
||||
? {}
|
||||
: {
|
||||
Ref: street.SettlementStreetRef,
|
||||
Description: street.Present,
|
||||
DescriptionRu:
|
||||
street.SettlementStreetDescriptionRu
|
||||
}
|
||||
)
|
||||
|
||||
setStreetsOpen(false)
|
||||
}}
|
||||
>
|
||||
{street?.Present}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
streetsValue === street?.Present
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
55
components/shared/terms/index.tsx
Normal file
55
components/shared/terms/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import {Entity, EntityLocale, type Lang} from '@prisma/client'
|
||||
import {useLocale} from 'next-intl'
|
||||
|
||||
import {getBlockEntity} from '@/actions/admin/entity'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@/components/ui/accordion'
|
||||
import {locales} from '@/i18n/routing'
|
||||
import {EntityTerm} from '@/lib/schemas/admin/entity'
|
||||
import {dump, thisLocale} from '@/lib/utils'
|
||||
|
||||
export default async function Terms() {
|
||||
const locale = useLocale()
|
||||
const terms = await getBlockEntity('terms')
|
||||
|
||||
return (
|
||||
<section className='container mb-4 mt-12'>
|
||||
{/*<pre>{dump(terms)}</pre>*/}
|
||||
<div className='bw-terms-section mx-auto max-w-[1080px]'>
|
||||
<Accordion
|
||||
type='single'
|
||||
collapsible
|
||||
className='flex w-full flex-wrap justify-between gap-y-4'
|
||||
>
|
||||
{terms.map(async (term: any, index: number) => {
|
||||
const {locales} = term
|
||||
const locale: EntityLocale = await thisLocale(locales)
|
||||
|
||||
const {title, body} = locale
|
||||
return (
|
||||
<AccordionItem
|
||||
key={index}
|
||||
value={`item-${index}`}
|
||||
className='bw-accordion-item'
|
||||
>
|
||||
<AccordionTrigger className='bw-accordion-trigger'>
|
||||
<div className='flex-grow'>{title}</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className='bw-accordion-content'>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{__html: body as string}}
|
||||
></span>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
{/*<div className='bw-accordion-item table-cell'></div>*/}
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
28
components/shared/youtube-component.tsx
Normal file
28
components/shared/youtube-component.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import YouTube, {YouTubeProps} from 'react-youtube'
|
||||
|
||||
export default function YoutubeComponent({id}: {id: string}) {
|
||||
const onPlayerReady: YouTubeProps['onReady'] = e => {
|
||||
e.target.pauseVideo()
|
||||
}
|
||||
|
||||
const opts: YouTubeProps['opts'] = {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
playerVars: {
|
||||
// https://developers.google.com/youtube/player_parameters
|
||||
autoplay: 0
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<YouTube
|
||||
id={`video-yt-${id}`}
|
||||
videoId={id}
|
||||
opts={opts}
|
||||
onReady={onPlayerReady}
|
||||
iframeClassName='bw-yt-video'
|
||||
/>
|
||||
)
|
||||
}
|
||||
57
components/ui/accordion.tsx
Normal file
57
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
153
components/ui/command.tsx
Normal file
153
components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
33
components/ui/popover.tsx
Normal file
33
components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
44
components/ui/radio-group.tsx
Normal file
44
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -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']
|
||||
|
||||
32
lib/db/prisma/schema/entity.prisma
Normal file
32
lib/db/prisma/schema/entity.prisma
Normal file
@@ -0,0 +1,32 @@
|
||||
model Entity {
|
||||
id Int @id @default(autoincrement())
|
||||
type EntityType
|
||||
published Boolean? @default(false)
|
||||
scopes Json?
|
||||
position Int? @default(0) @db.UnsignedSmallInt
|
||||
slug String? @db.VarChar(512)
|
||||
media String? @db.VarChar(512)
|
||||
storeId Int @default(1) @map("store_id")
|
||||
locales EntityLocale[]
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
@@unique([storeId, type, slug])
|
||||
@@map("entities")
|
||||
}
|
||||
|
||||
model EntityLocale {
|
||||
id Int @id @default(autoincrement())
|
||||
lang Lang @default(uk)
|
||||
slug String? @db.VarChar(512)
|
||||
title String @db.VarChar(384)
|
||||
annotation String? @db.MediumText
|
||||
body String? @db.MediumText
|
||||
entity Entity @relation(fields: [entityId], references: [id], onDelete: Cascade)
|
||||
entityId Int @map("entity_id")
|
||||
meta Meta? @relation(fields: [metaId], references: [id], onDelete: Cascade)
|
||||
metaId Int? @map("meta_id")
|
||||
|
||||
@@unique([slug, lang])
|
||||
@@map("entity_locale")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
26
lib/db/prisma/schema/order.prisma
Normal file
26
lib/db/prisma/schema/order.prisma
Normal file
@@ -0,0 +1,26 @@
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
storeId Int @default(1) @map("store_id")
|
||||
lang Lang
|
||||
orderNo String @map("order_no") @db.VarChar(45)
|
||||
isQuick Boolean @map("is_quick")
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int? @map("user_id")
|
||||
firstName String @map("first_name") @db.VarChar(255)
|
||||
patronymic String? @db.VarChar(255)
|
||||
surname String? @db.VarChar(255)
|
||||
deliveryOption DeliveryOption? @map("delivery_option")
|
||||
phone String? @db.Char(24)
|
||||
email String? @db.VarChar(320)
|
||||
emailSent Boolean? @map("email_sent")
|
||||
/// [OrderAddressType]
|
||||
address Json?
|
||||
notes String? @db.MediumText
|
||||
/// [OrderDetailsType]
|
||||
details Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime? @default(dbgenerated("NULL DEFAULT NULL ON UPDATE current_timestamp(3)")) @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
@@unique([orderNo, storeId])
|
||||
@@map("orders")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
88
lib/nova-post-helper.ts
Normal file
88
lib/nova-post-helper.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import cache from 'next/cache'
|
||||
|
||||
export const NP_SETTLEMENT_KYIV_REF = 'e718a680-4b33-11e4-ab6d-005056801329'
|
||||
|
||||
export type Street = {
|
||||
SettlementStreetRef: string
|
||||
Present: string
|
||||
StreetsTypeDescription: string
|
||||
SettlementStreetDescription: string
|
||||
SettlementStreetDescriptionRu: string
|
||||
}
|
||||
|
||||
export type Settlement = {
|
||||
Ref: string
|
||||
Description: string
|
||||
DescriptionRu: string
|
||||
AreaDescription: string
|
||||
AreaDescriptionRu: string
|
||||
SettlementTypeDescription: string
|
||||
SettlementTypeDescriptionRu: string
|
||||
}
|
||||
|
||||
export type Warehouse = {
|
||||
Ref: string
|
||||
Description: string
|
||||
DescriptionRu: string
|
||||
CityDescription: string
|
||||
CityDescriptionRu: string
|
||||
SettlementDescription: string
|
||||
AreaDescription: string
|
||||
SettlementRegionsDescription: string
|
||||
SettlementTypeDescription: string
|
||||
SettlementTypeDescriptionRu: string
|
||||
Longitude: string
|
||||
Latitude: string
|
||||
}
|
||||
|
||||
export const getApi = async (url: string) => {
|
||||
//TODO: need implement caching
|
||||
return await fetch(url)
|
||||
}
|
||||
|
||||
export const formatSettlement = (
|
||||
city: Settlement,
|
||||
locale: string = 'uk'
|
||||
): string => {
|
||||
if (!city) return ''
|
||||
|
||||
if (locale === 'ru') {
|
||||
const {DescriptionRu, AreaDescriptionRu, SettlementTypeDescriptionRu} = city
|
||||
// https://www.alta.ru/fias/socrname/
|
||||
const type = SettlementTypeDescriptionRu.replace(
|
||||
'поселок городского типа',
|
||||
'пгт '
|
||||
)
|
||||
.replace('поселок', 'п. ')
|
||||
.replace('село', 'с. ')
|
||||
.replace('город', 'г. ')
|
||||
|
||||
return (
|
||||
type +
|
||||
DescriptionRu.replace(`(${AreaDescriptionRu} обл.)`, '')
|
||||
.replace(`(${AreaDescriptionRu} обл)`, '')
|
||||
.replace(`${AreaDescriptionRu} обл., `, '')
|
||||
.trim() +
|
||||
` (${AreaDescriptionRu} обл.)`
|
||||
)
|
||||
} else {
|
||||
const {Description, AreaDescription, SettlementTypeDescription} = city
|
||||
const type = SettlementTypeDescription.replace(
|
||||
'селище міського типу',
|
||||
'смт '
|
||||
)
|
||||
.replace('село', 'с. ')
|
||||
.replace('селище', 'с-ще ')
|
||||
.replace('місто', 'м. ')
|
||||
|
||||
return (
|
||||
type +
|
||||
Description.replace(`(${AreaDescription} обл.)`, '')
|
||||
.replace(`(${AreaDescription} обл)`, '')
|
||||
.replace(`(село)`, '')
|
||||
.replace(`${AreaDescription} обл., `, '')
|
||||
.trim() +
|
||||
` (${AreaDescription} обл.)`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
43
lib/schemas/admin/entity.ts
Normal file
43
lib/schemas/admin/entity.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {Entity, EntityLocale, EntityType} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
import {metaFormSchema} from '@/lib/schemas/meta'
|
||||
|
||||
interface Map {
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export const EntityTypeDescription: Map = {
|
||||
article: 'Стаття',
|
||||
page: 'Сторінка',
|
||||
block: 'Блок'
|
||||
}
|
||||
//
|
||||
export type EntityTerm = Entity & {locales: EntityLocale}
|
||||
|
||||
export const entityLocaleSchema = z.object({
|
||||
lang: z.enum(i18nLocalesCodes),
|
||||
title: z.coerce.string().trim().min(1).max(384),
|
||||
annotation: z.coerce.string().trim().optional(),
|
||||
body: z.coerce.string().trim().optional()
|
||||
})
|
||||
|
||||
export const createEntityFormSchema = z.object({
|
||||
type: z.enum(Object.keys(EntityType) as [string, ...string[]], {
|
||||
message: "Обов'язкове до вибору"
|
||||
}), // ProductToStore
|
||||
published: z.coerce.boolean().default(false).optional(), // ProductToStore
|
||||
scopes: z.coerce.string().trim().optional(),
|
||||
slug: z.coerce
|
||||
.string()
|
||||
.trim()
|
||||
.max(512)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'тільки латинські символи, цифри та дефіс'
|
||||
})
|
||||
.optional(),
|
||||
media: z.coerce.string().trim().max(512).optional(),
|
||||
locales: z.array(entityLocaleSchema),
|
||||
meta: z.array(metaFormSchema)
|
||||
})
|
||||
38
lib/schemas/admin/order.ts
Normal file
38
lib/schemas/admin/order.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {DeliveryOption, Lang, Order} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
interface Map {
|
||||
[key: string]: string | undefined
|
||||
}
|
||||
|
||||
export const DeliveryOptionTypeDescription: Map = {
|
||||
NP: 'До відділення «Нової Пошти»',
|
||||
PICKUP: 'Самовивіз (для Києва)',
|
||||
COURIER: "Кур'єр (для Києва)"
|
||||
}
|
||||
|
||||
export const createOrderFormSchema = z.object({
|
||||
user_id: z.string().optional(),
|
||||
is_quick: z.boolean(),
|
||||
lang: z.enum(Object.keys(Lang) as [string, ...string[]]),
|
||||
first_name: z.coerce.string().trim().min(1).max(255),
|
||||
//patronymic: z.coerce.string().trim().min(1).max(255),
|
||||
surname: z.coerce.string().trim().min(1).max(255),
|
||||
delivery_option: z.enum(
|
||||
Object.keys(DeliveryOption) as [string, ...string[]],
|
||||
{
|
||||
message: "Обов'язкове до вибору"
|
||||
}
|
||||
),
|
||||
phone: z.coerce.string().trim().min(1).max(24),
|
||||
email: z.coerce
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9\._%+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,}$/, {
|
||||
message: 'Ведуть коректну e-mail адресу'
|
||||
})
|
||||
.max(320),
|
||||
address: z.coerce.string().trim(),
|
||||
notes: z.coerce.string().trim().max(1024).optional(),
|
||||
details: z.coerce.string().trim()
|
||||
})
|
||||
38
lib/utils.ts
38
lib/utils.ts
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "Особисті дані",
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9089
package-lock.json
generated
9089
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
63
package.json
63
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/fonts/myriad-black.woff2
Normal file
BIN
public/fonts/myriad-black.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-blackit.woff2
Normal file
BIN
public/fonts/myriad-blackit.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-bold.otf
Normal file
BIN
public/fonts/myriad-bold.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-bold.woff2
Normal file
BIN
public/fonts/myriad-bold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-boldcond.otf
Normal file
BIN
public/fonts/myriad-boldcond.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-boldcondit.otf
Normal file
BIN
public/fonts/myriad-boldcondit.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-boldit.otf
Normal file
BIN
public/fonts/myriad-boldit.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-boldit.woff2
Normal file
BIN
public/fonts/myriad-boldit.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-cond.otf
Normal file
BIN
public/fonts/myriad-cond.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-condit.otf
Normal file
BIN
public/fonts/myriad-condit.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-it.woff2
Normal file
BIN
public/fonts/myriad-it.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-light.otf
Normal file
BIN
public/fonts/myriad-light.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-light.woff2
Normal file
BIN
public/fonts/myriad-light.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-lightit.woff2
Normal file
BIN
public/fonts/myriad-lightit.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-regular.otf
Normal file
BIN
public/fonts/myriad-regular.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-regular.woff2
Normal file
BIN
public/fonts/myriad-regular.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-semibold.otf
Normal file
BIN
public/fonts/myriad-semibold.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-semibold.woff2
Normal file
BIN
public/fonts/myriad-semibold.woff2
Normal file
Binary file not shown.
BIN
public/fonts/myriad-semiboldit.otf
Normal file
BIN
public/fonts/myriad-semiboldit.otf
Normal file
Binary file not shown.
BIN
public/fonts/myriad-semiboldit.woff2
Normal file
BIN
public/fonts/myriad-semiboldit.woff2
Normal file
Binary file not shown.
41
public/images/empty-cart.svg
Normal file
41
public/images/empty-cart.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 244.915 108.57" style="enable-background:new 0 0 244.915 108.57;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
<!--.st0{fill: #F4F5F9;}
|
||||
.st1{fill:#41464B;}
|
||||
.st2{fill:#D9EAF9;}-->
|
||||
.st0{fill: rgba(198, 197, 255, .2);}
|
||||
.st1{fill: rgba(40, 26, 76, .8);}
|
||||
.st2{fill: rgba(255, 244, 198, 1);}
|
||||
</style>
|
||||
<polygon class="st0" points="162.95,108.57 0,108.57 81.966,78.737 244.915,78.737 "/>
|
||||
<path class="st1" d="M160.604,71.67H92.939c-1.852,0-3.44-1.26-3.859-3.065L74.156,4.443C73.547,1.827,71.246,0,68.56,0H54.896
|
||||
c-0.989,0-1.791,0.802-1.791,1.791s0.802,1.791,1.791,1.791H68.56c1.011,0,1.878,0.688,2.107,1.673l14.924,64.162
|
||||
c0.799,3.436,3.821,5.836,7.348,5.836h67.665c0.989,0,1.791-0.802,1.791-1.791C162.395,72.473,161.593,71.67,160.604,71.67z"/>
|
||||
<path class="st1" d="M148.92,78.503l-0.19,0.002c-2.63,0.049-5.084,1.121-6.909,3.017c-1.825,1.895-2.802,4.388-2.752,7.018
|
||||
c0.1,5.328,4.517,9.663,9.845,9.663l0.19-0.002c2.63-0.049,5.084-1.121,6.909-3.017c1.825-1.895,2.802-4.388,2.752-7.019
|
||||
C158.664,82.838,154.247,78.503,148.92,78.503z M148.914,94.369c-3.253,0-5.951-2.648-6.013-5.902
|
||||
c-0.03-1.607,0.567-3.129,1.682-4.287c1.115-1.158,2.613-1.812,4.336-1.843c3.253,0,5.95,2.647,6.013,5.902
|
||||
c0.03,1.607-0.567,3.129-1.682,4.287c-1.115,1.158-2.613,1.812-4.219,1.843L148.914,94.369z"/>
|
||||
<path class="st1" d="M100.324,78.503c-5.432,0-9.85,4.419-9.85,9.85c0,5.432,4.419,9.85,9.85,9.85c5.431,0,9.85-4.419,9.85-9.85
|
||||
C110.174,82.922,105.755,78.503,100.324,78.503z M100.324,94.369c-3.317,0-6.016-2.699-6.016-6.016c0-3.317,2.699-6.016,6.016-6.016
|
||||
c3.317,0,6.016,2.698,6.016,6.016C106.34,91.67,103.642,94.369,100.324,94.369z"/>
|
||||
<path class="st1" d="M171.855,17.899h-7.594h-3.948h-19.895h-4.25H95.772h-4.25h-3.455c-1.257,0-2.429,0.563-3.214,1.546
|
||||
s-1.076,2.25-0.798,3.478l8.759,38.59c0.734,3.229,3.558,5.483,6.869,5.483h52.563c2.811,0,5.348-1.667,6.463-4.246l16.921-39.104
|
||||
c0.552-1.276,0.426-2.73-0.336-3.891C174.53,18.593,173.245,17.899,171.855,17.899z"/>
|
||||
<path class="st2" d="M172.112,22.123L155.19,61.229c-0.508,1.174-1.664,1.934-2.944,1.934H99.683c-1.508,0-2.796-1.028-3.13-2.499
|
||||
l-8.759-38.589c-0.02-0.087-0.001-0.167,0.054-0.237c0.056-0.069,0.129-0.105,0.218-0.105h83.788c0.1,0,0.178,0.042,0.233,0.126
|
||||
S172.151,22.031,172.112,22.123z"/>
|
||||
<g>
|
||||
<circle class="st1" cx="120.042" cy="36.258" r="3.89"/>
|
||||
<circle class="st1" cx="135.326" cy="36.258" r="3.89"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st1" d="M140.725,55.343c-0.719,0-1.389-0.456-1.627-1.176c-1.376-4.153-6.069-7.054-11.414-7.054
|
||||
c-5.345,0-10.038,2.901-11.415,7.054c-0.297,0.899-1.27,1.388-2.166,1.088c-0.898-0.298-1.386-1.268-1.088-2.166
|
||||
c1.835-5.537,7.867-9.404,14.668-9.404c6.802,0,12.834,3.867,14.668,9.404c0.298,0.898-0.189,1.868-1.088,2.166
|
||||
C141.085,55.314,140.904,55.343,140.725,55.343z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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: []})
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
21
sv.js
21
sv.js
@@ -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
|
||||
}`
|
||||
);
|
||||
});
|
||||
@@ -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