grand commit
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -126,3 +126,4 @@ dist
|
||||
.env*.local
|
||||
/messages/*.d.json.ts
|
||||
/public/uploads/
|
||||
/public/main-fallback.jpg
|
||||
|
||||
1
.idea/sqldialects.xml
generated
1
.idea/sqldialects.xml
generated
@@ -2,6 +2,7 @@
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use server'
|
||||
|
||||
import {Meta, ProductLocale, ProductToStore} from '@prisma/client'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import {Meta, ProductLocale} from '@prisma/client'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
@@ -9,12 +8,7 @@ import {STORE_ID} from '@/lib/config/constants'
|
||||
import {getProductBySlug} from '@/lib/data/models/product'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {createProductFormSchema} from '@/lib/schemas/admin/product'
|
||||
import {
|
||||
cleanEmptyParams,
|
||||
dbErrorHandling,
|
||||
isEmptyObj,
|
||||
slug as slugger
|
||||
} from '@/lib/utils'
|
||||
import {cleanEmptyParams, dbErrorHandling, slug as slugger} from '@/lib/utils'
|
||||
|
||||
export const onProductCreateAction = async (
|
||||
formData: z.infer<typeof createProductFormSchema>
|
||||
@@ -42,7 +36,6 @@ export const onProductCreateAction = async (
|
||||
for (const i in validatedData.meta) {
|
||||
const normalizedMeta: any = cleanEmptyParams(validatedData.meta[i])
|
||||
|
||||
//if (!isEmptyObj(normalizedMeta)) {}
|
||||
meta.push(normalizedMeta)
|
||||
}
|
||||
|
||||
@@ -58,7 +51,6 @@ export const onProductCreateAction = async (
|
||||
if (!result) {
|
||||
const normalized: any = cleanEmptyParams({slug, ...locale})
|
||||
|
||||
//locales.push({...normalized, meta: {create: meta[i]}})
|
||||
locales.push(normalized)
|
||||
} else {
|
||||
return {error: `Продукт з такою назвою ${title} вже існує`}
|
||||
|
||||
@@ -15,3 +15,9 @@ export const getCategoryBySlug = async (data: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getCategoryLocalesById = async (id: number) => {
|
||||
return db.categoryLocale.findMany({
|
||||
where: {categoryId: id}
|
||||
})
|
||||
}
|
||||
|
||||
100
app/[locale]/(root)/(shop)/about-us/page.tsx
Normal file
100
app/[locale]/(root)/(shop)/about-us/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -16,13 +16,15 @@ export default function Cart() {
|
||||
return (
|
||||
<div className='mt-1'>
|
||||
<div className='container'>
|
||||
<section className='mx-auto my-8 max-w-[640px] text-brand-violet'>
|
||||
<h1 className='text-3xl font-bold'>{t('basket')}</h1>
|
||||
<div className='bsdg-brand-violet-200 my-4 grid grid-cols-3 gap-4 border-t-2 border-brand-violet py-4'>
|
||||
<div className='bg-brand-violet-100'>Назва</div>
|
||||
<div className='bg-brand-violet-100'>Кількість</div>
|
||||
<div className='bg-brand-violet-100'>Вартість</div>
|
||||
</div>
|
||||
<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 />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
22
app/[locale]/(root)/(shop)/catalog/page.tsx
Normal file
22
app/[locale]/(root)/(shop)/catalog/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {getCatalogIndexData} from '@prisma/client/sql'
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import {notFound} from 'next/navigation'
|
||||
|
||||
import CategoryPageIndex from '@/components/pages/category/page'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export default async function CatalogPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string}>
|
||||
}) {
|
||||
const loc = await getLocale()
|
||||
const catalog: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getCatalogIndexData(loc)
|
||||
)
|
||||
|
||||
if (catalog.length < 1) notFound()
|
||||
|
||||
return <CategoryPageIndex data={catalog} />
|
||||
}
|
||||
22
app/[locale]/(root)/(shop)/category/[[...slug]]/page.tsx
Normal file
22
app/[locale]/(root)/(shop)/category/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {getCategoryBySlugWitData} from '@prisma/client/sql'
|
||||
import {notFound} from 'next/navigation'
|
||||
|
||||
import CategoryPageIndex from '@/components/pages/category/page'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export default async function Categories({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{slug?: string}>
|
||||
}) {
|
||||
const {slug} = await params
|
||||
const [uri] = slug || []
|
||||
const category: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getCategoryBySlugWitData(uri)
|
||||
)
|
||||
|
||||
if (category.length < 2) notFound()
|
||||
|
||||
return <CategoryPageIndex data={category} />
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import {getCatalogIndexData} from '@prisma/client/sql'
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import Image from 'next/image'
|
||||
|
||||
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 {carousels} from '@/lib/data'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {dump} from '@/lib/utils'
|
||||
import mainFallback from '@/public/main-fallback.jpg'
|
||||
import image from '@/public/uploads/products/IMG_6572.jpg'
|
||||
|
||||
// const storeModel = async (id: any) => {
|
||||
@@ -26,6 +31,11 @@ import image from '@/public/uploads/products/IMG_6572.jpg'
|
||||
// }
|
||||
|
||||
export default async function HomePage() {
|
||||
const loc = await getLocale()
|
||||
const catalog: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getCatalogIndexData(loc)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mt-1'>
|
||||
@@ -34,18 +44,27 @@ export default async function HomePage() {
|
||||
<AppCatalog />
|
||||
</section>
|
||||
<div className='bw-layout-col-right pt-3'>
|
||||
{/*<pre>{dump(await storeModel(1))}</pre>*/}
|
||||
<section className='w-full'>
|
||||
<HomeCarousel items={carousels}></HomeCarousel>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*<pre>{JSON.stringify(session)}</pre>*/}
|
||||
|
||||
<section className='mb-4 mt-[128px]'>
|
||||
<div className='container'>
|
||||
<FeatureCards />
|
||||
<section className='container mb-4 mt-[128px]'>
|
||||
<FeatureCards items={catalog} />
|
||||
<div className='re relative my-12 overflow-hidden'>
|
||||
<Image
|
||||
alt={''}
|
||||
width={1440}
|
||||
height={753}
|
||||
src={mainFallback}
|
||||
sizes='100vw'
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "@/components/pages/product/embla.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -120,6 +122,13 @@ body {
|
||||
@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
|
||||
}
|
||||
|
||||
.bw-product-col-left{
|
||||
@apply flex-1 sm:w-5/12 sm:flex-auto sm:pl-4 md:w-7/12 md:pl-7 xl:w-auto xl:pl-9
|
||||
}
|
||||
.bw-product-col-right{
|
||||
@apply flex-1 sm:w-7/12 md:w-5/12 lg:flex-col xl:w-[360px] xl:flex-none
|
||||
}
|
||||
|
||||
.bw-header-col-left {
|
||||
@apply w-[9/12] flex-auto
|
||||
}
|
||||
@@ -221,8 +230,10 @@ body {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1.45 !important;
|
||||
color: rgb(40, 26, 76) !important;
|
||||
|
||||
background: transparent !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.bw-product__text {
|
||||
h2 * {
|
||||
font-weight: 700 !important;
|
||||
@@ -234,5 +245,23 @@ body {
|
||||
input {
|
||||
@apply text-xl leading-none border-0 text-brand-violet font-bold;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.bw-cart-btn.lucide {
|
||||
@apply text-brand-violet;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
&:hover {
|
||||
@apply drop-shadow-lg shadow-brand-violet-400;
|
||||
}
|
||||
}
|
||||
|
||||
.bw-card-footer {
|
||||
background: #f2f5fa !important;
|
||||
}
|
||||
|
||||
.bw-cart-wrapper {
|
||||
.col{
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,14 @@ import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import dynamic from 'next/dynamic'
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {useFieldArray, useForm} from 'react-hook-form'
|
||||
import toast from 'react-hot-toast'
|
||||
import {z} from 'zod'
|
||||
|
||||
import {onProductCreateAction} from '@/actions/admin/product'
|
||||
import {useToast} from '@/hooks/use-toast'
|
||||
import {i18nDefaultLocale, i18nLocales} from '@/i18n-config'
|
||||
import {BaseEditorConfig} from '@/lib/config/editor'
|
||||
import {createProductFormSchema} from '@/lib/schemas/admin/product'
|
||||
import {toEmptyParams} from '@/lib/utils'
|
||||
import useCountStore from '@/store/cart-store'
|
||||
import {Button} from '@/ui/button'
|
||||
import {
|
||||
Form,
|
||||
@@ -64,7 +63,6 @@ export default function ProductCreateEditForm({data}: {data?: any}) {
|
||||
data?.locales[1].instruction || ''
|
||||
)
|
||||
const editor = useRef(null) //declared a null value
|
||||
const {toast} = useToast()
|
||||
|
||||
const config = useMemo(() => BaseEditorConfig, [])
|
||||
|
||||
@@ -129,20 +127,12 @@ export default function ProductCreateEditForm({data}: {data?: any}) {
|
||||
setError(res?.error)
|
||||
setSuccess('')
|
||||
setLoading(false)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: res?.error,
|
||||
description: res?.message
|
||||
})
|
||||
toast.error(res?.error)
|
||||
} else {
|
||||
setSuccess(res?.success as string)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
toast({
|
||||
variant: 'success',
|
||||
title: res?.success,
|
||||
description: res?.message
|
||||
})
|
||||
toast.success(res?.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// import styles from '@/components/pages/cart/cart.module.scss'
|
||||
import Link from 'next/link'
|
||||
import {Minus, Plus} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import useCartStore from '@/store/cart-store'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import useCartStore, {CartItem} from '@/store/cart-store'
|
||||
import {Button} from '@/ui/button'
|
||||
import {Input} from '@/ui/input'
|
||||
|
||||
export default function CartItems() {
|
||||
const {cartItems} = useCartStore()
|
||||
@@ -27,7 +28,7 @@ export default function CartItems() {
|
||||
<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={'/products'}
|
||||
href={'/catalog'}
|
||||
className='rounded-md bg-orange-500 px-6 py-2 text-white'
|
||||
>
|
||||
Shop
|
||||
@@ -38,33 +39,45 @@ export default function CartItems() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{cartItems?.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='bsdg-brand-violet-200 bw-cart-item-counter my-4 flex items-center gap-4 py-4'
|
||||
>
|
||||
<div className='flex-auto bg-brand-violet-100'>{item.title}</div>
|
||||
{cartItems?.map((item: CartItem, i: number) => (
|
||||
<div className='my-4 flex items-center' key={i}>
|
||||
<div className='col'>
|
||||
{item.title}
|
||||
<Image
|
||||
src={(item?.image || '').replace('.jpg', '-thumb.jpg')}
|
||||
alt=''
|
||||
width={96}
|
||||
height={96}
|
||||
className='rounded-md border'
|
||||
style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-none'>
|
||||
<div className='flex w-16 flex-none items-center justify-center'>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className='rounded-0'
|
||||
className='rounded-0 h-[3rem] w-[3rem] text-2xl leading-none text-brand-violet'
|
||||
onClick={() => onDecreaseQuantity(item.id)}
|
||||
>
|
||||
-
|
||||
<Minus />
|
||||
</Button>
|
||||
<div className='mx-4 border-0 text-xl font-bold leading-none text-brand-violet'>
|
||||
<div className='mx-4 text-xl font-bold leading-none text-brand-violet'>
|
||||
{item.quantity}
|
||||
</div>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className='rounded-0'
|
||||
className='rounded-0 h-[3rem] w-[3rem] text-2xl leading-none text-brand-violet'
|
||||
onClick={() => onIncreaseQuantity(item.id)}
|
||||
>
|
||||
+
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='text-3 flex-auto text-right font-bold'>
|
||||
</div>
|
||||
<div className='col text-right text-lg font-bold'>
|
||||
{(item.price * item.quantity).toFixed(2)} грн
|
||||
</div>
|
||||
</div>
|
||||
|
||||
29
components/pages/category/page.tsx
Normal file
29
components/pages/category/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import AppCatalog from '@/components/shared/sidebar/app-catalog'
|
||||
import FeatureCard from '@/components/shared/store/feature-card'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {thisLocales} from '@/lib/utils'
|
||||
|
||||
export default async function CategoryPageIndex({
|
||||
data
|
||||
}: {
|
||||
data: CategoryPageSqlSchema[]
|
||||
}) {
|
||||
const locales = await thisLocales(data)
|
||||
|
||||
return (
|
||||
<div className='mt-1'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<section className='bw-layout-col-left pt-3'>
|
||||
<AppCatalog />
|
||||
</section>
|
||||
<div className='bw-layout-col-right pt-3'>
|
||||
<section className='grid w-full grid-cols-3 gap-6 p-6 shadow-xl shadow-brand-violet/25'>
|
||||
{locales.map((card: CategoryPageSqlSchema, i: number) => (
|
||||
<FeatureCard key={i} card={card} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
components/pages/product/carousel.tsx
Normal file
116
components/pages/product/carousel.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import {ProductResource} from '@prisma/client'
|
||||
import {EmblaOptionsType} from 'embla-carousel'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import Image from 'next/image'
|
||||
import React, {useCallback, useEffect, useState} from 'react'
|
||||
|
||||
import {Thumb} from './thumb'
|
||||
import {Dialog, DialogContent, DialogTitle} from '@/ui/dialog'
|
||||
|
||||
type PropType = {
|
||||
slides: number[]
|
||||
options?: EmblaOptionsType
|
||||
}
|
||||
|
||||
export default function ProductCarousel({
|
||||
images,
|
||||
title
|
||||
}: {
|
||||
images: ProductResource[] | null
|
||||
title: string
|
||||
}) {
|
||||
//const {slides, options} = props
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [emblaMainRef, emblaMainApi] = useEmblaCarousel({}) //options
|
||||
const [emblaThumbsRef, emblaThumbsApi] = useEmblaCarousel({
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true
|
||||
})
|
||||
|
||||
const onThumbClick = useCallback(
|
||||
(index: number) => {
|
||||
if (!emblaMainApi || !emblaThumbsApi) return
|
||||
emblaMainApi.scrollTo(index)
|
||||
},
|
||||
[emblaMainApi, emblaThumbsApi]
|
||||
)
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaMainApi || !emblaThumbsApi) return
|
||||
setSelectedIndex(emblaMainApi.selectedScrollSnap())
|
||||
emblaThumbsApi.scrollTo(emblaMainApi.selectedScrollSnap())
|
||||
}, [emblaMainApi, emblaThumbsApi, setSelectedIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaMainApi) return
|
||||
onSelect()
|
||||
|
||||
emblaMainApi.on('select', onSelect).on('reInit', onSelect)
|
||||
}, [emblaMainApi, onSelect])
|
||||
//let featuredImage: ProductResource | null | undefined
|
||||
/*if ((resources || []).length > 0) {
|
||||
featuredImage = resources?.find(resource => resource.isFeature)
|
||||
}*/
|
||||
|
||||
return (
|
||||
<div className='embla my-8'>
|
||||
<div className='embla__viewport' ref={emblaMainRef}>
|
||||
<div className='embla__container'>
|
||||
{images?.map((image: ProductResource, index: number) => (
|
||||
<div className='embla__slide' key={index}>
|
||||
<div className='embla__slide__number h-[480px] overflow-hidden'>
|
||||
<Image
|
||||
src={image.uri}
|
||||
alt=''
|
||||
width={image.width || 100}
|
||||
height={image.height || 100}
|
||||
sizes='100vw'
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: '480px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='embla-thumbs mb-8 mt-6'>
|
||||
<div className='embla-thumbs__viewport' ref={emblaThumbsRef}>
|
||||
<div className='embla-thumbs__container flex items-center justify-center gap-x-6'>
|
||||
{images?.map((image: ProductResource, index: number) => (
|
||||
<Dialog key={index}>
|
||||
<DialogTitle className='hidden'>{title}</DialogTitle>
|
||||
<Thumb
|
||||
image={image}
|
||||
onClick={() => onThumbClick(index)}
|
||||
selected={index === selectedIndex}
|
||||
index={index}
|
||||
/>
|
||||
<DialogContent className='overflow w-full'>
|
||||
<Image
|
||||
src={image.uri}
|
||||
alt=''
|
||||
width={image.width || 100}
|
||||
height={image.height || 100}
|
||||
sizes='100vw'
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '100dvh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
components/pages/product/embla.css
Normal file
81
components/pages/product/embla.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.embla {
|
||||
max-width: 48rem;
|
||||
margin: auto;
|
||||
--slide-height: 19rem;
|
||||
--slide-spacing: 1rem;
|
||||
--slide-size: 100%;
|
||||
}
|
||||
.embla__viewport {
|
||||
overflow: hidden;
|
||||
}
|
||||
.embla__container {
|
||||
display: flex;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
margin-left: calc(var(--slide-spacing) * -1);
|
||||
}
|
||||
.embla__slide {
|
||||
transform: translate3d(0, 0, 0);
|
||||
flex: 0 0 var(--slide-size);
|
||||
min-width: 0;
|
||||
padding-left: var(--slide-spacing);
|
||||
}
|
||||
.embla__slide__number {
|
||||
box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast);
|
||||
border-radius: 1.8rem;
|
||||
font-size: 4rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--slide-height);
|
||||
user-select: none;
|
||||
}
|
||||
.embla-thumbs {
|
||||
--thumbs-slide-spacing: 0.8rem;
|
||||
--thumbs-slide-height: 6rem;
|
||||
margin-top: var(--thumbs-slide-spacing);
|
||||
}
|
||||
.embla-thumbs__viewport {
|
||||
overflow: hidden;
|
||||
}
|
||||
.embla-thumbs__container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: calc(var(--thumbs-slide-spacing) * -1);
|
||||
}
|
||||
.embla-thumbs__slide {
|
||||
flex: 0 0 22%;
|
||||
min-width: 0;
|
||||
padding-left: var(--thumbs-slide-spacing);
|
||||
}
|
||||
@media (min-width: 576px) {
|
||||
.embla-thumbs__slide {
|
||||
flex: 0 0 15%;
|
||||
}
|
||||
}
|
||||
.embla-thumbs__slide__number {
|
||||
border-radius: 1.8rem;
|
||||
-webkit-tap-highlight-color: rgba(var(--text-high-contrast-rgb-value), 0.5);
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: transparent;
|
||||
touch-action: manipulation;
|
||||
display: inline-flex;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-shadow: inset 0 0 0 0.2rem var(--detail-medium-contrast);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--detail-high-contrast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--thumbs-slide-height);
|
||||
width: 100%;
|
||||
}
|
||||
.embla-thumbs__slide--selected .embla-thumbs__slide__number {
|
||||
color: var(--text-body);
|
||||
}
|
||||
@@ -1,37 +1,79 @@
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import {ProductResource} from '@prisma/client'
|
||||
import {getProductByIdWitData} from '@prisma/client/sql'
|
||||
import {getTranslations} from 'next-intl/server'
|
||||
import {notFound} from 'next/navigation'
|
||||
import {strict} from 'node:assert'
|
||||
|
||||
import AddCartButton from '@/components/pages/product/add-cart-button'
|
||||
import {ProductProps, getProductById} from '@/lib/data/models/product'
|
||||
import {dump} from '@/lib/utils'
|
||||
import useCartStore from '@/store/cart-store'
|
||||
import ProductCarousel from '@/components/pages/product/carousel'
|
||||
import AddCartButton from '@/components/shared/store/add-cart-button'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb'
|
||||
import {getProductResources} from '@/lib/data/models/product'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
import {thisLocale, toPrice} from '@/lib/utils'
|
||||
import {Separator} from '@/ui/separator'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/ui/tabs'
|
||||
|
||||
export default async function ProductPageIndex({id}: {id: string}) {
|
||||
const product = await getProductById(parseInt(id))
|
||||
if (!product) notFound()
|
||||
const {locales, toStore} = product as ProductProps
|
||||
const lang = await getLocale()
|
||||
const locale = locales[lang === 'uk' ? 0 : 1]
|
||||
const store = JSON.parse(JSON.stringify(toStore[0]))
|
||||
const t = await getTranslations('Common')
|
||||
|
||||
const data: CategoryPageSqlSchema[] = await db.$queryRawTyped(
|
||||
getProductByIdWitData(id)
|
||||
)
|
||||
|
||||
const locale = await thisLocale(data)
|
||||
if (!locale) notFound()
|
||||
|
||||
const resources: ProductResource[] | null = await getProductResources(
|
||||
parseInt(id)
|
||||
)
|
||||
|
||||
//const files = await getMetaOfFile(locale.productId)
|
||||
|
||||
return (
|
||||
<div className='mt-1'>
|
||||
<div className='container flex flex-col sm:flex-row'>
|
||||
<div>
|
||||
<div className='container flex flex-col gap-8 sm:flex-row'>
|
||||
{/*<pre>{dump(resources)}</pre>*/}
|
||||
<div className='bw-product-col-left pt-3'>
|
||||
<Breadcrumb className='mb-4'>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={'/'}>{t('home')}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href={`/category/${locale.categorySlug}`}>
|
||||
{locale.categoryTitle}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{locale.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className='flex w-[82%] items-center justify-between'>
|
||||
<h1 className='my-4 text-3xl font-bold text-brand-violet-950'>
|
||||
{locale.title}
|
||||
</h1>
|
||||
<AddCartButton
|
||||
product={{
|
||||
id: product.id,
|
||||
id: locale.productId,
|
||||
quantity: 1,
|
||||
title: locale.title,
|
||||
price: store.price as string, //parseFloat().toFixed(2),
|
||||
image: product.image
|
||||
price: toPrice(locale.price),
|
||||
image: locale.image
|
||||
}}
|
||||
/>
|
||||
<hr />
|
||||
</div>
|
||||
<div>
|
||||
<Separator className='my-4 w-[82%] border-b border-brand-violet' />
|
||||
<ProductCarousel images={resources} title={locale.title} />
|
||||
<Tabs defaultValue='article' className=''>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='article'>Опис</TabsTrigger>
|
||||
@@ -51,6 +93,14 @@ export default async function ProductPageIndex({id}: {id: string}) {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<section className='bw-product-col-right bg-gray-50 pt-3'>
|
||||
<div className=''>
|
||||
{t('price')}:
|
||||
<span className='ml-2 flex-auto text-right text-xl font-bold text-brand-violet'>
|
||||
{toPrice(locale.price)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
46
components/pages/product/thumb.tsx
Normal file
46
components/pages/product/thumb.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {ProductResource} from '@prisma/client'
|
||||
import Image from 'next/image'
|
||||
import React from 'react'
|
||||
|
||||
import {Button} from '@/ui/button'
|
||||
import {DialogTrigger} from '@/ui/dialog'
|
||||
|
||||
type PropType = {
|
||||
selected: boolean
|
||||
index: number
|
||||
onClick: () => void
|
||||
image: ProductResource
|
||||
}
|
||||
|
||||
export const Thumb: React.FC<PropType> = props => {
|
||||
const {selected, index, onClick, image} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'embla-thumbs__slide'.concat(
|
||||
selected ? 'embla-thumbs__slide--selected' : ''
|
||||
)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
onClick={onClick}
|
||||
type='button'
|
||||
className='embla-thumbs__slide__number'
|
||||
>
|
||||
<Image
|
||||
src={image.uri.replace('.jpg', '-thumb.jpg')}
|
||||
alt=''
|
||||
width={96}
|
||||
height={96}
|
||||
className='rounded-md border'
|
||||
style={{
|
||||
width: '96px',
|
||||
height: '96px',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,13 +15,6 @@ type MenuProps = {
|
||||
}
|
||||
|
||||
export default async function Header() {
|
||||
/*{
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{query?: string}>
|
||||
}*/
|
||||
//const query = (await searchParams).query
|
||||
|
||||
return (
|
||||
<header className='w-full border-none bg-background text-white'>
|
||||
<div className='container flex'>
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
|
||||
import {cards} from '@/lib/data'
|
||||
import {Card, CardContent} from '@/ui/card'
|
||||
import FeatureCardFront from '@/components/shared/store/feature-card-front'
|
||||
import {CategoryPageSqlSchema} from '@/lib/data/models/sqlSchemas'
|
||||
import {Carousel, CarouselContent, CarouselItem} from '@/ui/carousel'
|
||||
|
||||
export default function FeatureCards() {
|
||||
export default function FeatureCards({
|
||||
items
|
||||
}: {
|
||||
items: CategoryPageSqlSchema[]
|
||||
}) {
|
||||
return (
|
||||
<Carousel className='mx-auto w-full'>
|
||||
<CarouselContent className='-ml-2 md:-ml-4'>
|
||||
{cards.map((card: any) => (
|
||||
{items.map((card: any) => (
|
||||
<CarouselItem
|
||||
key={card.title}
|
||||
className='pl-3 md:basis-1/3 lg:basis-1/4 xl:basis-1/5'
|
||||
className='py-4 pl-3 md:basis-1/3 lg:basis-1/4'
|
||||
>
|
||||
<div className='p-1'>
|
||||
<Card className='border-[2px] border-brand-violet'>
|
||||
<CardContent className='aspect-card flex items-center justify-center p-1'>
|
||||
<CarouselItem>
|
||||
<Image
|
||||
src={card.image}
|
||||
width={256}
|
||||
height={256}
|
||||
className='object-scale-down'
|
||||
priority
|
||||
alt={''}
|
||||
/>
|
||||
</CarouselItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FeatureCardFront card={card} />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import cookies from 'js-cookie'
|
||||
import {useLocale} from 'next-intl'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
import {Link, usePathname, useRouter} from '@/i18n/routing'
|
||||
import {useRouter} from '@/i18n/routing'
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {Label} from '@/ui/label'
|
||||
import {Switch} from '@/ui/switch'
|
||||
|
||||
export default function LocaleSwitcher() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const locale = useLocale()
|
||||
const initialState = locale !== 'uk'
|
||||
//const [localeState, setLocaleState] = useState(initialState)
|
||||
|
||||
const handler = (state: boolean) => {
|
||||
const newPath = `/${locale}${pathname}`
|
||||
//window.history.replaceState(null, '', newPath)
|
||||
const link = document.getElementById('lang-switch')
|
||||
if (link) {
|
||||
link.innerText = `${state ? '/ru' : ''}${pathname}`
|
||||
link.setAttribute('href', `${state ? '/ru' : ''}${pathname}`)
|
||||
link.click()
|
||||
const handleLocaleChange = (state: boolean) => {
|
||||
const locale = cookies.get('NEXT_LOCALE') === 'ru' ? 'uk' : 'ru'
|
||||
cookies.set('NEXT_LOCALE', locale, {
|
||||
expires: 7,
|
||||
path: '/',
|
||||
sameSite: 'Lax'
|
||||
})
|
||||
router.replace(window.location.pathname.replace(/^\/ru/, ''), {locale})
|
||||
}
|
||||
}
|
||||
// router.replace('/cabinet', {locale: checked ? 'ru' : 'uk'}
|
||||
|
||||
return (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Link id='lang-switch' href='/' locale='uk'>
|
||||
LA
|
||||
</Link>
|
||||
<Label htmlFor='locale-switcher'>Укр</Label>
|
||||
<Switch
|
||||
className='bg-brand-violet-900'
|
||||
id='locale-switcher'
|
||||
defaultChecked={initialState}
|
||||
onCheckedChange={handler}
|
||||
onCheckedChange={handleLocaleChange}
|
||||
/>
|
||||
<Label htmlFor='locale-switcher'>Рус</Label>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {Menu as MenuIcon, X} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {useState} from 'react'
|
||||
|
||||
import {Link} from '@/i18n/routing'
|
||||
import {data} from '@/lib/data'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
@@ -21,7 +20,7 @@ export default function NavbarMenu() {
|
||||
<div className={`hidden ${bp}:block w-full`}>
|
||||
<div className='flex items-center justify-between'>
|
||||
{data.headerMenus.map(item => (
|
||||
<Link href={item.href + item.slug} className='' key={item.name}>
|
||||
<Link href='/about-us' className='' key={item.name}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,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'>
|
||||
<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>
|
||||
<span>
|
||||
@@ -24,6 +25,7 @@ export default function AppCatalogRender(data: {items: Category[]}) {
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<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
|
||||
@@ -33,7 +35,7 @@ export default function AppCatalogRender(data: {items: Category[]}) {
|
||||
<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]'
|
||||
href={`/category/${item.id}-${item.locales[locale === 'uk' ? 0 : 1].slug}`}
|
||||
href={`/category/${item.locales[locale === 'uk' ? 0 : 1].slug}`}
|
||||
>
|
||||
{item.locales[locale === 'uk' ? 0 : 1].title}
|
||||
</Link>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useTranslations} from 'next-intl'
|
||||
|
||||
import {dump} from '@/lib/utils'
|
||||
import useCartStore, {CartItem} from '@/store/cart-store'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function AddCartButton({product}: {product: CartItem}) {
|
||||
const t = useTranslations('cart')
|
||||
@@ -13,16 +14,20 @@ export default function AddCartButton({product}: {product: CartItem}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='flex flex-col items-center'
|
||||
role='button'
|
||||
{/*<Button
|
||||
className='bw-cart-btn flex flex-col items-center border-0 shadow-none'
|
||||
variant='outline'
|
||||
title={t('basket')}
|
||||
onClick={() => addItemToCart(product)}
|
||||
>
|
||||
<ShoppingCartIcon className='h-[21px] w-[21px]' />
|
||||
</button>
|
||||
|
||||
<pre>{dump(cartItems)}</pre>
|
||||
</Button>*/}
|
||||
|
||||
<ShoppingCartIcon
|
||||
role='button'
|
||||
className='bw-cart-btn h-[24px] w-[24px]'
|
||||
onClick={() => addItemToCart(product)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
components/shared/store/card-buy-button.tsx
Normal file
31
components/shared/store/card-buy-button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import {ShoppingCartIcon} from 'lucide-react'
|
||||
import {useTranslations} from 'next-intl'
|
||||
|
||||
import useCartStore, {CartItem} from '@/store/cart-store'
|
||||
import {Button} from '@/ui/button'
|
||||
|
||||
export default function CardBuyButton({
|
||||
item,
|
||||
isIcon
|
||||
}: {
|
||||
item: CartItem
|
||||
isIcon?: boolean
|
||||
}) {
|
||||
const t = useTranslations('cart')
|
||||
const addItemToCart = useCartStore(state => state.addItemToCart)
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={`mr-2 ${isIcon ? '' : 'w-[80px]'} grow-0 shadow-white hover:shadow-md hover:shadow-brand-violet/50`}
|
||||
onClick={() => addItemToCart(item)}
|
||||
>
|
||||
{isIcon ? (
|
||||
<ShoppingCartIcon role='button' className='bw-cart-btn p-1' />
|
||||
) : (
|
||||
t('buy')
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
71
components/shared/store/feature-card-front.tsx
Normal file
71
components/shared/store/feature-card-front.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}: {
|
||||
card: CategoryPageSqlSchema
|
||||
}) {
|
||||
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>*/}
|
||||
<Image
|
||||
className='transition duration-300 hover:scale-110'
|
||||
src={card?.image || ''}
|
||||
width={card.imageWidth || 100}
|
||||
height={card.imageHeight || 100}
|
||||
/*className='object-scale-down'*/
|
||||
priority
|
||||
alt={''}
|
||||
sizes='100vw'
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: 'calc(100% - 24px)'
|
||||
}}
|
||||
/>
|
||||
<RateStars />
|
||||
{/*</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=''>
|
||||
<p className='ml-2 border-b border-b-brand-violet pl-2 pr-6 text-[16px]'>
|
||||
{card.title}
|
||||
</p>
|
||||
<p className='pl-4 text-[16px] font-bold text-brand-violet'>
|
||||
{parseFloat(card.price as string).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<CardBuyButton
|
||||
item={{
|
||||
id: card.productId,
|
||||
quantity: 1,
|
||||
title: card.title,
|
||||
price: parseFloat(card.price as string).toFixed(2)
|
||||
}}
|
||||
isIcon={true}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// id: number
|
||||
// quantity: number
|
||||
// title: string
|
||||
// price: string | any
|
||||
// image?: string | null
|
||||
// imageWidth?: number | null
|
||||
// imageHeight?: number | null
|
||||
65
components/shared/store/feature-card.tsx
Normal file
65
components/shared/store/feature-card.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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>*/}
|
||||
<Image
|
||||
className='transition duration-300 hover:scale-110'
|
||||
src={card.image}
|
||||
width={card.imageWidth}
|
||||
height={card.imageHeight}
|
||||
/*className='object-scale-down'*/
|
||||
priority
|
||||
alt={''}
|
||||
sizes='100vw'
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
width: '100%',
|
||||
height: 'calc(100% - 24px)'
|
||||
}}
|
||||
/>
|
||||
<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=''>
|
||||
<p className='ml-2 border-b border-b-brand-violet pl-2 pr-6'>
|
||||
{card.title}
|
||||
</p>
|
||||
<p className='pl-4 text-[18px] font-bold text-brand-violet'>
|
||||
{parseFloat(card.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<CardBuyButton
|
||||
item={{
|
||||
id: card.productId,
|
||||
quantity: 1,
|
||||
title: card.title,
|
||||
price: parseFloat(card.price).toFixed(2),
|
||||
image: card.image
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// id: number
|
||||
// quantity: number
|
||||
// title: string
|
||||
// price: string | any
|
||||
// image?: string | null
|
||||
// imageWidth?: number | null
|
||||
// imageHeight?: number | null
|
||||
16
components/shared/store/stars.tsx
Normal file
16
components/shared/store/stars.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import {Star} from 'lucide-react'
|
||||
|
||||
const startStroke = 1.5
|
||||
const color = '#ffd139'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
120
components/ui/table.tsx
Normal file
120
components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import {type VariantProps, cva} from 'class-variance-authority'
|
||||
import {X} from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {cn} from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({className, ...props}, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
success:
|
||||
'group text-emerald-950-foreground border-emerald-600 bg-emerald-200',
|
||||
warning:
|
||||
'group text-amber-950-foreground border-amber-700 bg-amber-200',
|
||||
brand:
|
||||
'text-brand-violet-950 border-brand-violet-200 bg-brand-violet-50'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'brand'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({className, variant, ...props}, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({variant}), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({className, ...props}, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors group-[.destructive]:border-muted/40 hover:bg-secondary group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground focus:outline-none focus:ring-1 focus:ring-ring group-[.destructive]:focus:ring-destructive disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({className, ...props}, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 group-[.destructive]:text-red-300 hover:text-foreground group-[.destructive]:hover:text-red-50 focus:opacity-100 focus:outline-none focus:ring-1 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=''
|
||||
{...props}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({className, ...props}, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({className, ...props}, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
9
data/products.ts
Normal file
9
data/products.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default [
|
||||
{
|
||||
id: 0,
|
||||
qnt: '14',
|
||||
form: 'порошок',
|
||||
vendor: 'Fine Foods & Pharmaceuticals N.T.M. SpA',
|
||||
country: 'Італія'
|
||||
}
|
||||
]
|
||||
@@ -1,191 +0,0 @@
|
||||
'use client'
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from 'react'
|
||||
|
||||
import type {ToastActionElement, ToastProps} from '@/components/ui/toast'
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST'
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST']
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST']
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST']
|
||||
toastId?: ToasterToast['id']
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
|
||||
}
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map(t =>
|
||||
t.id === action.toast.id ? {...t, ...action.toast} : t
|
||||
)
|
||||
}
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const {toastId} = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach(toast => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map(t =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false
|
||||
}
|
||||
: t
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter(t => t.id !== action.toastId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = {toasts: []}
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach(listener => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>
|
||||
|
||||
function toast({...props}: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: {...props, id}
|
||||
})
|
||||
const dismiss = () => dispatch({type: 'DISMISS_TOAST', toastId: id})
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: open => {
|
||||
if (!open) dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({type: 'DISMISS_TOAST', toastId})
|
||||
}
|
||||
}
|
||||
|
||||
export {useToast, toast}
|
||||
67
lib/config/resources.ts
Normal file
67
lib/config/resources.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {ResourceType} from '@prisma/client'
|
||||
import im, {Features} from 'imagemagick'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const PUBLIC_UPLOAD_PRODUCTS_DIR = '/uploads/products'
|
||||
export const UPLOAD_PRODUCTS_DIR = path.resolve(
|
||||
`./public${PUBLIC_UPLOAD_PRODUCTS_DIR}`
|
||||
)
|
||||
|
||||
export function getFilesByProductId(id: number, fullPath: boolean = true) {
|
||||
const files = fs
|
||||
.readdirSync(UPLOAD_PRODUCTS_DIR)
|
||||
.filter(file => file.startsWith(`${id}-`))
|
||||
|
||||
return !fullPath
|
||||
? files
|
||||
: files.map(file => path.join(UPLOAD_PRODUCTS_DIR, file))
|
||||
}
|
||||
|
||||
interface ProductImage {
|
||||
type: string
|
||||
productId: number
|
||||
filesize: number
|
||||
height: number
|
||||
width: number
|
||||
'mime type'?: string
|
||||
mimeType: string
|
||||
quality: number
|
||||
properties: {
|
||||
signature: string
|
||||
}
|
||||
signature: string
|
||||
}
|
||||
|
||||
export async function getMetaOfFile(id: number) {
|
||||
getFilesByProductId(id).forEach(file => {
|
||||
im.identify(file, async (err, features) => {
|
||||
if (err) throw err
|
||||
// { format: 'JPEG', width: 3904, height: 2622, depth: 8 }
|
||||
const data = features as ProductImage
|
||||
|
||||
try {
|
||||
const result = await db.productResource.create({
|
||||
data: {
|
||||
type: 'IMAGE' as ResourceType,
|
||||
productId: id,
|
||||
uri: PUBLIC_UPLOAD_PRODUCTS_DIR + '/' + path.basename(file),
|
||||
filesize: parseInt(data.filesize as unknown as string),
|
||||
height: data.height,
|
||||
width: data.width,
|
||||
quality: data.quality,
|
||||
mimeType: data['mime type'] || 'image/*',
|
||||
signature: data.properties.signature
|
||||
//meta: features
|
||||
}
|
||||
})
|
||||
|
||||
//return result
|
||||
} catch (e) {
|
||||
//console.log(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export const data = {
|
||||
{
|
||||
name: 'Про нас',
|
||||
slug: slug('Про нас'),
|
||||
href: '/search?tag='
|
||||
href: '/about-us'
|
||||
},
|
||||
{
|
||||
name: "Цікаво про здоров'я",
|
||||
|
||||
22
lib/data/models/category.ts
Normal file
22
lib/data/models/category.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
'use server'
|
||||
|
||||
import {CategoryLocale} from '@prisma/client'
|
||||
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import {db} from '@/lib/db/prisma/client'
|
||||
|
||||
export const getCategoryBySlug = async (slug: string) => {
|
||||
return db.categoryLocale.findFirst({
|
||||
where: {
|
||||
slug
|
||||
},
|
||||
select: {
|
||||
category: {
|
||||
include: {
|
||||
locales: true,
|
||||
categoriesOnProducts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
'use server'
|
||||
|
||||
import {Lang, Product, ProductLocale, ProductToStore} from '@prisma/client'
|
||||
import {
|
||||
CategoriesOnProducts,
|
||||
CategoryLocale,
|
||||
Lang,
|
||||
Product,
|
||||
ProductLocale,
|
||||
ProductResource,
|
||||
ProductToStore
|
||||
} from '@prisma/client'
|
||||
|
||||
import {STORE_ID} from '@/lib/config/constants'
|
||||
import {db, dbQueryLog} from '@/lib/db/prisma/client'
|
||||
|
||||
export interface ProductProps extends Product {
|
||||
locales: ProductLocale[]
|
||||
toStore: ProductToStore[]
|
||||
categoriesOnProducts: CategoriesOnProducts[]
|
||||
resources: ProductResource[]
|
||||
}
|
||||
|
||||
export const getProductBySlug = async (data: {
|
||||
@@ -24,8 +35,28 @@ export const getProductBySlug = async (data: {
|
||||
export const getProductById = async (id: unknown): Promise<Product | null> => {
|
||||
return db.product.findUnique({
|
||||
where: {id: id as number},
|
||||
include: {
|
||||
resources: true,
|
||||
categoriesOnProducts: {
|
||||
where: {
|
||||
storeId: STORE_ID
|
||||
},
|
||||
include: {
|
||||
category: {
|
||||
include: {
|
||||
locales: {
|
||||
orderBy: {
|
||||
lang: 'asc'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
locales: {
|
||||
orderBy: {
|
||||
lang: 'asc'
|
||||
},
|
||||
include: {
|
||||
meta: true
|
||||
}
|
||||
@@ -39,6 +70,9 @@ export const getProducts = async (): Promise<Product[] | null> => {
|
||||
return db.product.findMany({
|
||||
include: {
|
||||
locales: {
|
||||
orderBy: {
|
||||
lang: 'asc'
|
||||
},
|
||||
omit: {
|
||||
description: true,
|
||||
content: true,
|
||||
@@ -52,3 +86,13 @@ export const getProducts = async (): Promise<Product[] | null> => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const getProductResources = async (
|
||||
productId: number | null
|
||||
): Promise<ProductResource[] | null> => {
|
||||
return db.productResource.findMany({
|
||||
where: {
|
||||
productId: productId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
23
lib/data/models/sqlSchemas.ts
Normal file
23
lib/data/models/sqlSchemas.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Decimal from 'decimal.js'
|
||||
|
||||
import {i18nLocalesCodes} from '@/i18n-config'
|
||||
import {locales} from '@/i18n/routing'
|
||||
|
||||
export type CategoryPageSqlSchema = {
|
||||
productId: number
|
||||
lang: string
|
||||
slug?: string | null | undefined
|
||||
image?: string | null | undefined
|
||||
imageWidth?: number | null | undefined
|
||||
imageHeight?: number | null | undefined
|
||||
categoryId?: number
|
||||
title: string
|
||||
shortTitle?: string | null | undefined
|
||||
description?: string | null | undefined
|
||||
content?: string | null | undefined
|
||||
headingTitle?: string | null | undefined
|
||||
instruction?: string | null | undefined
|
||||
categorySlug?: string | null | undefined
|
||||
categoryTitle?: string | null | undefined
|
||||
price?: string | null | Decimal
|
||||
}
|
||||
@@ -6,7 +6,7 @@ model Category {
|
||||
storeId Int @map("store_id")
|
||||
locales CategoryLocale[]
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
categoriesOnPruducts CategoriesOnProducts[]
|
||||
categoriesOnProducts CategoriesOnProducts[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
@@ -36,5 +36,5 @@ model CategoriesOnProducts {
|
||||
categoryId Int @map("category_id")
|
||||
|
||||
@@id([storeId, productId, categoryId])
|
||||
@@map("categories_on_pruducts")
|
||||
@@map("categories_on_products")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ model ProductLocale {
|
||||
shortTitle String? @map("short_title")
|
||||
headingTitle String? @map("heading_title")
|
||||
description String? @db.MediumText
|
||||
content String @db.MediumText
|
||||
content String? @db.MediumText
|
||||
instruction String? @db.MediumText
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
productId Int @map("product_id")
|
||||
@@ -40,12 +40,13 @@ model ProductResource {
|
||||
id Int @id @default(autoincrement())
|
||||
type ResourceType
|
||||
mimeType String? @map("mime_type")
|
||||
isFeature Boolean? @default(false)
|
||||
filesize Int? @db.UnsignedInt
|
||||
width Int? @db.UnsignedSmallInt
|
||||
height Int? @db.UnsignedSmallInt
|
||||
quality Int? @db.UnsignedTinyInt
|
||||
signature String? @db.Char(64)
|
||||
uri String @db.Text
|
||||
quality Float? @db.Float
|
||||
signature String @db.Char(64)
|
||||
uri String @db.VarChar(1024)
|
||||
title String? @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
meta Json? @db.Json
|
||||
@@ -54,6 +55,7 @@ model ProductResource {
|
||||
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([productId, signature])
|
||||
@@map("product_resources")
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ model ProductToStore {
|
||||
productId Int @map("product_id")
|
||||
storeId Int @map("store_id")
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
@@unique([storeId, productId])
|
||||
@@map("product_to_store")
|
||||
|
||||
20
lib/db/prisma/sql/getCatalogIndexData.sql
Normal file
20
lib/db/prisma/sql/getCatalogIndexData.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
SELECT DISTINCT
|
||||
p.id as productId,
|
||||
pl.lang,
|
||||
pl.slug,
|
||||
pr.uri as image,
|
||||
pr.width as imageWidth,
|
||||
pr.height as imageHeight,
|
||||
pts.price,
|
||||
pts.price_promotional as pricePromotional,
|
||||
pl.title,
|
||||
pl.short_title as shortTitle,
|
||||
pl.description
|
||||
-- pl.content, pl.heading_title as headingTitle, pl.instruction
|
||||
FROM products p
|
||||
JOIN categories_on_products cop ON p.id = cop.product_id
|
||||
JOIN product_locale pl ON pl.product_id = p.id
|
||||
JOIN product_to_store pts ON pts.product_id = p.id AND pts.store_id = cop.store_id
|
||||
LEFT JOIN product_resources pr ON p.id = pr.product_id AND pr.isFeature = TRUE
|
||||
WHERE cop.store_id = 1 AND pl.lang = ?
|
||||
ORDER BY p.id DESC;
|
||||
26
lib/db/prisma/sql/getCategoryBySlugWitData.sql
Normal file
26
lib/db/prisma/sql/getCategoryBySlugWitData.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
SELECT
|
||||
p.id as productId,
|
||||
pl.lang,
|
||||
pl.slug,
|
||||
pr.uri as image,
|
||||
pr.width as imageWidth,
|
||||
pr.height as imageHeight,
|
||||
pts.price,
|
||||
pts.price_promotional as pricePromotional,
|
||||
cl.category_id as categoryId,
|
||||
pl.title,
|
||||
pl.short_title as shortTitle,
|
||||
pl.description,
|
||||
-- pl.content,
|
||||
pl.heading_title as headingTitle,
|
||||
-- pl.instruction,
|
||||
cl.slug as categorySlug,
|
||||
cl.title as categoryTitle
|
||||
FROM products p
|
||||
JOIN categories_on_products cop ON p.id = cop.product_id
|
||||
JOIN product_locale pl ON pl.product_id = p.id
|
||||
JOIN category_locales cl ON cl.category_id = cop.category_id
|
||||
JOIN product_to_store pts ON pts.product_id = p.id AND pts.store_id = cop.store_id
|
||||
LEFT JOIN product_resources pr ON p.id = pr.product_id AND pr.isFeature = TRUE
|
||||
WHERE cop.store_id = 1 AND cl.slug = ?
|
||||
ORDER BY pl.lang;
|
||||
26
lib/db/prisma/sql/getProductByIdWitData.sql
Normal file
26
lib/db/prisma/sql/getProductByIdWitData.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
SELECT DISTINCT
|
||||
p.id as productId,
|
||||
pl.lang,
|
||||
pl.slug,
|
||||
pr.uri as image,
|
||||
pr.width as imageWidth,
|
||||
pr.height as imageHeight,
|
||||
pts.price,
|
||||
pts.price_promotional as pricePromotional,
|
||||
cl.category_id as categoryId,
|
||||
pl.title,
|
||||
pl.short_title as shortTitle,
|
||||
pl.description,
|
||||
pl.content,
|
||||
pl.heading_title as headingTitle,
|
||||
pl.instruction,
|
||||
cl.slug as categorySlug,
|
||||
cl.title as categoryTitle
|
||||
FROM products p
|
||||
JOIN categories_on_products cop ON p.id = cop.product_id
|
||||
JOIN product_locale pl ON pl.product_id = p.id
|
||||
JOIN category_locales cl ON cl.category_id = cop.category_id
|
||||
JOIN product_to_store pts ON pts.product_id = p.id AND pts.store_id = cop.store_id
|
||||
LEFT JOIN product_resources pr ON p.id = pr.product_id AND pr.isFeature = TRUE
|
||||
WHERE cop.store_id = 1 AND p.id = ?
|
||||
ORDER BY pl.lang
|
||||
15
lib/utils.ts
15
lib/utils.ts
@@ -1,9 +1,12 @@
|
||||
import {Prisma} from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import {type ClassValue, clsx} from 'clsx'
|
||||
import {getLocale} from 'next-intl/server'
|
||||
import slugify from 'slugify'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
import {i18nDefaultLocale} from '@/i18n-config'
|
||||
|
||||
/**
|
||||
* Just output dump using pretty output
|
||||
*
|
||||
@@ -16,6 +19,8 @@ export function dump(variable: any): [string, string] {
|
||||
]
|
||||
}
|
||||
|
||||
export const toPrice = (price: any) => parseFloat(price).toFixed(2)
|
||||
|
||||
/**
|
||||
* Create fallback avatar for showing during login process or in case if empty
|
||||
*
|
||||
@@ -118,6 +123,16 @@ export const toEmptyParams = (data: object | object[]) => {
|
||||
return result
|
||||
}
|
||||
|
||||
export const thisLocales = async (locales: any) => {
|
||||
const loc = await getLocale()
|
||||
return locales.filter((locale: any) => locale.lang === loc)
|
||||
}
|
||||
|
||||
export const thisLocale = async (locales: any) => {
|
||||
const loc = await getLocale()
|
||||
return locales.find((locale: any) => locale.lang === loc)
|
||||
}
|
||||
|
||||
export const dbErrorHandling = (e: unknown, message?: string | null) => {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return {error: `${e.code}: ${e.message}`}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"title": "Hello world!",
|
||||
"about": "Go to the about page"
|
||||
},
|
||||
"Common": {
|
||||
"home": "Главная",
|
||||
"price": "Цена"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Произошла ошибка",
|
||||
"try-again": "Попробуйте снова",
|
||||
@@ -24,8 +28,12 @@
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"buy": "Купить",
|
||||
"basket": "Корзина",
|
||||
"favorites": "Избранное"
|
||||
"favorites": "Избранное",
|
||||
"empty": "Корзина пуста",
|
||||
"continue": "Продолжить покупки",
|
||||
"checkout": "Оформить заказ"
|
||||
},
|
||||
"cabinet": {
|
||||
"personal-information": {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"title": "Привіт світ!",
|
||||
"about": "Go to the about page"
|
||||
},
|
||||
"Common": {
|
||||
"home": "Головна",
|
||||
"price": "Ціна"
|
||||
},
|
||||
"Error": {
|
||||
"title": "Сталася помилка",
|
||||
"try-again": "Спробуйте знову",
|
||||
@@ -27,11 +31,16 @@
|
||||
"title": "Каталог"
|
||||
},
|
||||
"cart": {
|
||||
"buy": "Купити",
|
||||
"basket": "Кошик",
|
||||
"favorites": "Обрані",
|
||||
"empty": "Кошик порожній",
|
||||
"continue": "Продовжити покупки",
|
||||
"checkout": "Оформити замовлення"
|
||||
"checkout": "Оформити замовлення",
|
||||
"title": "Назва",
|
||||
"quantity": "Кількість",
|
||||
"amount": "Вартість",
|
||||
"total": "Всього"
|
||||
},
|
||||
|
||||
"cabinet": {
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -21,7 +21,6 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"argon2": "^0.41.1",
|
||||
@@ -33,6 +32,7 @@
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"imagemagick": "^0.1.3",
|
||||
"jodit-react": "^5.2.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.471.1",
|
||||
"next": "^15.1.6",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
@@ -57,6 +57,7 @@
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
|
||||
"@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",
|
||||
@@ -74,8 +75,7 @@
|
||||
"prisma-json-types-generator": "^3.2.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5",
|
||||
"zod-prisma-types": "^3.2.1"
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -1648,13 +1648,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.2.1.tgz",
|
||||
"integrity": "sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.3.1.tgz",
|
||||
@@ -2388,40 +2381,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toast": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.5.tgz",
|
||||
"integrity": "sha512-ZzUsAaOx8NdXZZKcFNDhbSlbsCUy8qQWmzTdgrlrhhZAOx2ofLtKrBDW9fkqhFvXgmtv560Uj16pkLkqML7SHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.7.tgz",
|
||||
@@ -2776,6 +2735,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-cookie": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
|
||||
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3667,13 +3633,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/code-block-writer": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz",
|
||||
"integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -5989,6 +5948,15 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-cookie": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -8867,36 +8835,6 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-prisma-types": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.2.1.tgz",
|
||||
"integrity": "sha512-qsOD8aMVx3Yg9gHctvhTRpavaJizt8xUda6qjwOcH7suvrirXax38tjs0ilKcY7GKbyY57q2rZ+uoSDnzVXpag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/generator-helper": "^6.0.1",
|
||||
"code-block-writer": "^12.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"bin": {
|
||||
"zod-prisma-types": "dist/bin.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@prisma/client": "^4.x.x || ^5.x.x || ^6.x.x",
|
||||
"prisma": "^4.x.x || ^5.x.x || ^6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-prisma-types/node_modules/@prisma/generator-helper": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.2.1.tgz",
|
||||
"integrity": "sha512-7Ws8DCXGan7hhaFMERXYdmhsudvSzEsrTttJEC7ubZJidvyimS12m3xpM+dLTt+NAShJ7Op7PgF+Mal2jf6xfg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"argon2": "^0.41.1",
|
||||
@@ -56,6 +55,7 @@
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"imagemagick": "^0.1.3",
|
||||
"jodit-react": "^5.2.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.471.1",
|
||||
"next": "^15.1.6",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
@@ -80,6 +80,7 @@
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
|
||||
"@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",
|
||||
@@ -97,8 +98,7 @@
|
||||
"prisma-json-types-generator": "^3.2.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5",
|
||||
"zod-prisma-types": "^3.2.1"
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
@@ -8,6 +8,8 @@ export interface CartItem {
|
||||
title: string
|
||||
price: string | any
|
||||
image?: string | null
|
||||
imageWidth?: number | null
|
||||
imageHeight?: number | null
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
|
||||
Reference in New Issue
Block a user