From 94a38202ed004ce67cd29fc248bd9d422b2bd7e4 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Fri, 29 Aug 2025 22:37:27 +0200 Subject: [PATCH 01/11] chore: update barometer and document management components - Added a comment in the barometer page to investigate the use of Prisma.BarometerCreateInput type for schema foundation. - Renamed the form submission handler in the document page from `handleFormSubmit` to `submitForm` for clarity. - Updated the form submission method to use the new handler, improving code readability and maintainability. - Introduced a new export for `revalidateCategory` in the utils index, enhancing server-side functionality. --- app/admin/add-barometer/page.tsx | 1 + app/admin/add-document/page.tsx | 6 ++---- lib/barometers/actions.ts | 32 ++++++++++++++++++++++++++++++++ lib/barometers/queries.ts | 0 test-prisma-types.ts | 0 utils/index.ts | 2 ++ utils/revalidate.ts | 31 +++++++++++++++++++++++++++++++ 7 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 lib/barometers/actions.ts create mode 100644 lib/barometers/queries.ts create mode 100644 test-prisma-types.ts create mode 100644 utils/revalidate.ts diff --git a/app/admin/add-barometer/page.tsx b/app/admin/add-barometer/page.tsx index 527db515..2ab705da 100644 --- a/app/admin/add-barometer/page.tsx +++ b/app/admin/add-barometer/page.tsx @@ -37,6 +37,7 @@ import { FileUpload } from './file-upload' dayjs.extend(utc) +// ! выяснить как использовать Prisma.BarometerCreateInput тип для основы схемы // Yup validation schema const barometerSchema = yup.object().shape({ collectionId: yup diff --git a/app/admin/add-document/page.tsx b/app/admin/add-document/page.tsx index 6b3bdd03..b6ad5f4f 100644 --- a/app/admin/add-document/page.tsx +++ b/app/admin/add-document/page.tsx @@ -122,7 +122,7 @@ export default function AddDocument() { const [isPending, startTransition] = useTransition() - const handleFormSubmit = async (values: DocumentFormData) => { + const submitForm = async (values: DocumentFormData) => { startTransition(async () => { try { const documentWithImages = { @@ -166,15 +166,13 @@ export default function AddDocument() { } }, [condition.data, setValue]) - const onSubmit = (data: DocumentFormData) => handleFormSubmit(data) - return (

Add new document

- + { + const { materials, images, ...barometerData } = data + //const barometer = cleanObject(barometerData) + const { id, categoryId } = await prisma.barometer.create({ + data: { + ...barometerData, + ...(images ? { images } : {}), + ...(materials && Array.isArray(materials) + ? { + materials: { + connect: materials.map((materialId: number) => ({ id: materialId })), + }, + } + : {}), + createdAt: new Date(), + updatedAt: new Date(), + }, + }) + await revalidateCategory(prisma, categoryId) + revalidatePath(trimTrailingSlash(FrontRoutes.NewArrivals)) // regenerate new arrivals page + return { id } +}) + +export { createBarometer } diff --git a/lib/barometers/queries.ts b/lib/barometers/queries.ts new file mode 100644 index 00000000..e69de29b diff --git a/test-prisma-types.ts b/test-prisma-types.ts new file mode 100644 index 00000000..e69de29b diff --git a/utils/index.ts b/utils/index.ts index 90eed355..0ba23a62 100644 --- a/utils/index.ts +++ b/utils/index.ts @@ -9,5 +9,7 @@ export { default as customImageLoader } from './image-loader' // Image utilities export { generateIcon, getThumbnailBase64 } from './images' export { cleanObject } from './misc' +// Server +export { revalidateCategory } from './revalidate' // Text utilities export { getBrandSlug, slug, trimTrailingSlash } from './text' diff --git a/utils/revalidate.ts b/utils/revalidate.ts new file mode 100644 index 00000000..2fa7e4ce --- /dev/null +++ b/utils/revalidate.ts @@ -0,0 +1,31 @@ +import type { PrismaClient } from '@prisma/client' +import { revalidatePath } from 'next/cache' +import { BAROMETERS_PER_CATEGORY_PAGE } from '@/constants/globals' +import { FrontRoutes } from '@/constants/routes-front' +import { SortOptions } from '@/types' + +/** + * Revalidates the cache for a specific category by recalculating the paths that need to be revalidated. + * Call this function after adding/updating a barometer to update the category pages that include the + * barometer. + * + * @param prisma - The PrismaClient instance used to interact with the database. + * @param categoryId - The ID of the category to revalidate. + */ +export async function revalidateCategory(prisma: PrismaClient, categoryId: string) { + const { name: categoryName } = await prisma.category.findUniqueOrThrow({ + where: { id: categoryId }, + select: { name: true }, + }) + const barometersInCategory = await prisma.barometer.count({ where: { categoryId } }) + const pagesPerCategory = Math.ceil(barometersInCategory / BAROMETERS_PER_CATEGORY_PAGE) + const pathsToRevalidate = SortOptions.flatMap(({ value: sort }) => + Array.from( + { length: pagesPerCategory }, + (_, i) => `${FrontRoutes.Categories}${[categoryName, sort, String(i + 1)].join('/')}`, + ), + ) + for (const path of pathsToRevalidate) { + revalidatePath(path) + } +} From d63d8c2a9b63b12c7444875740ecce226dab6fc0 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Sat, 30 Aug 2025 09:51:47 +0200 Subject: [PATCH 02/11] feat(barometer): integrate Zod for form validation + refactor barometer form with zod - Replaced Yup with Zod for form validation in the barometer component, enhancing type safety and validation capabilities. - Updated the form submission logic to utilize Zod's transformation and validation features. - Added Zod as a new dependency in package.json and bun.lock for consistent validation handling. - Cleaned up unused imports and improved code readability in the barometer form component. --- app/admin/add-barometer/page.tsx | 165 +++++++-------------------- bun.lock | 3 + lib/barometers/actions.ts | 21 +--- lib/schemas/barometer-form.schema.ts | 131 +++++++++++++++++++++ package.json | 3 +- 5 files changed, 179 insertions(+), 144 deletions(-) create mode 100644 lib/schemas/barometer-form.schema.ts diff --git a/app/admin/add-barometer/page.tsx b/app/admin/add-barometer/page.tsx index 2ab705da..a8219c97 100644 --- a/app/admin/add-barometer/page.tsx +++ b/app/admin/add-barometer/page.tsx @@ -1,13 +1,9 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import { useEffect } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useTransition } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' import { Button } from '@/components/ui/button' import { Form, @@ -26,83 +22,24 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { imageStorage } from '@/constants/globals' import { useBarometers } from '@/hooks/useBarometers' -import { createBarometer } from '@/services/fetch' -import { getThumbnailBase64, slug } from '@/utils' +import { createBarometer } from '@/lib/barometers/actions' +import { + type BarometerFormData, + BarometerFormTransformSchema, + BarometerFormValidationSchema, +} from '@/lib/schemas/barometer-form.schema' import { AddManufacturer } from './add-manufacturer' import { MaterialsMultiSelect } from './add-materials' import { Dimensions } from './dimensions' import { FileUpload } from './file-upload' -dayjs.extend(utc) - -// ! выяснить как использовать Prisma.BarometerCreateInput тип для основы схемы -// Yup validation schema -const barometerSchema = yup.object().shape({ - collectionId: yup - .string() - .required('Catalogue No. is required') - .max(100, 'Catalogue No. must be less than 100 characters'), - name: yup - .string() - .required('Title is required') - .max(200, 'Title must be less than 200 characters'), - categoryId: yup.string().required('Category is required'), - date: yup - .string() - .required('Year is required') - .matches(/^\d{4}$/, 'Year must be 4 digits'), - dateDescription: yup.string().required('Date description is required'), - manufacturerId: yup.string().required('Manufacturer is required'), - conditionId: yup.string().required('Condition is required'), - description: yup.string().default(''), - dimensions: yup - .array() - .of( - yup.object().shape({ - dim: yup.string().default(''), - value: yup.string().default(''), - }), - ) - .default([]), - images: yup - .array() - .of(yup.string().required()) - .min(1, 'At least one image is required') - .default([]), - purchasedAt: yup - .string() - .test('valid-date', 'Must be a valid date', value => { - if (!value) return true // Allow empty string - return dayjs(value).isValid() - }) - .test('not-future', 'Purchase date cannot be in the future', value => { - if (!value) return true - return dayjs(value).isBefore(dayjs(), 'day') || dayjs(value).isSame(dayjs(), 'day') - }) - .default(''), - serial: yup.string().max(100, 'Serial number must be less than 100 characters').default(''), - estimatedPrice: yup - .string() - .test('is-positive-number', 'Must be a positive number', value => { - if (!value) return true // Allow empty string - const num = parseFloat(value) - return !Number.isNaN(num) && num > 0 - }) - .default(''), - subCategoryId: yup.string().default(''), - materials: yup.array().of(yup.number().required()).default([]), -}) - -// Auto-generated TypeScript type from Yup schema -type BarometerFormData = yup.InferType - export default function AddCard() { const { condition, categories, subcategories, manufacturers, materials } = useBarometers() + const [isPending, startTransition] = useTransition() const methods = useForm({ - resolver: yupResolver(barometerSchema), + resolver: zodResolver(BarometerFormValidationSchema), defaultValues: { collectionId: '', name: '', @@ -122,41 +59,21 @@ export default function AddCard() { }, }) - const { handleSubmit, setValue, reset } = methods + const { handleSubmit, setValue, reset, control } = methods - const queryClient = useQueryClient() - const { mutate, isPending } = useMutation({ - mutationFn: async (values: BarometerFormData) => { - const barometerWithImages = { - ...values, - date: dayjs(`${values.date}-01-01`).toISOString(), - purchasedAt: values.purchasedAt ? dayjs.utc(values.purchasedAt).toISOString() : null, - estimatedPrice: values.estimatedPrice ? parseFloat(values.estimatedPrice) : null, - ...(values.subCategoryId && - values.subCategoryId !== 'none' && { subCategoryId: parseInt(values.subCategoryId, 10) }), - images: await Promise.all( - (values.images || []).map(async (url, i) => ({ - url, - order: i, - name: values.name, - blurData: await getThumbnailBase64(imageStorage + url), - })), - ), - slug: slug(values.name), + const submitForm = (values: BarometerFormData) => { + startTransition(async () => { + try { + // Transform schema does ALL the heavy lifting - validation AND transformation! + const transformedData = await BarometerFormTransformSchema.parseAsync(values) + const { id } = await createBarometer(transformedData) + reset() + toast.success(`Added ${id} to the database`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error adding barometer') } - return createBarometer(barometerWithImages) - }, - onSuccess: ({ id }) => { - queryClient.invalidateQueries({ - queryKey: ['barometers'], - }) - reset() - toast.success(`Added ${id} to the database`) - }, - onError: error => { - toast.error(error.message || 'Error adding barometer') - }, - }) + }) + } // Set default values when data loads useEffect(() => { @@ -181,19 +98,15 @@ export default function AddCard() { setValue('manufacturerId', id) } - const onSubmit = (data: BarometerFormData) => { - mutate(data) - } - return (

Add new barometer

- + ( @@ -207,7 +120,7 @@ export default function AddCard() { /> ( @@ -221,7 +134,7 @@ export default function AddCard() { /> ( @@ -235,7 +148,7 @@ export default function AddCard() { /> ( @@ -257,7 +170,7 @@ export default function AddCard() { /> ( @@ -271,7 +184,7 @@ export default function AddCard() { /> ( @@ -302,7 +215,7 @@ export default function AddCard() { /> ( @@ -322,14 +235,14 @@ export default function AddCard() { /> ( Materials @@ -340,7 +253,7 @@ export default function AddCard() { /> ( @@ -371,12 +284,12 @@ export default function AddCard() { /> ( Movement Type - @@ -397,7 +310,7 @@ export default function AddCard() { /> ( @@ -431,7 +344,7 @@ export default function AddCard() { /> ( @@ -466,7 +379,7 @@ export default function AddCard() { ( diff --git a/bun.lock b/bun.lock index 1be7e660..96a99296 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ "uuid": "^10.0.0", "vanilla-cookieconsent": "^3.1.0", "yup": "^1.7.0", + "zod": "^4.1.5", }, "devDependencies": { "@babel/core": "^7.24.7", @@ -1849,6 +1850,8 @@ "yup": ["yup@1.7.0", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q=="], + "zod": ["zod@4.1.5", "", {}, "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], diff --git a/lib/barometers/actions.ts b/lib/barometers/actions.ts index c927867e..3f361672 100644 --- a/lib/barometers/actions.ts +++ b/lib/barometers/actions.ts @@ -4,25 +4,12 @@ import type { Prisma } from '@prisma/client' import { revalidatePath } from 'next/cache' import { FrontRoutes } from '@/constants' import { withPrisma } from '@/prisma/prismaClient' -import { /* cleanObject, */ revalidateCategory, trimTrailingSlash } from '@/utils' +import { revalidateCategory, trimTrailingSlash } from '@/utils' -const createBarometer = withPrisma(async (prisma, data: Prisma.BarometerCreateInput) => { - const { materials, images, ...barometerData } = data - //const barometer = cleanObject(barometerData) +// Simple function - just creates the barometer with provided data +const createBarometer = withPrisma(async (prisma, data: Prisma.BarometerUncheckedCreateInput) => { const { id, categoryId } = await prisma.barometer.create({ - data: { - ...barometerData, - ...(images ? { images } : {}), - ...(materials && Array.isArray(materials) - ? { - materials: { - connect: materials.map((materialId: number) => ({ id: materialId })), - }, - } - : {}), - createdAt: new Date(), - updatedAt: new Date(), - }, + data, }) await revalidateCategory(prisma, categoryId) revalidatePath(trimTrailingSlash(FrontRoutes.NewArrivals)) // regenerate new arrivals page diff --git a/lib/schemas/barometer-form.schema.ts b/lib/schemas/barometer-form.schema.ts new file mode 100644 index 00000000..5ae01869 --- /dev/null +++ b/lib/schemas/barometer-form.schema.ts @@ -0,0 +1,131 @@ +import type { Prisma } from '@prisma/client' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { z } from 'zod' +import { imageStorage } from '@/constants/globals' +import { getThumbnailBase64, slug } from '@/utils' + +dayjs.extend(utc) + +/** + * Validation schema for react-hook-form - NO transformations! + * This keeps original form types (strings, arrays, etc.) + */ +export const BarometerFormValidationSchema = z.object({ + // Required fields - just validation + collectionId: z + .string() + .min(1, 'Catalogue No. is required') + .max(100, 'Catalogue No. must be less than 100 characters'), + + name: z.string().min(1, 'Title is required').max(200, 'Title must be less than 200 characters'), + + categoryId: z.string().min(1, 'Category is required'), + + date: z + .string() + .min(1, 'Year is required') + .regex(/^\d{4}$/, 'Year must be 4 digits'), + + dateDescription: z.string().min(1, 'Date description is required'), + manufacturerId: z.string().min(1, 'Manufacturer is required'), + conditionId: z.string().min(1, 'Condition is required'), + + // Optional fields + serial: z.string().max(100, 'Serial number must be less than 100 characters').optional(), + + description: z.string().optional(), + + estimatedPrice: z + .string() + .refine(val => { + if (!val || val === '') return true + const num = parseFloat(val) + return !Number.isNaN(num) && num > 0 + }, 'Must be a positive number') + .optional(), + + purchasedAt: z + .string() + .refine(val => { + if (!val || val === '') return true + return dayjs(val).isValid() + }, 'Must be a valid date') + .refine(val => { + if (!val || val === '') return true + return dayjs(val).isBefore(dayjs(), 'day') || dayjs(val).isSame(dayjs(), 'day') + }, 'Purchase date cannot be in the future') + .optional(), + + subCategoryId: z.string().optional(), + + // Array fields + dimensions: z + .array( + z.object({ + dim: z.string(), + value: z.string(), + }), + ) + .optional(), + + materials: z.array(z.number().int()).optional(), + + images: z.array(z.string()).nonempty('At least one image is required'), +}) + +/** + * Transformation schema - converts form data to Prisma format + * This does ALL the transformations and type conversions! + */ +export const BarometerFormTransformSchema = BarometerFormValidationSchema.transform( + async (formData): Promise => ({ + // Direct mappings + ...formData, + + // Generated fields + slug: slug(formData.name), + + // Transformed fields + description: formData.description || '', + serial: formData.serial || null, + date: dayjs(`${formData.date}-01-01`).toDate(), + estimatedPrice: formData.estimatedPrice ? parseFloat(formData.estimatedPrice) : null, + purchasedAt: formData.purchasedAt ? dayjs(formData.purchasedAt).toDate() : null, + dimensions: + formData.dimensions && formData.dimensions.length > 0 ? formData.dimensions : undefined, + + // Optional subcategory + subCategoryId: + formData.subCategoryId && formData.subCategoryId !== '' && formData.subCategoryId !== 'none' + ? parseInt(formData.subCategoryId, 10) + : null, + + // Materials relation + materials: + formData.materials && formData.materials.length > 0 + ? { + connect: formData.materials.map(id => ({ id })), + } + : undefined, + + // Images relation - with async processing + images: + formData.images.length > 0 + ? { + create: await Promise.all( + formData.images.map(async (url, i) => ({ + url, + order: i, + name: formData.name, + blurData: await getThumbnailBase64(imageStorage + url), + })), + ), + } + : undefined, + }), +) + +// Export types +export type BarometerFormData = z.infer +//export type BarometerPrismaData = z.infer diff --git a/package.json b/package.json index bc7a813a..37366672 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "traverse": "^0.6.10", "uuid": "^10.0.0", "vanilla-cookieconsent": "^3.1.0", - "yup": "^1.7.0" + "yup": "^1.7.0", + "zod": "^4.1.5" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ From 4b62d08872fb24e0dad10264b3c65ebc8ad24d83 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Sun, 7 Sep 2025 00:58:18 +0200 Subject: [PATCH 03/11] refactor: APIs -> server functions --- app/admin/add-barometer/add-manufacturer.tsx | 4 +- app/admin/add-barometer/page.tsx | 6 +- app/admin/add-document/page.tsx | 6 +- app/api/v2/barometers/[slug]/getters.ts | 4 +- app/brands/brand-edit.tsx | 193 ++++++++++++++ app/brands/page.tsx | 111 +++----- .../categories/[...category]/page.tsx | 15 +- .../delete-barometer/delete-barometer.tsx | 29 +-- .../components/edit-fields/brand-edit.tsx | 117 +++++++++ .../components/edit-fields/condition-edit.tsx | 88 ++++--- .../components/edit-fields/date-edit.tsx | 92 +++---- .../edit-fields/dimensions-edit.tsx | 144 +++++----- .../edit-fields/estimated-price-edit.tsx | 92 +++---- .../components/edit-fields/images-edit.tsx | 163 ++++++------ .../edit-fields/manufacturer-edit.tsx | 245 ++++++++++-------- .../components/edit-fields/materials-edit.tsx | 106 ++++---- ...ubcategory-edit.tsx => movements-edit.tsx} | 103 ++++---- .../edit-fields/purchased-at-edit.tsx | 103 ++++---- .../components/edit-fields/textarea-edit.tsx | 86 +++--- .../components/edit-fields/textfield-edit.tsx | 85 +++--- app/collection/items/[slug]/layout.tsx | 6 +- app/collection/items/[slug]/page.tsx | 33 ++- app/collection/new-arrivals/page.tsx | 16 +- app/register/page.tsx | 4 +- app/search/page.tsx | 14 +- app/signin/components/signin-form.tsx | 4 +- bun.lock | 13 + components/ui/alert-dialog.tsx | 116 +++++++++ components/ui/button.tsx | 2 +- components/ui/form.tsx | 4 +- components/ui/index.ts | 15 +- components/ui/pagination.tsx | 14 +- constants/globals.ts | 1 + lib/barometers/actions.ts | 72 ++++- lib/barometers/queries.ts | 162 ++++++++++++ lib/barometers/search.ts | 80 ++++++ lib/brands/actions.ts | 61 +++++ lib/brands/queries.ts | 150 +++++++++++ lib/conditions/queries.ts | 17 ++ lib/counties/queries.ts | 11 + lib/materials/queries.ts | 11 + lib/movements/queries.ts | 15 ++ package.json | 1 + prisma/prismaClient.ts | 40 +-- services/api.ts | 1 - services/fetch.ts | 53 ---- 46 files changed, 1855 insertions(+), 853 deletions(-) create mode 100644 app/brands/brand-edit.tsx create mode 100644 app/collection/items/[slug]/components/edit-fields/brand-edit.tsx rename app/collection/items/[slug]/components/edit-fields/{subcategory-edit.tsx => movements-edit.tsx} (59%) create mode 100644 components/ui/alert-dialog.tsx create mode 100644 lib/barometers/search.ts create mode 100644 lib/brands/actions.ts create mode 100644 lib/brands/queries.ts create mode 100644 lib/conditions/queries.ts create mode 100644 lib/counties/queries.ts create mode 100644 lib/materials/queries.ts create mode 100644 lib/movements/queries.ts diff --git a/app/admin/add-barometer/add-manufacturer.tsx b/app/admin/add-barometer/add-manufacturer.tsx index a367b080..6f5138ee 100644 --- a/app/admin/add-barometer/add-manufacturer.tsx +++ b/app/admin/add-barometer/add-manufacturer.tsx @@ -119,7 +119,7 @@ export function AddManufacturer({ onAddManufacturer }: AddManufacturerProps) { Add Manufacturer - +
- + ) diff --git a/app/admin/add-barometer/page.tsx b/app/admin/add-barometer/page.tsx index a8219c97..4f7f8fc5 100644 --- a/app/admin/add-barometer/page.tsx +++ b/app/admin/add-barometer/page.tsx @@ -6,12 +6,12 @@ import { FormProvider, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { - Form, FormControl, FormField, FormItem, FormLabel, FormMessage, + FormProvider, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { @@ -103,7 +103,7 @@ export default function AddCard() {

Add new barometer

-
+ - +
) diff --git a/app/admin/add-document/page.tsx b/app/admin/add-document/page.tsx index b6ad5f4f..a7873158 100644 --- a/app/admin/add-document/page.tsx +++ b/app/admin/add-document/page.tsx @@ -9,12 +9,12 @@ import { toast } from 'sonner' import * as yup from 'yup' import { Button } from '@/components/ui/button' import { - Form, FormControl, FormField, FormItem, FormLabel, FormMessage, + FormProvider, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { @@ -171,7 +171,7 @@ export default function AddDocument() {

Add new document

-
+ - +
) diff --git a/app/api/v2/barometers/[slug]/getters.ts b/app/api/v2/barometers/[slug]/getters.ts index 5a3bfc98..a19f1893 100644 --- a/app/api/v2/barometers/[slug]/getters.ts +++ b/app/api/v2/barometers/[slug]/getters.ts @@ -1,7 +1,7 @@ import { withPrisma } from '@/prisma/prismaClient' export const getBarometer = withPrisma(async (prisma, slug: string) => { - const barometer = await prisma.barometer.findFirst({ + return prisma.barometer.findFirstOrThrow({ where: { slug: { equals: slug, @@ -55,8 +55,6 @@ export const getBarometer = withPrisma(async (prisma, slug: string) => { }, }, }) - if (barometer === null) throw new Error('Barometer not found') - return barometer }) export type BarometerDTO = Awaited> diff --git a/app/brands/brand-edit.tsx b/app/brands/brand-edit.tsx new file mode 100644 index 00000000..46ea2d7d --- /dev/null +++ b/app/brands/brand-edit.tsx @@ -0,0 +1,193 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { Edit, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' +import * as UI from '@/components/ui' +import { deleteBrand, updateBrand } from '@/lib/brands/actions' +import type { BrandDTO } from '@/lib/brands/queries' +import type { CountryListDTO } from '@/lib/counties/queries' + +interface Props { + brand: BrandDTO + countries: CountryListDTO +} + +// Schema for form validation (input) +const brandFormSchema = z.object({ + id: z.string(), + name: z + .string() + .min(1, 'Name is required') + .min(2, 'Name should be longer than 2 symbols') + .max(100, 'Name should be shorter than 100 symbols'), + firstName: z.string(), + city: z.string().max(100, 'City should be shorter than 100 symbols'), + countries: z.array(z.number().int()), + url: z.string().url('URL should be valid internet domain').or(z.literal('')), + description: z.string(), + successors: z.array(z.string()), + images: z.array( + z.object({ + id: z.string(), + url: z.string(), + }), + ), +}) + +// Schema for API submission (output with transforms) +const brandApiSchema = brandFormSchema.extend({ + firstName: z.string().transform(val => (val === '' ? null : val)), + city: z.string().transform(val => (val === '' ? null : val)), + url: z.string().transform(val => (val === '' ? null : val)), + description: z.string().transform(val => (val === '' ? null : val)), +}) + +type BrandForm = z.infer + +export function BrandEdit({ brand, countries }: Props) { + const [openBrandDialog, setOpenBrandDialog] = useState(false) + const closeBrandDialog = () => setOpenBrandDialog(false) + const [openDeleteDialog, setOpenDeleteDialog] = useState(false) + const closeDeleteDialog = () => setOpenDeleteDialog(false) + const [isPending, startTransition] = useTransition() + + const form = useForm({ + resolver: zodResolver(brandFormSchema), + mode: 'onSubmit', + reValidateMode: 'onChange', + }) + + const cleanUpOnClose = useCallback(() => {}, []) + + // biome-ignore lint/correctness/useExhaustiveDependencies: exclude closeDialog + const onUpdate = useCallback( + (values: BrandForm) => { + startTransition(async () => { + try { + // Transform form data to API format (empty strings -> null) + const apiData = brandApiSchema.parse(values) + const { name } = await updateBrand({ + ...apiData, + countries: { + set: apiData.countries.map(id => ({ id })), + }, + successors: { + set: apiData.successors.map(id => ({ id })), + }, + images: { + set: apiData.images, + }, + }) + toast.success(`Brand ${name} was updated`) + closeBrandDialog() + } catch (error) { + toast.error( + error instanceof Error ? error.message : `Error updating brand ${values.name}.`, + ) + } + }) + }, + [brand.name], + ) + + // biome-ignore lint/correctness/useExhaustiveDependencies: exclude closeDialog + const onDelete = useCallback(() => { + startTransition(async () => { + try { + //await deleteBrand(brand.slug) + toast.success(`Brand ${brand.name} was deleted`) + closeDeleteDialog() + closeBrandDialog() + } catch (error) { + toast.error(error instanceof Error ? error.message : `Error deleting brand ${brand.name}.`) + } + }) + }, [brand]) + + // Update form values when selected manufacturer changes + useEffect(() => { + if (!openBrandDialog) return cleanUpOnClose() + form.reset({ + id: brand.id, + name: brand.name, + firstName: brand.firstName ?? '', + city: brand.city ?? '', + url: brand.url ?? '', + description: brand.description ?? '', + images: brand.images, + successors: brand.successors.map(({ id }) => id), + countries: brand.countries.map(({ id }) => id), + }) + }, [openBrandDialog, brand, form.reset, cleanUpOnClose]) + + return ( + + + + + + + + +
+ Edit {brand.name} + + + + + + + + + Delete Brand + + Are you sure you want to delete "{brand.name}"? This action cannot be undone. + + + + Cancel + + Delete + + + + +
+ Update manufacturer details. +
+ +
+ + Update + + ( + + First name + + + + + )} + /> + +
+
+
+ ) +} diff --git a/app/brands/page.tsx b/app/brands/page.tsx index 68608caf..5c01333a 100644 --- a/app/brands/page.tsx +++ b/app/brands/page.tsx @@ -7,9 +7,11 @@ import Link from 'next/link' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { FrontRoutes } from '@/constants/routes-front' -import { withPrisma } from '@/prisma/prismaClient' +import { type BrandsByCountryDTO, getBrandsByCountry } from '@/lib/brands/queries' +import { type CountryListDTO, getCountries } from '@/lib/counties/queries' import { title } from '../../constants/metadata' import type { DynamicOptions } from '../../types' +import { BrandEdit } from './brand-edit' export const dynamic: DynamicOptions = 'force-static' @@ -17,52 +19,12 @@ export const metadata: Metadata = { title: `${title} - Manufacturers`, } -const getBrandsByCountry = withPrisma(async prisma => { - return prisma.country.findMany({ - orderBy: { - name: 'asc', - }, - where: { - manufacturers: { - some: {}, - }, - }, - include: { - manufacturers: { - orderBy: { - name: 'asc', - }, - select: { - id: true, - firstName: true, - name: true, - slug: true, - barometers: { - select: { - images: { - where: { - order: 0, - }, - select: { - url: true, - blurData: true, - }, - take: 1, - }, - }, - take: 1, - }, - icon: true, - }, - }, - }, - }) -}) - const BrandsOfCountry = ({ country, + countries, }: { - country: Awaited>[number] + country: BrandsByCountryDTO[number] + countries: CountryListDTO }) => { const width = 32 return ( @@ -71,34 +33,34 @@ const BrandsOfCountry = ({
- {country.manufacturers.map(({ id, firstName, name, slug, icon }) => { + {country.manufacturers.map(brand => { + const { id, firstName, name, slug, icon } = brand const base64 = icon ? Buffer.from(icon).toString('base64') : null const image = base64 ? `data:image/png;base64,${base64}` : null return ( - -
- {image ? ( - {name} - ) : ( - - )} -

- {name + (firstName ? `, ${firstName}` : '')} -

-
- +
+ + +
+ {image ? ( + {name} + ) : ( + + )} +

+ {name + (firstName ? `, ${firstName}` : '')} +

+
+ +
) })}
@@ -107,10 +69,11 @@ const BrandsOfCountry = ({ } export default async function Manufacturers() { - const countries = await getBrandsByCountry() + const brandsByCountry = await getBrandsByCountry() + const countries = await getCountries() const firstColStates = ['France', 'Great Britain'] - const firstColumn = countries.filter(({ name }) => firstColStates.includes(name)) - const secondColumn = countries.filter(({ name }) => !firstColStates.includes(name)) + const firstColumn = brandsByCountry.filter(({ name }) => firstColStates.includes(name)) + const secondColumn = brandsByCountry.filter(({ name }) => !firstColStates.includes(name)) return (

Manufacturers

@@ -124,13 +87,13 @@ export default async function Manufacturers() {
{firstColumn.map(country => ( - + ))}
{secondColumn.map(country => ( - + ))}
diff --git a/app/collection/categories/[...category]/page.tsx b/app/collection/categories/[...category]/page.tsx index 4ebb7141..c4d454dc 100644 --- a/app/collection/categories/[...category]/page.tsx +++ b/app/collection/categories/[...category]/page.tsx @@ -5,11 +5,12 @@ import type { Metadata } from 'next' import { FooterVideo } from '@/components/containers' import { BarometerCard, ShowMore } from '@/components/elements' import { Card, Pagination } from '@/components/ui' -import { BAROMETERS_PER_CATEGORY_PAGE, imageStorage } from '@/constants' +import { DEFAULT_PAGE_SIZE, imageStorage } from '@/constants' import { openGraph, title, twitter } from '@/constants/metadata' import { FrontRoutes } from '@/constants/routes-front' +import { getBarometersByParams } from '@/lib/barometers/queries' import { withPrisma } from '@/prisma/prismaClient' -import { getBarometersByParams, getCategory } from '@/services' +import { getCategory } from '@/services' import { type DynamicOptions, SortOptions, type SortValue } from '@/types' import Sort from './sort' @@ -60,8 +61,8 @@ export default async function Collection({ params: { category } }: CollectionPro const [categoryName, sort, page] = category const { barometers, totalPages } = await getBarometersByParams( categoryName, - Number(page), - BAROMETERS_PER_CATEGORY_PAGE, + +page, + DEFAULT_PAGE_SIZE, sort as SortValue, ) const { description } = await getCategory(categoryName) @@ -88,7 +89,9 @@ export default async function Collection({ params: { category } }: CollectionPro /> ))}
- {totalPages > 1 && } + {totalPages > 1 && ( + + )} {categoryName === 'recorders' && } @@ -110,7 +113,7 @@ export const generateStaticParams = withPrisma(async prisma => { for (const { name, id } of categories) { const categoryData = categoriesWithCount.find(({ categoryId }) => categoryId === id) const barometersPerCategory = categoryData?._count._all ?? 0 - const pagesPerCategory = Math.ceil(barometersPerCategory / BAROMETERS_PER_CATEGORY_PAGE) + const pagesPerCategory = Math.ceil(barometersPerCategory / DEFAULT_PAGE_SIZE) // generate all category/sort/page combinations for static page generation for (const { value: sort } of SortOptions) { for (let page = 1; page <= pagesPerCategory; page += 1) { diff --git a/app/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx b/app/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx index 447638a1..ec4398c4 100644 --- a/app/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx +++ b/app/collection/items/[slug]/components/delete-barometer/delete-barometer.tsx @@ -2,7 +2,7 @@ import { Trash2 } from 'lucide-react' import { useRouter } from 'next/navigation' -import { useState } from 'react' +import { useState, useTransition } from 'react' import { toast } from 'sonner' import { IsAdmin } from '@/components/elements' import { @@ -15,7 +15,7 @@ import { DialogTrigger, } from '@/components/ui' import { FrontRoutes } from '@/constants' -import { deleteBarometer } from '@/services' +import { deleteBarometer } from '@/lib/barometers/actions' import type { BarometerDTO } from '@/types' import { cn } from '@/utils' @@ -28,21 +28,20 @@ const warnStyles = 'leading-tight tracking-tighter indent-4 font-medium text-des export function DeleteBarometer({ barometer, className }: Props) { const [open, setOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) + const [isDeleting, startTransition] = useTransition() const router = useRouter() - const handleDelete = async () => { - setIsDeleting(true) - try { - const { message } = await deleteBarometer(barometer.slug) - toast.success(message) - setOpen(false) - router.replace(FrontRoutes.Categories + barometer.category.name) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error deleting barometer') - } finally { - setIsDeleting(false) - } + const handleDelete = () => { + startTransition(async () => { + try { + await deleteBarometer(barometer.slug) + toast.success('Barometer deleted successfully') + setOpen(false) + router.replace(FrontRoutes.Categories + barometer.category.name) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error deleting barometer') + } + }) } return ( diff --git a/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx b/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx new file mode 100644 index 00000000..d935b4cf --- /dev/null +++ b/app/collection/items/[slug]/components/edit-fields/brand-edit.tsx @@ -0,0 +1,117 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { Edit } from 'lucide-react' +import { useCallback, useEffect, useState, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' +import * as UI from '@/components/ui' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import type { AllBrandsDTO } from '@/lib/brands/queries' + +interface Props { + barometer: NonNullable + brands: AllBrandsDTO +} + +const validationSchema = z.object({ + brandId: z.string().min(1, 'Brand is required'), +}) + +type BrandForm = z.output + +function BrandEdit({ brands, barometer }: Props) { + const [open, setOpen] = useState(false) + const closeDialog = () => setOpen(false) + const [isPending, startTransition] = useTransition() + + const form = useForm({ + resolver: zodResolver(validationSchema), + }) + + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ brandId: barometer.manufacturerId }) + }, [open, barometer.manufacturerId, form.reset]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: exclude closeDialog + const update = useCallback( + (values: BrandForm) => { + if (values.brandId === barometer.manufacturerId) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return closeDialog() + } + startTransition(async () => { + try { + const { name } = await updateBarometer({ + id: barometer.id, + manufacturerId: values.brandId, + }) + setOpen(false) + toast.success(`Updated brand in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer brand') + } + }) + }, + [barometer.manufacturerId, barometer.name], + ) + return ( + + + + + + + + +
+ + Change Brand + + Update the manufacturer for this barometer. + + +
+ ( + + Brand + + + + + + + {brands.map(({ name, id, firstName }) => ( + + {firstName ? `${firstName} ` : ''} + {name} + + ))} + + + + + + )} + /> +
+
+ + Save + +
+
+
+
+
+ ) +} + +export { BrandEdit } diff --git a/app/collection/items/[slug]/components/edit-fields/condition-edit.tsx b/app/collection/items/[slug]/components/edit-fields/condition-edit.tsx index 0697e930..f4f2f3f9 100644 --- a/app/collection/items/[slug]/components/edit-fields/condition-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/condition-edit.tsx @@ -1,62 +1,70 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import * as zod from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { useBarometers } from '@/hooks/useBarometers' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import type { ConditionsDTO } from '@/lib/conditions/queries' import { cn } from '@/utils' interface ConditionEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO + barometer: NonNullable + conditions: ConditionsDTO } -const validationSchema = yup.object({ - conditionId: yup.string().required('Condition is required'), +const validationSchema = zod.object({ + conditionId: zod.string().min(1, 'Condition is required'), }) -type ConditionForm = yup.InferType +type ConditionForm = zod.infer -export function ConditionEdit({ size = 18, barometer, className, ...props }: ConditionEditProps) { - const { condition } = useBarometers() +export function ConditionEdit({ + size = 18, + barometer, + conditions, + className, + ...props +}: ConditionEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - conditionId: barometer.condition?.id ?? '', - }, + resolver: zodResolver(validationSchema), }) - const update = async (values: ConditionForm) => { - try { - const { slug } = await updateBarometer({ - id: barometer.id, - conditionId: values.conditionId, - }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ conditionId: barometer.condition?.id ?? '' }) + }, [open, form.reset, barometer.condition?.id]) + + const update = (values: ConditionForm) => { + if (values.conditionId === barometer.conditionId) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) } + startTransition(async () => { + try { + const { name } = await updateBarometer({ + id: barometer.id, + conditionId: values.conditionId, + }) + setOpen(false) + toast.success(`Updated condition in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer condition') + } + }) } return ( - { - if (isOpen) { - form.reset({ conditionId: barometer.condition?.id ?? '' }) - } - }} - > + - +
Edit Condition @@ -87,7 +95,7 @@ export function ConditionEdit({ size = 18, barometer, className, ...props }: Con - {condition.data.map(({ name, id }) => ( + {conditions.map(({ name, id }) => ( {name} @@ -101,12 +109,12 @@ export function ConditionEdit({ size = 18, barometer, className, ...props }: Con />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/date-edit.tsx b/app/collection/items/[slug]/components/edit-fields/date-edit.tsx index c50fb609..2aefc5c3 100644 --- a/app/collection/items/[slug]/components/edit-fields/date-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/date-edit.tsx @@ -1,68 +1,72 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import dayjs from 'dayjs' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' import { cn } from '@/utils' interface DateEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO + barometer: NonNullable } -const validationSchema = yup.object({ - date: yup +const fromYear = 1600 // barometers barely existed before this year +const currentYear = dayjs().year() + +const validationSchema = z.object({ + date: z .string() - .required('Year is required') - .matches(/^\d{4}$/, 'Must be a 4-digit year') - .test('year-range', 'Year must be between 1000 and 2099', value => { - if (!value) return false - const year = parseInt(value, 10) - return year >= 1000 && year <= 2099 - }), + .min(1, 'Year is required') + .length(4, 'Must be exactly 4 digits') + .regex(/^\d{4}$/, 'Must be a 4-digit year') + .refine(year => { + return +year >= fromYear && +year <= currentYear + }, `Year must be between ${fromYear} and ${currentYear}`), }) -type DateForm = yup.InferType +type DateForm = z.output export function DateEdit({ size = 18, barometer, className, ...props }: DateEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - date: dayjs(barometer.date).format('YYYY'), - }, + resolver: zodResolver(validationSchema), }) - const update = async (values: DateForm) => { - try { - const { slug } = await updateBarometer({ - id: barometer.id, - date: dayjs(`${values.date}-01-01`).toISOString(), - }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + const update = (values: DateForm) => { + startTransition(async () => { + try { + if (+values.date === dayjs(barometer.date).year()) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } + const { name } = await updateBarometer({ + id: barometer.id, + date: dayjs(`${values.date}-01-01`).toISOString(), + }) + toast.success(`Updated year in ${name}.`) + setOpen(false) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ date: dayjs(barometer.date).format('YYYY') }) + }, [open, form.reset, barometer.date]) + return ( - { - if (isOpen) { - form.reset({ date: dayjs(barometer.date).format('YYYY') }) - } - }} - > + - +
Edit Year @@ -105,12 +109,12 @@ export function DateEdit({ size = 18, barometer, className, ...props }: DateEdit />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx b/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx index 89726b21..0837350d 100644 --- a/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/dimensions-edit.tsx @@ -1,88 +1,89 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { isEqual } from 'lodash' import { Edit, Plus, Trash2 } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useCallback, useEffect, useState, useTransition } from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO, Dimensions } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import type { Dimensions } from '@/types' import { cn } from '@/utils' interface DimensionEditProps extends ComponentProps<'button'> { - barometer: BarometerDTO + barometer: NonNullable } -const validationSchema = yup.object({ - dimensions: yup - .array() - .of( - yup.object({ - dim: yup.string().required('Unit is required'), - value: yup.string().required('Value is required'), +const maxDimensions = 7 + +const validationSchema = z.object({ + dimensions: z + .array( + z.object({ + dim: z.string().min(1, 'Unit is required').max(20, 'Dimension name is too long'), + value: z.string().min(1, 'Value is required').max(20, 'Dimension value is too long'), }), ) - .defined() - .default([]), + .max(maxDimensions, 'Too many dimensions'), }) -type DimensionsForm = yup.InferType +type DimensionsForm = z.output export function DimensionEdit({ barometer, className, ...props }: DimensionEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - dimensions: (barometer.dimensions as Dimensions) || [], - }, + resolver: zodResolver(validationSchema), }) + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ dimensions: (barometer.dimensions as Dimensions) || [] }) + }, [open, form.reset, barometer.dimensions]) + const { fields, append, remove } = useFieldArray({ control: form.control, name: 'dimensions', }) - const handleUpdateBarometer = async (values: DimensionsForm) => { - try { - // Filter out empty entries - const filteredDimensions = values.dimensions.filter(({ dim }) => dim.trim()) + const handleUpdateBarometer = useCallback( + (values: DimensionsForm) => { + startTransition(async () => { + try { + // Filter out empty entries + const filteredDimensions = values.dimensions.filter(({ dim }) => dim.trim()) - if (isEqual(filteredDimensions, barometer.dimensions)) { - return - } + if (isEqual(filteredDimensions, barometer.dimensions)) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } - const { slug } = await updateBarometer({ - id: barometer.id, - dimensions: filteredDimensions, - }) + const { name } = await updateBarometer({ + id: barometer.id, + dimensions: filteredDimensions, + }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } - } + setOpen(false) + toast.success(`Updated dimensions in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) + }, + [barometer.name, barometer.dimensions, barometer.id], + ) const addDimension = () => { - if (fields.length >= 7) return + if (fields.length >= maxDimensions) return append({ dim: '', value: '' }) } return ( - { - if (isOpen) { - form.reset({ - dimensions: (barometer.dimensions as Dimensions) || [], - }) - } - }} - > + - +
Edit Dimensions Update the dimensions for this barometer.
+ ( + + + + )} + />
{fields.map((field, index) => (
@@ -111,6 +121,7 @@ export function DimensionEdit({ barometer, className, ...props }: DimensionEditP aria-label="Delete parameter" onClick={() => remove(index)} className="shrink-0" + disabled={isPending} > @@ -140,26 +151,31 @@ export function DimensionEdit({ barometer, className, ...props }: DimensionEditP />
))} - = 7} - className="w-fit" - > - - Add Parameter - +
+ = 7 || isPending} + className="w-fit" + > + + Add Parameter + +
+ {fields.length === 0 &&

No dimensions

} +
+
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/estimated-price-edit.tsx b/app/collection/items/[slug]/components/edit-fields/estimated-price-edit.tsx index d5bb3420..62de4f99 100644 --- a/app/collection/items/[slug]/components/edit-fields/estimated-price-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/estimated-price-edit.tsx @@ -1,33 +1,29 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' import { cn } from '@/utils' interface EstimatedPriceEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO + barometer: NonNullable } -const validationSchema = yup.object({ - estimatedPrice: yup +const validationSchema = z.object({ + estimatedPrice: z .string() - .required('Price is required') - .test('is-decimal', 'Must be a valid decimal number', value => { - if (!value) return false - return /^\d+(\.\d{1,2})?$/.test(value) - }), + .min(1, 'Price is required') + .regex(/^\d+(\.\d{1,2})?$/, 'Must be a valid decimal number'), }) -type EstimatedPriceForm = yup.InferType +type EstimatedPriceForm = z.output export function EstimatedPriceEdit({ size = 18, @@ -35,46 +31,44 @@ export function EstimatedPriceEdit({ className, ...props }: EstimatedPriceEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - estimatedPrice: barometer.estimatedPrice ? String(barometer.estimatedPrice) : '', - }, + resolver: zodResolver(validationSchema), }) - const update = async (values: EstimatedPriceForm) => { - try { - const newEstimatedPrice = Number(values.estimatedPrice) + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ estimatedPrice: barometer.estimatedPrice ? String(barometer.estimatedPrice) : '' }) + }, [open, form.reset, barometer.estimatedPrice]) - // Don't update if value hasn't changed - if (newEstimatedPrice === barometer.estimatedPrice) { - return - } + const update = (values: EstimatedPriceForm) => { + startTransition(async () => { + try { + const newEstimatedPrice = Number(values.estimatedPrice) + + // Don't update if value hasn't changed + if (newEstimatedPrice === barometer.estimatedPrice) { + toast.info(`Nothing was updated in ${barometer.name}`) + return setOpen(false) + } - const { slug } = await updateBarometer({ - id: barometer.id, - estimatedPrice: newEstimatedPrice, - }) + const { name } = await updateBarometer({ + id: barometer.id, + estimatedPrice: newEstimatedPrice, + }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + setOpen(false) + toast.success(`Updated estimated price in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } return ( - { - if (isOpen) { - form.reset({ - estimatedPrice: barometer.estimatedPrice ? String(barometer.estimatedPrice) : '', - }) - } - }} - > + - +
Edit Estimated Price @@ -115,12 +109,12 @@ export function EstimatedPriceEdit({ />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/images-edit.tsx b/app/collection/items/[slug]/components/edit-fields/images-edit.tsx index acc42011..236c7981 100644 --- a/app/collection/items/[slug]/components/edit-fields/images-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/images-edit.tsx @@ -8,31 +8,30 @@ import { useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { isEqual } from 'lodash' import { Edit, ImagePlus, Loader2, X } from 'lucide-react' -import type { ComponentProps } from 'react' -import { useMemo, useRef, useState } from 'react' +import { type ComponentProps, useEffect, useMemo, useRef, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' import { imageStorage } from '@/constants/globals' -import { FrontRoutes } from '@/constants/routes-front' -import { createImageUrls, deleteImage, updateBarometer, uploadFileToCloud } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import { createImageUrls, deleteImage, uploadFileToCloud } from '@/services/fetch' import { cn, customImageLoader, getThumbnailBase64 } from '@/utils' interface ImagesEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO + barometer: NonNullable } -const validationSchema = yup.object({ - images: yup.array().of(yup.string().required()).defined().default([]), +const validationSchema = z.object({ + images: z.array(z.string()), }) -type ImagesForm = yup.InferType +type ImagesForm = z.output function SortableImage({ image, @@ -75,17 +74,22 @@ function SortableImage({ ) } export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditProps) { - const barometerImages = useMemo(() => barometer.images.map(img => img.url), [barometer]) + const barometerImages = useMemo(() => barometer.images.map(img => img.url), [barometer.images]) + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const [isUploading, setIsUploading] = useState(false) const fileInputRef = useRef(null) const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - images: barometerImages, - }, + resolver: zodResolver(validationSchema), }) + // reset form on open and when barometer images change + useEffect(() => { + if (!open) return + form.reset({ images: barometerImages }) + }, [open, barometerImages, form]) + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (!over) return @@ -98,29 +102,29 @@ export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditP } } - const update = async (values: ImagesForm) => { - // exit if no image was changed - if (isEqual(values.images, barometerImages)) { - return - } - setIsUploading(true) - try { - // erase deleted images - const extraFiles = barometerImages?.filter(img => !values.images.includes(img)) - if (extraFiles) - await Promise.all( - extraFiles?.map(async file => { - try { - await deleteImage(file) - } catch (_error) { - // don't mind if it was not possible to delete the file - } - }), - ) + const update = (values: ImagesForm) => { + startTransition(async () => { + // exit if no image was changed + if (isEqual(values.images, barometerImages)) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } + setIsUploading(true) + try { + // erase deleted images + const extraFiles = barometerImages?.filter(img => !values.images.includes(img)) + if (extraFiles) + await Promise.all( + extraFiles?.map(async file => { + try { + await deleteImage(file) + } catch (_error) { + // don't mind if it was not possible to delete the file + } + }), + ) - const updatedBarometer = { - id: barometer.id, - images: await Promise.all( + const imageData = await Promise.all( values.images.map(async (url, i) => { const blurData = await getThumbnailBase64(imageStorage + url) return { @@ -130,20 +134,26 @@ export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditP blurData, } }), - ), - } + ) - const { slug } = await updateBarometer(updatedBarometer) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - console.error(error) - toast.error(error instanceof Error ? error.message : 'editImages: Error updating barometer') - } finally { - setIsUploading(false) - } + const updatedBarometer = { + id: barometer.id, + images: { + deleteMany: {}, + create: imageData, + }, + } + + const { name } = await updateBarometer(updatedBarometer) + setOpen(false) + toast.success(`Updated images in ${name}.`) + } catch (error) { + console.error(error) + toast.error(error instanceof Error ? error.message : 'editImages: Error updating barometer') + } finally { + setIsUploading(false) + } + }) } /** @@ -198,30 +208,31 @@ export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditP } } - const onClose = async () => { - // delete unused files from storage - try { - setIsUploading(true) - const currentImages = form.getValues('images') - const extraImages = currentImages.filter(img => !barometerImages?.includes(img)) - await Promise.all(extraImages.map(deleteImage)) - } catch (_error) { - // do nothing - } finally { - setIsUploading(false) + // reset form on open and cleanup on close + // biome-ignore lint/correctness/useExhaustiveDependencies: form not gonna change + useEffect(() => { + if (open) { + form.reset() + } else { + // delete unused files from storage when closing + const cleanup = async () => { + try { + setIsUploading(true) + const currentImages = form.getValues('images') + const extraImages = currentImages.filter(img => !barometerImages?.includes(img)) + await Promise.all(extraImages.map(deleteImage)) + } catch (_error) { + // do nothing + } finally { + setIsUploading(false) + } + } + cleanup() } - } + }, [open, barometerImages]) return ( - { - if (isOpen) { - form.reset({ images: barometerImages }) - } else { - await onClose() - } - }} - > + - +
{isUploading && ( @@ -254,7 +265,7 @@ export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditP variant="outline" className="w-fit" onClick={() => fileInputRef.current?.click()} - disabled={isUploading} + disabled={isUploading || isPending} > Add Images @@ -303,14 +314,14 @@ export function ImagesEdit({ barometer, size, className, ...props }: ImagesEditP type="submit" variant="outline" className="w-full" - disabled={isUploading} + disabled={isUploading || isPending} > Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx b/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx index 9393ef44..9fafe824 100644 --- a/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx @@ -1,51 +1,69 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { Edit, Trash2 } from 'lucide-react' -import type { ComponentProps } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { + type ComponentProps, + useCallback, + useEffect, + useMemo, + useState, + useTransition, +} from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' import { imageStorage } from '@/constants/globals' -import { FrontRoutes } from '@/constants/routes-front' -import { useBarometers } from '@/hooks/useBarometers' -import { deleteImage, updateBarometer, updateManufacturer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import { deleteBrand, updateBrand } from '@/lib/brands/actions' +import type { AllBrandsDTO } from '@/lib/brands/queries' +import type { CountryListDTO } from '@/lib/counties/queries' +import { deleteImage } from '@/services/fetch' import { cn, getThumbnailBase64 } from '@/utils' import { ManufacturerImageEdit } from './manufacturer-image-edit' -import type { ManufacturerForm } from './types' interface ManufacturerEditProps extends ComponentProps<'button'> { size?: string | number - barometer: BarometerDTO + barometer: NonNullable + brands: AllBrandsDTO + countries: CountryListDTO } -const initialValues: ManufacturerForm = { - id: '', - name: '', - firstName: '', - city: '', - countries: [], - url: '', - description: '', - successors: [], - images: [], -} +const validationSchema = z.object({ + id: z.string(), + name: z + .string() + .min(1, 'Name is required') + .min(2, 'Name should be longer than 2 symbols') + .max(100, 'Name should be shorter than 100 symbols'), + firstName: z.string(), + city: z.string().max(100, 'City should be shorter than 100 symbols'), + countries: z.array(z.number().int()), + url: z.string().refine(value => !value || /^(https?:\/\/).+/i.test(value), 'Must be a valid URL'), + description: z.string(), + successors: z.array(z.string()), + images: z.array(z.string()), +}) + +type ManufacturerForm = z.output export function ManufacturerEdit({ size = 18, barometer, + brands, + countries, className, ...props }: ManufacturerEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const [isLoading, setIsLoading] = useState(false) - const { manufacturers, countries } = useBarometers() const [selectedManufacturerIndex, setSelectedManufacturerIndex] = useState(0) const currentBrand = useMemo( - () => manufacturers.data[selectedManufacturerIndex], - [manufacturers.data, selectedManufacturerIndex], + () => brands[selectedManufacturerIndex], + [brands, selectedManufacturerIndex], ) const brandImages = useMemo( () => currentBrand?.images?.map(({ url }) => url), @@ -67,35 +85,25 @@ export function ManufacturerEdit({ } }, [currentBrand]) - const validationSchema: yup.ObjectSchema = yup - .object({ - id: yup.string().default(''), - name: yup - .string() - .required('Name is required') - .min(2, 'Name should be longer than 2 symbols') - .max(100, 'Name should be shorter than 100 symbols'), - firstName: yup.string().default(''), - city: yup.string().max(100, 'City should be shorter than 100 symbols').default(''), - countries: yup.array().of(yup.number().integer().required()).defined().default([]), - url: yup - .string() - .test('is-url', 'Must be a valid URL', value => !value || /^(https?:\/\/).+/i.test(value)) - .default(''), - description: yup.string().default(''), - successors: yup.array().of(yup.string().required()).defined().default([]), - images: yup.array().of(yup.string().required()).defined().default([]), - }) - .required() - const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: initialValues, + resolver: zodResolver(validationSchema), + defaultValues: { + id: '', + name: '', + firstName: '', + city: '', + countries: [], + url: '', + description: '', + successors: [], + images: [], + }, mode: 'onSubmit', reValidateMode: 'onChange', }) - const cleanupOnClose = async () => { + // biome-ignore lint/correctness/useExhaustiveDependencies: form not gonna change + const cleanupOnClose = useCallback(async () => { // delete unused files from storage try { setIsLoading(true) @@ -107,15 +115,13 @@ export function ManufacturerEdit({ } finally { setIsLoading(false) } - } + }, [brandImages]) // Reset selected manufacturer index only const resetManufacturerIndex = useCallback(() => { - const manufacturerIndex = manufacturers.data.findIndex( - ({ id }) => id === barometer.manufacturer.id, - ) + const manufacturerIndex = brands.findIndex(({ id }) => id === barometer.manufacturer.id) setSelectedManufacturerIndex(manufacturerIndex) - }, [barometer.manufacturer.id, manufacturers.data]) + }, [barometer.manufacturer.id, brands]) // when dialog opens we'll reset index; form will be updated by effect below @@ -127,31 +133,34 @@ export function ManufacturerEdit({ }, [currentBrandFormData, currentBrand, form.reset]) const update = useCallback( - async (formValues: ManufacturerForm) => { - setIsLoading(true) - try { - // erase deleted images - const extraFiles = brandImages?.filter(url => !formValues.images.includes(url)) - if (extraFiles) - await Promise.all( - extraFiles?.map(async file => { - try { - await deleteImage(file) - } catch (_error) { - // don't mind if it was not possible to delete the file - } - }), - ) + (formValues: ManufacturerForm) => { + startTransition(async () => { + try { + // Check if manufacturer changed + if (currentBrand.id !== barometer.manufacturer.id) { + toast.info(`Brand was not updated`) + return setOpen(false) + } - const updatedBarometer = { - id: barometer.id, - manufacturerId: currentBrand.id, - } - const updatedManufacturer = { - ...formValues, - successors: formValues.successors.map(id => ({ id })), - countries: formValues.countries.map(id => ({ id })), - images: await Promise.all( + // erase deleted images + const extraFiles = brandImages?.filter(url => !formValues.images.includes(url)) + if (extraFiles) + await Promise.all( + extraFiles?.map(async file => { + try { + await deleteImage(file) + } catch (_error) { + // don't mind if it was not possible to delete the file + } + }), + ) + + const updatedBarometer = { + id: barometer.id, + manufacturerId: currentBrand.id, + } + + const imageData = await Promise.all( formValues.images.map(async (url, i) => { const blurData = await getThumbnailBase64(imageStorage + url) return { @@ -161,36 +170,47 @@ export function ManufacturerEdit({ blurData, } }), - ), + ) + + const updatedManufacturer = { + ...formValues, + successors: { + set: formValues.successors.map(id => ({ id })), + }, + countries: { + set: formValues.countries.map(id => ({ id })), + }, + images: { + deleteMany: {}, + create: imageData, + }, + } + + const [{ name: barometerName }, { name: manufacturerName }] = await Promise.all([ + updateBarometer(updatedBarometer), + updateBrand(updatedManufacturer), + ]) + + setOpen(false) + toast.success(`Updated manufacturer to ${manufacturerName} in ${barometerName}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating manufacturer') } - const [{ slug }, { name }] = await Promise.all([ - updateBarometer(updatedBarometer), - updateManufacturer(updatedManufacturer), - ]) - toast.success(`${name} updated`) - // Small delay to show toast before redirect - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating manufacturer') - } finally { - setIsLoading(false) - } + }) }, - [barometer.id, barometer.name, brandImages, currentBrand?.id], + [barometer.id, barometer.name, barometer.manufacturer.id, brandImages, currentBrand?.id], ) + // reset form on open and cleanup on close + useEffect(() => { + if (open) { + resetManufacturerIndex() + } else { + cleanupOnClose() + } + }, [open, resetManufacturerIndex, cleanupOnClose]) + return ( - { - if (isOpen) { - resetManufacturerIndex() - // Don't reset to initialValues - let useEffect handle the proper data - return - } - await cleanupOnClose() - }} - > + ) : null} - +
@@ -217,7 +237,7 @@ export function ManufacturerEdit({ variant="destructive" size="icon" aria-label="Delete manufacturer" - onClick={() => manufacturers.delete(currentBrand?.slug)} + onClick={() => deleteBrand(currentBrand?.slug)} > @@ -237,7 +257,7 @@ export function ManufacturerEdit({ - {manufacturers.data.map(({ name, id }, i) => ( + {brands.map(({ name, id }, i) => ( {name} @@ -284,7 +304,7 @@ export function ManufacturerEdit({ ({ id, name })) ?? []} + options={countries?.map(({ id, name }) => ({ id, name })) ?? []} onChange={vals => field.onChange(vals)} placeholder={ ((field.value as number[]) ?? []).length === 0 @@ -336,7 +356,7 @@ export function ManufacturerEdit({ ({ id, name }))} + options={brands.map(({ id, name }) => ({ id, name }))} onChange={vals => field.onChange(vals)} /> @@ -361,11 +381,16 @@ export function ManufacturerEdit({ )} /> - + Update - + ) diff --git a/app/collection/items/[slug]/components/edit-fields/materials-edit.tsx b/app/collection/items/[slug]/components/edit-fields/materials-edit.tsx index 14821233..66b48c1f 100644 --- a/app/collection/items/[slug]/components/edit-fields/materials-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/materials-edit.tsx @@ -1,77 +1,73 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { isEqual } from 'lodash' import { Check, Edit, X } from 'lucide-react' -import type { ComponentProps } from 'react' -import { useMemo } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { useBarometers } from '@/hooks/useBarometers' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import type { MaterialList } from '@/lib/materials/queries' import { cn } from '@/utils' interface MaterialsEditProps extends ComponentProps<'button'> { - barometer: BarometerDTO + barometer: NonNullable + materials: MaterialList } -const validationSchema = yup.object({ - materials: yup.array().of(yup.number().required()).defined().default([]), +const validationSchema = z.object({ + materials: z.array(z.number()), }) -type MaterialsForm = yup.InferType +type MaterialsForm = z.output -export function MaterialsEdit({ barometer, className, ...props }: MaterialsEditProps) { - const { materials: materialList } = useBarometers() +export function MaterialsEdit({ barometer, materials, className, ...props }: MaterialsEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - materials: barometer.materials.map(({ id }) => id), - }, + resolver: zodResolver(validationSchema), }) - const handleUpdateBarometer = async (values: MaterialsForm) => { - try { - if ( - isEqual( - values.materials, - barometer.materials.map(({ id }) => id), - ) - ) { - return - } + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ materials: barometer.materials.map(({ id }) => id) }) + }, [open, form.reset, barometer.materials.map]) + + const handleUpdateBarometer = (values: MaterialsForm) => { + startTransition(async () => { + try { + if ( + isEqual( + values.materials, + barometer.materials.map(({ id }) => id), + ) + ) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } - const { slug } = await updateBarometer({ - id: barometer.id, - materials: values.materials, - }) - - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + const { name } = await updateBarometer({ + id: barometer.id, + materials: { + set: values.materials.map(id => ({ id })), + }, + }) + + setOpen(false) + toast.success(`Updated materials in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } - const materialsData = useMemo(() => materialList.data ?? [], [materialList]) - return ( - { - if (isOpen) { - form.reset({ - materials: barometer.materials.map(({ id }) => id), - }) - } - }} - > + - +
Edit Materials @@ -100,7 +96,7 @@ export function MaterialsEdit({ barometer, className, ...props }: MaterialsEditP @@ -109,12 +105,12 @@ export function MaterialsEdit({ barometer, className, ...props }: MaterialsEditP />
- + Update
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/subcategory-edit.tsx b/app/collection/items/[slug]/components/edit-fields/movements-edit.tsx similarity index 59% rename from app/collection/items/[slug]/components/edit-fields/subcategory-edit.tsx rename to app/collection/items/[slug]/components/edit-fields/movements-edit.tsx index a1cfb3e2..557fed7c 100644 --- a/app/collection/items/[slug]/components/edit-fields/subcategory-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/movements-edit.tsx @@ -1,84 +1,79 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { useBarometers } from '@/hooks/useBarometers' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' +import type { MovementListDTO } from '@/lib/movements/queries' import { cn } from '@/utils' interface SubcategoryEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO -} - -interface SubcategoryForm { - subCategoryId?: string | null + barometer: NonNullable + movements: MovementListDTO } const NONE_VALUE = '__none__' -const validationSchema: yup.ObjectSchema = yup.object({ - subCategoryId: yup.string().nullable().optional(), +const validationSchema = z.object({ + subCategoryId: z.string().nullable().optional(), }) -export function SubcategoryEdit({ +type SubcategoryForm = z.output + +export function MovementsEdit({ size = 18, barometer, + movements, className, ...props }: SubcategoryEditProps) { - const { subcategories } = useBarometers() + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - subCategoryId: barometer.subCategoryId ? String(barometer.subCategoryId) : NONE_VALUE, - }, + resolver: zodResolver(validationSchema), }) - const update = async (values: SubcategoryForm) => { - try { - const subCategoryId = - values.subCategoryId && values.subCategoryId !== NONE_VALUE - ? Number(values.subCategoryId) - : null + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ + subCategoryId: barometer.subCategoryId ? String(barometer.subCategoryId) : NONE_VALUE, + }) + }, [open, form.reset, barometer.subCategoryId]) - // Don't update DB if selected value doesn't differ from the recorded - if (subCategoryId === barometer.subCategoryId) { - return - } + const update = (values: SubcategoryForm) => { + startTransition(async () => { + try { + const subCategoryId = + values.subCategoryId && values.subCategoryId !== NONE_VALUE + ? Number(values.subCategoryId) + : null - const { slug } = await updateBarometer({ - id: barometer.id, - subCategoryId, - }) + // Don't update DB if selected value doesn't differ from the recorded + if (subCategoryId === barometer.subCategoryId) return setOpen(false) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + const { name } = await updateBarometer({ + id: barometer.id, + subCategoryId, + }) + + setOpen(false) + toast.success(`Updated movement type in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } return ( - { - if (isOpen) { - form.reset({ - subCategoryId: barometer.subCategoryId ? String(barometer.subCategoryId) : NONE_VALUE, - }) - } - }} - > + - +
Edit Movement Type @@ -115,7 +110,7 @@ export function SubcategoryEdit({ None - {subcategories.data.map(({ name, id }) => ( + {movements.map(({ name, id }) => ( {name} @@ -129,12 +124,12 @@ export function SubcategoryEdit({ />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/purchased-at-edit.tsx b/app/collection/items/[slug]/components/edit-fields/purchased-at-edit.tsx index ddedac63..7dc104ff 100644 --- a/app/collection/items/[slug]/components/edit-fields/purchased-at-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/purchased-at-edit.tsx @@ -1,40 +1,37 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' import { cn } from '@/utils' dayjs.extend(utc) interface PurchasedAtEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO + barometer: NonNullable } -const validationSchema = yup.object({ - purchasedAt: yup +const validationSchema = z.object({ + purchasedAt: z .string() - .test('valid-date', 'Must be a valid date', value => { - if (!value) return true // Allow empty string + .min(1, 'Purchase date is required') + .refine(value => { return dayjs(value).isValid() - }) - .test('not-future', 'Purchase date cannot be in the future', value => { - if (!value) return true + }, 'Must be a valid date') + .refine(value => { return dayjs(value).isBefore(dayjs(), 'day') || dayjs(value).isSame(dayjs(), 'day') - }) - .defined(), + }, 'Purchase date cannot be in the future'), }) -type PurchasedAtForm = yup.InferType +type PurchasedAtForm = z.output export function PurchasedAtEdit({ size = 18, @@ -42,28 +39,45 @@ export function PurchasedAtEdit({ className, ...props }: PurchasedAtEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { + resolver: zodResolver(validationSchema), + }) + + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ purchasedAt: barometer.purchasedAt ? dayjs.utc(barometer.purchasedAt).format('YYYY-MM-DD') : '', - }, - }) + }) + }, [open, barometer.purchasedAt, form.reset]) - const update = async (values: PurchasedAtForm) => { - try { - const { slug } = await updateBarometer({ - id: barometer.id, - purchasedAt: values.purchasedAt ? dayjs.utc(values.purchasedAt).toISOString() : null, - }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + const update = (values: PurchasedAtForm) => { + startTransition(async () => { + try { + const currentValue = barometer.purchasedAt + ? dayjs.utc(barometer.purchasedAt).format('YYYY-MM-DD') + : '' + + if (values.purchasedAt === currentValue) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } + + const { name } = await updateBarometer({ + id: barometer.id, + purchasedAt: values.purchasedAt ? dayjs.utc(values.purchasedAt).toISOString() : null, + }) + + setOpen(false) + toast.success(`Updated purchase date in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } const clearDate = () => { @@ -71,17 +85,7 @@ export function PurchasedAtEdit({ } return ( - { - if (isOpen) { - form.reset({ - purchasedAt: barometer.purchasedAt - ? dayjs.utc(barometer.purchasedAt).format('YYYY-MM-DD') - : '', - }) - } - }} - > + - +
Edit Purchase Date - Update the purchase date for this barometer. Leave empty if unknown. + Update the purchase date for this barometer.
@@ -123,6 +127,7 @@ export function PurchasedAtEdit({ variant="outline" size="sm" onClick={clearDate} + disabled={isPending} className="shrink-0" > Clear @@ -135,12 +140,12 @@ export function PurchasedAtEdit({ />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/textarea-edit.tsx b/app/collection/items/[slug]/components/edit-fields/textarea-edit.tsx index 280506aa..916b65b1 100644 --- a/app/collection/items/[slug]/components/edit-fields/textarea-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/textarea-edit.tsx @@ -1,30 +1,29 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { isEqual } from 'lodash' import { Edit } from 'lucide-react' -import type { ComponentProps } from 'react' +import { type ComponentProps, useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' -import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' import { cn } from '@/utils' interface TextAreaEditProps extends ComponentProps<'button'> { size?: string | number | undefined - barometer: BarometerDTO - property: keyof BarometerDTO + barometer: NonNullable + property: keyof NonNullable label?: string } -const validationSchema = yup.object({ - value: yup.string().required('This field is required'), +const validationSchema = z.object({ + value: z.string().min(1, 'This field is required'), }) -type TextAreaForm = yup.InferType +type TextAreaForm = z.output export function TextAreaEdit({ size = 18, @@ -34,45 +33,46 @@ export function TextAreaEdit({ className, ...props }: TextAreaEditProps) { + const [open, setOpen] = useState(false) + const [isPending, startTransition] = useTransition() + const form = useForm({ - resolver: yupResolver(validationSchema), - defaultValues: { - value: String(barometer[property] || ''), - }, + resolver: zodResolver(validationSchema), }) - const handleUpdate = async (values: TextAreaForm) => { - try { - if (isEqual(values.value, barometer[property])) { - return - } + // reset form on open with current data + useEffect(() => { + if (!open) return + form.reset({ + value: String(barometer[property] || ''), + }) + }, [open, barometer, property, form]) - const { slug } = await updateBarometer({ - id: barometer.id, - [property]: values.value, - }) + const handleUpdate = (values: TextAreaForm) => { + startTransition(async () => { + try { + if (isEqual(values.value, barometer[property])) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) + } + + const { name } = await updateBarometer({ + id: barometer.id, + [property]: values.value, + }) - toast.success(`${barometer.name} updated`) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } + setOpen(false) + toast.success(`Updated ${String(property).toLowerCase()} in ${name}.`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } const displayLabel = label || String(property).charAt(0).toUpperCase() + String(property).slice(1) return ( - { - if (isOpen) { - form.reset({ - value: String(barometer[property] || ''), - }) - } - }} - > + - +
Edit {displayLabel} @@ -108,12 +108,12 @@ export function TextAreaEdit({ />
- + Save
-
+
) diff --git a/app/collection/items/[slug]/components/edit-fields/textfield-edit.tsx b/app/collection/items/[slug]/components/edit-fields/textfield-edit.tsx index 99a54da2..01984e5e 100644 --- a/app/collection/items/[slug]/components/edit-fields/textfield-edit.tsx +++ b/app/collection/items/[slug]/components/edit-fields/textfield-edit.tsx @@ -1,65 +1,68 @@ 'use client' -import { yupResolver } from '@hookform/resolvers/yup' +import { zodResolver } from '@hookform/resolvers/zod' import { Edit } from 'lucide-react' -import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { useEffect, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as yup from 'yup' +import { z } from 'zod' import * as UI from '@/components/ui' import { FrontRoutes } from '@/constants/routes-front' -import { updateBarometer } from '@/services/fetch' -import type { BarometerDTO } from '@/types' +import { updateBarometer } from '@/lib/barometers/actions' +import type { BarometerDTO } from '@/lib/barometers/queries' import { cn } from '@/utils' interface TextFieldEditProps { size?: number - barometer: BarometerDTO - property: keyof BarometerDTO + barometer: NonNullable + property: keyof NonNullable className?: string } -const textFieldSchema = yup.object().shape({ - value: yup.string().required('Field is required').max(200, 'Must be less than 200 characters'), +const textFieldSchema = z.object({ + value: z.string().min(1, 'Field is required').max(200, 'Must be less than 200 characters'), }) -type FormData = yup.InferType +type FormData = z.infer export function TextFieldEdit({ size = 18, barometer, property, className }: TextFieldEditProps) { + const router = useRouter() const [open, setOpen] = useState(false) - const [isUpdating, setIsUpdating] = useState(false) + const [isPending, startTransition] = useTransition() const form = useForm({ - resolver: yupResolver(textFieldSchema), - defaultValues: { - value: String(barometer[property] || ''), - }, + resolver: zodResolver(textFieldSchema), }) - const onSubmit = async (values: FormData) => { - setIsUpdating(true) - try { - // Check if value actually changed - if (values.value === String(barometer[property] || '')) { - setOpen(false) - return - } - - const { slug } = await updateBarometer({ - id: barometer.id, - [property]: values.value, - }) + // reset form on open + useEffect(() => { + if (!open) return + form.reset({ value: String(barometer[property] || '') }) + }, [open, form.reset, barometer[property], property]) - toast.success(`${barometer.name} updated`) - setOpen(false) - setTimeout(() => { - window.location.href = FrontRoutes.Barometer + (slug ?? '') - }, 1000) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating barometer') - } finally { - setIsUpdating(false) + const onSubmit = (values: FormData) => { + // Check if value actually changed + if (values.value === String(barometer[property] || '')) { + toast.info(`Nothing was updated in ${barometer.name}.`) + return setOpen(false) } + startTransition(async () => { + try { + const { slug, name } = await updateBarometer({ + id: barometer.id, + [property]: values.value, + }) + // reload the page if property was 'name' or 'slug' and the page URL has changed + if (property === 'name' || property === 'slug') { + router.replace(FrontRoutes.Barometer + slug) + } + toast.success(`${name} updated`) + setOpen(false) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error updating barometer') + } + }) } return ( @@ -80,7 +83,7 @@ export function TextFieldEdit({ size = 18, barometer, property, className }: Tex - +
)} /> - - {isUpdating ? 'Saving...' : 'Save'} + + {isPending ? 'Saving...' : 'Save'} -
+
) diff --git a/app/collection/items/[slug]/layout.tsx b/app/collection/items/[slug]/layout.tsx index f80240a4..233d1c76 100644 --- a/app/collection/items/[slug]/layout.tsx +++ b/app/collection/items/[slug]/layout.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from 'react' import { imageStorage } from '@/constants/globals' import { keywords, openGraph, title, twitter } from '@/constants/metadata' import { FrontRoutes } from '@/constants/routes-front' -import { getBarometer } from '@/services' +import { getBarometer } from '@/lib/barometers/queries' export async function generateMetadata({ params: { slug }, @@ -12,7 +12,9 @@ export async function generateMetadata({ params: { slug: string } }): Promise { try { - const { description, name, images } = await getBarometer(slug) + const barometer = await getBarometer(slug) + if (!barometer) throw new Error() + const { description, name, images } = barometer const barometerTitle = `${title}: ${capitalize(name)}` const [image] = images diff --git a/app/collection/items/[slug]/page.tsx b/app/collection/items/[slug]/page.tsx index 7078085f..5072a242 100644 --- a/app/collection/items/[slug]/page.tsx +++ b/app/collection/items/[slug]/page.tsx @@ -16,26 +16,31 @@ import { Wrench, } from 'lucide-react' import Link from 'next/link' +import { notFound } from 'next/navigation' import { IsAdmin, MD, ShowMore } from '@/components/elements' import { Card, SeparatorWithText } from '@/components/ui' import { FrontRoutes } from '@/constants' +import { getBarometer } from '@/lib/barometers/queries' +import { getAllBrands } from '@/lib/brands/queries' +import { getConditions } from '@/lib/conditions/queries' +import { getMaterials } from '@/lib/materials/queries' +import { getMovements } from '@/lib/movements/queries' import { withPrisma } from '@/prisma/prismaClient' -import { getBarometer } from '@/services' import type { Dimensions } from '@/types' import { BreadcrumbsComponent } from './components/breadcrumbs' // local components import { ImageCarousel } from './components/carousel' import { Condition } from './components/condition' import { DeleteBarometer } from './components/delete-barometer' +import { BrandEdit } from './components/edit-fields/brand-edit' import { ConditionEdit } from './components/edit-fields/condition-edit' import { DateEdit } from './components/edit-fields/date-edit' // edit components import { DimensionEdit } from './components/edit-fields/dimensions-edit' import { EstimatedPriceEdit } from './components/edit-fields/estimated-price-edit' -import { ManufacturerEdit } from './components/edit-fields/manufacturer-edit' import { MaterialsEdit } from './components/edit-fields/materials-edit' +import { MovementsEdit } from './components/edit-fields/movements-edit' import { PurchasedAtEdit } from './components/edit-fields/purchased-at-edit' -import { SubcategoryEdit } from './components/edit-fields/subcategory-edit' import { TextAreaEdit } from './components/edit-fields/textarea-edit' import { TextFieldEdit } from './components/edit-fields/textfield-edit' import { InaccuracyReport } from './components/inaccuracy-report' @@ -60,9 +65,17 @@ export const generateStaticParams = withPrisma(prisma => ) export default async function Page({ params: { slug } }: Props) { - const barometer = await getBarometer(slug) + const [barometer, materials, movements, brands, conditions] = await Promise.all([ + getBarometer(slug).catch(() => null), + getMaterials().catch(() => []), + getMovements().catch(() => []), + getAllBrands().catch(() => []), + getConditions().catch(() => []), + ]) + + if (!barometer) notFound() const { firstName, name, city } = barometer.manufacturer - const dimensions = (barometer.dimensions ?? []) as Dimensions + const dimensions = (barometer?.dimensions ?? []) as Dimensions return ( <> @@ -81,7 +94,7 @@ export default async function Page({ params: { slug } }: Props) { } + edit={} > } > - {barometer.dateDescription} + {barometer?.dateDescription} } + edit={} > @@ -140,7 +153,7 @@ export default async function Page({ params: { slug } }: Props) { adminOnly={!barometer.subCategory?.name} icon={Wrench} title="Movement Type" - edit={} + edit={} >

{barometer.subCategory?.name}

@@ -176,7 +189,7 @@ export default async function Page({ params: { slug } }: Props) { adminOnly={!barometer.materials || barometer.materials.length === 0} icon={TreePine} title="Materials" - edit={} + edit={} >

{barometer.materials.map(item => item.name).join(', ')} diff --git a/app/collection/new-arrivals/page.tsx b/app/collection/new-arrivals/page.tsx index 42d33eaf..736e0b38 100644 --- a/app/collection/new-arrivals/page.tsx +++ b/app/collection/new-arrivals/page.tsx @@ -3,20 +3,20 @@ import 'server-only' import { BarometerCardWithIcon } from '@/components/elements' import { Card, Pagination } from '@/components/ui' import { FrontRoutes } from '@/constants' -import { fetchBarometerList } from '@/services' - -const itemsOnPage = 12 +import { getBarometersByParams } from '@/lib/barometers/queries' interface newArrivalsProps { searchParams: Record } export default async function NewArrivals({ searchParams }: newArrivalsProps) { - const { barometers, totalPages, page } = await fetchBarometerList({ - sort: 'last-added', - page: searchParams.page ?? 1, - size: searchParams.size ?? itemsOnPage, - }) + const { page: pageNo, size } = searchParams + const { barometers, totalPages, page } = await getBarometersByParams( + null, // all categories + +pageNo, + +size, + 'last-added', // sort by + ) return (

Last Added

diff --git a/app/register/page.tsx b/app/register/page.tsx index 13660b9d..00e21e52 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -60,7 +60,7 @@ export default function Register() { return (
- +

Registration

@@ -173,7 +173,7 @@ export default function Register() { {isLoading ? 'Signing up...' : 'Sign up'} - +
) } diff --git a/app/search/page.tsx b/app/search/page.tsx index fba06ba2..b26a0f15 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,6 +1,9 @@ +import 'server-only' + import { Pagination } from '@/components/ui/pagination' +import { DEFAULT_PAGE_SIZE } from '@/constants' import { FrontRoutes } from '@/constants/routes-front' -import { searchBarometers } from '@/services/fetch' +import { searchBarometers } from '@/lib/barometers/search' import { SearchInfo } from './search-info' import { SearchItem } from './search-item' @@ -9,7 +12,14 @@ interface SearchProps { } export default async function Search({ searchParams }: SearchProps) { - const { barometers = [], page = 1, totalPages = 0 } = await searchBarometers(searchParams) + const query = searchParams.q ?? '' + const pageSize = Math.max(Number(searchParams.size) ?? DEFAULT_PAGE_SIZE, 0) + const pageNo = Math.max(Number(searchParams.page) || 1, 1) + const { + barometers = [], + page = 1, + totalPages = 0, + } = await searchBarometers(query, pageSize, pageNo) return (
diff --git a/app/signin/components/signin-form.tsx b/app/signin/components/signin-form.tsx index 19666576..7372cc65 100644 --- a/app/signin/components/signin-form.tsx +++ b/app/signin/components/signin-form.tsx @@ -59,7 +59,7 @@ export function SignInForm() { } return ( - +
- + ) } diff --git a/bun.lock b/bun.lock index 96a99296..f3ececda 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@prisma/adapter-pg": "^6.6.0", "@prisma/client": "^6.6.0", "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", @@ -378,6 +379,8 @@ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="], @@ -1868,6 +1871,10 @@ "@parcel/watcher/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "@radix-ui/react-alert-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], @@ -2008,6 +2015,12 @@ "@mapbox/node-pre-gyp/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], "@types/jest/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..c70142a8 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,116 @@ +'use client' + +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' +import * as React from 'react' +import { buttonVariants } from '@/components/ui/button' +import { cn } from '@/utils/index' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 5fd16e04..811a90a7 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -2,7 +2,7 @@ import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' -import { cn } from '@/utils' +import { cn } from '@/utils/index' const buttonVariants = cva( 'no-underline cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', diff --git a/components/ui/form.tsx b/components/ui/form.tsx index f0c0cbbc..f6cef114 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -14,8 +14,6 @@ import { import { Label } from '@/components/ui/label' import { cn } from '@/utils' -const Form = FormProvider - type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath = FieldPath, @@ -158,7 +156,7 @@ FormMessage.displayName = 'FormMessage' export { useFormField, - Form, + FormProvider, FormItem, FormLabel, FormControl, diff --git a/components/ui/index.ts b/components/ui/index.ts index 6d4c43f5..d7e8db82 100644 --- a/components/ui/index.ts +++ b/components/ui/index.ts @@ -1,4 +1,17 @@ export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './accordion' +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} from './alert-dialog' export { Badge, type BadgeProps, badgeVariants } from './badge' export { Breadcrumb, @@ -69,13 +82,13 @@ export { DropdownMenuTrigger, } from './dropdown-menu' export { - Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, + FormProvider, useFormField, } from './form' export { Input } from './input' diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx index c4cedf87..9a28d57c 100644 --- a/components/ui/pagination.tsx +++ b/components/ui/pagination.tsx @@ -93,9 +93,16 @@ interface PaginationProps { value?: number onChange?: (page: number) => void className?: string + pageAsRoute?: boolean } -export function Pagination({ total, value = 1, onChange, className }: PaginationProps) { +export function Pagination({ + total, + value = 1, + onChange, + className, + pageAsRoute = false, +}: PaginationProps) { const router = useRouter() const pathname = usePathname() @@ -103,7 +110,10 @@ export function Pagination({ total, value = 1, onChange, className }: Pagination if (onChange) { onChange(newPage) } else { - router.push(pathname.split('/').slice(0, -1).concat(`${newPage}`).join('/')) + const pagePath = pageAsRoute + ? pathname.split('/').slice(0, -1).concat(`${newPage}`).join('/') + : `${pathname}?${new URLSearchParams({ page: String(newPage) })}` + router.push(pagePath) } } diff --git a/constants/globals.ts b/constants/globals.ts index 11542520..c34df76c 100644 --- a/constants/globals.ts +++ b/constants/globals.ts @@ -10,3 +10,4 @@ export const imageStorage = `${process.env.NEXT_PUBLIC_MINIO_URL}/${process.env. export const github = 'https://github.com/shenshin' export const BAROMETERS_PER_CATEGORY_PAGE = 12 +export const DEFAULT_PAGE_SIZE = 12 diff --git a/lib/barometers/actions.ts b/lib/barometers/actions.ts index 3f361672..908f387b 100644 --- a/lib/barometers/actions.ts +++ b/lib/barometers/actions.ts @@ -1,10 +1,11 @@ 'use server' -import type { Prisma } from '@prisma/client' +import type { Image, Prisma } from '@prisma/client' import { revalidatePath } from 'next/cache' import { FrontRoutes } from '@/constants' import { withPrisma } from '@/prisma/prismaClient' -import { revalidateCategory, trimTrailingSlash } from '@/utils' +import { minioBucket, minioClient } from '@/services/minio' +import { revalidateCategory, slug as slugify, trimTrailingSlash } from '@/utils' // Simple function - just creates the barometer with provided data const createBarometer = withPrisma(async (prisma, data: Prisma.BarometerUncheckedCreateInput) => { @@ -16,4 +17,69 @@ const createBarometer = withPrisma(async (prisma, data: Prisma.BarometerUnchecke return { id } }) -export { createBarometer } +const updateBarometer = withPrisma(async (prisma, data: Prisma.BarometerUncheckedUpdateInput) => { + const oldBarometer = await prisma.barometer.findUniqueOrThrow({ + where: { id: data.id as string }, + }) + // create new slug if name changed + const slug = data.name ? slugify(data.name as string) : oldBarometer.slug + await prisma.barometer.update({ + where: { id: data.id as string }, + data: { + ...data, + slug, + }, + }) + revalidatePath(FrontRoutes.Barometer + slug) + await revalidateCategory(prisma, (data.categoryId as string) ?? oldBarometer.categoryId) + const name = data.name ?? oldBarometer.name + return { slug, name } +}) + +/** + * Deletes selected images from storage + */ +async function deleteImagesFromStorage(images: Image[]) { + await Promise.all( + images.map(async image => { + try { + await minioClient.removeObject(minioBucket, image.url) + } catch (error) { + // don't throw error if image was not deleted + console.error(`Could not delete ${image.url} from storage`) + console.error(error) + } + }), + ) +} + +const deleteBarometer = withPrisma(async (prisma, slug: string) => { + const barometer = await prisma.barometer.findFirstOrThrow({ + where: { + slug: { + equals: slug, + mode: 'insensitive', + }, + }, + }) + + const args = { + where: { barometers: { some: { id: barometer.id } } }, + } + // save deleting images info + const imagesBeforeDbUpdate = await prisma.image.findMany(args) + await prisma.$transaction(async tx => { + await tx.image.deleteMany(args) + await tx.barometer.delete({ + where: { + id: barometer.id, + }, + }) + }) + await deleteImagesFromStorage(imagesBeforeDbUpdate) + revalidatePath(FrontRoutes.Barometer + barometer.slug) + revalidatePath(trimTrailingSlash(FrontRoutes.NewArrivals)) + await revalidateCategory(prisma, barometer.categoryId) +}) + +export { createBarometer, updateBarometer, deleteBarometer } diff --git a/lib/barometers/queries.ts b/lib/barometers/queries.ts index e69de29b..784547ce 100644 --- a/lib/barometers/queries.ts +++ b/lib/barometers/queries.ts @@ -0,0 +1,162 @@ +import type { Prisma } from '@prisma/client' +import { DEFAULT_PAGE_SIZE } from '@/constants' +import { withPrisma } from '@/prisma/prismaClient' +import type { SortValue } from '@/types' + +function getSortCriteria( + sortBy: SortValue | null, + direction: 'asc' | 'desc' = 'asc', +): Prisma.BarometerOrderByWithRelationInput { + switch (sortBy) { + case 'manufacturer': + return { manufacturer: { name: direction } } + case 'name': + return { name: direction } + case 'date': + return { date: direction } + case 'last-added': + return { createdAt: 'desc' } + default: + return { date: direction } + } +} + +/** + * Find a list of barometers of a certain category (and other params) + * Respond with pagination + */ +const getBarometersByParams = withPrisma( + async ( + prisma, + categoryName: string | null, + page: number, + size: number, + sortBy: SortValue | null, + ) => { + const pageNo = Math.max(page || 1, 1) + const pageSize = Math.max(size ?? DEFAULT_PAGE_SIZE, 0) + // perform case-insensitive compare with the stored categories + const category = categoryName + ? await prisma.category.findFirst({ + where: { name: { equals: categoryName, mode: 'insensitive' } }, + }) + : null + + const skip = pageSize ? (pageNo - 1) * pageSize : undefined + const where: Prisma.BarometerWhereInput | undefined = category + ? { categoryId: category.id } + : undefined + + const [barometers, totalItems] = await Promise.all([ + prisma.barometer.findMany({ + where, + select: { + id: true, + name: true, + date: true, + slug: true, + collectionId: true, + manufacturer: { + select: { + firstName: true, + name: true, + }, + }, + category: { + select: { + name: true, + }, + }, + images: { + select: { + url: true, + order: true, + blurData: true, + }, + orderBy: { + order: 'asc', + }, + }, + }, + skip, + take: pageSize || undefined, + orderBy: [getSortCriteria(sortBy), { name: 'asc' }], + }), + prisma.barometer.count({ where }), + ]) + + return { + barometers, + // if page size is 0 the DB returns all records in one page + page: pageSize ? pageNo : 1, + totalPages: pageSize ? Math.ceil(totalItems / pageSize) : 1, + totalItems, + pageSize, + } + }, +) + +/** + * Find barometer by slug + */ +const getBarometer = withPrisma(async (prisma, slug: string) => { + return prisma.barometer.findFirst({ + where: { + slug: { + equals: slug, + mode: 'insensitive', + }, + }, + include: { + category: true, + condition: { + select: { + id: true, + name: true, + description: true, + value: true, + }, + }, + manufacturer: { + include: { + countries: true, + successors: { + select: { + id: true, + name: true, + slug: true, + }, + }, + predecessors: { + select: { + id: true, + name: true, + slug: true, + }, + }, + images: true, + }, + }, + images: { + orderBy: { + order: 'asc', + }, + }, + subCategory: true, + materials: { + select: { + id: true, + name: true, + }, + orderBy: { + name: 'asc', + }, + }, + }, + }) +}) + +type BarometerDTO = Awaited> +type BarometerListDTO = Awaited> + +export { type BarometerDTO, type BarometerListDTO, getBarometer, getBarometersByParams } diff --git a/lib/barometers/search.ts b/lib/barometers/search.ts new file mode 100644 index 00000000..f977da89 --- /dev/null +++ b/lib/barometers/search.ts @@ -0,0 +1,80 @@ +import type { Prisma } from '@prisma/client' +import { withPrisma } from '@/prisma/prismaClient' + +/** + * Search barometers matching a query + */ +export const searchBarometers = withPrisma( + async (prisma, query: string, page: number, pageSize: number) => { + const skip = pageSize ? (page - 1) * pageSize : undefined + + const where: Prisma.BarometerWhereInput = { + OR: [ + { name: { contains: query, mode: 'insensitive' } }, + { description: { contains: query, mode: 'insensitive' } }, + { manufacturer: { name: { contains: query, mode: 'insensitive' } } }, + ], + } + + const [barometers, totalItems] = await Promise.all([ + prisma.barometer.findMany({ + where, + select: { + id: true, + name: true, + dateDescription: true, + slug: true, + manufacturer: { + select: { + firstName: true, + name: true, + }, + }, + category: { + select: { + name: true, + }, + }, + images: { + orderBy: { + order: 'asc', + }, + take: 1, + select: { + url: true, + blurData: true, + }, + }, + }, + skip, + take: pageSize || undefined, + orderBy: { + createdAt: 'desc', + }, + }), + prisma.barometer.count({ + where, + }), + ]) + + // replace array of images with the first image + const barometersWithFirstImage = barometers.map(barometer => { + const { images, ...restBarometer } = barometer + return { + ...restBarometer, + image: images.at(0), + } + }) + + return { + barometers: barometersWithFirstImage, + totalItems, + // if page size is 0 the DB returns all records in one page + page: pageSize ? page : 1, + totalPages: pageSize ? Math.ceil(totalItems / pageSize) : 1, + pageSize, + } + }, +) + +export type SearchResultsDTO = Awaited> diff --git a/lib/brands/actions.ts b/lib/brands/actions.ts new file mode 100644 index 00000000..2a0b2a12 --- /dev/null +++ b/lib/brands/actions.ts @@ -0,0 +1,61 @@ +import type { Prisma } from '@prisma/client' +import { revalidatePath } from 'next/cache' +import { FrontRoutes } from '@/constants' +import { withPrisma } from '@/prisma/prismaClient' +import { getBrandSlug, trimTrailingSlash } from '@/utils' + +const createBrand = withPrisma(async (prisma, data: Prisma.ManufacturerUncheckedCreateInput) => { + const { id, slug } = await prisma.manufacturer.create({ + data, + }) + const { successors } = await prisma.manufacturer.findUniqueOrThrow({ + where: { id }, + include: { successors: { select: { slug: true } } }, + }) + revalidatePath(trimTrailingSlash(FrontRoutes.Brands)) + revalidatePath(FrontRoutes.Brands + slug) + successors.forEach(({ slug }) => { + revalidatePath(FrontRoutes.Brands + slug) + }) + return { id } +}) + +const updateBrand = withPrisma(async (prisma, data: Prisma.ManufacturerUncheckedUpdateInput) => { + const oldBrand = await prisma.manufacturer.findUniqueOrThrow({ where: { id: data.id as string } }) + const slug = + data.name && data.firstName + ? getBrandSlug(data.name as string, data.firstName as string) + : oldBrand.slug + const { id, name } = await prisma.manufacturer.update({ + where: { + id: data.id as string, + }, + data: { + ...data, + slug, + }, + }) + const { successors } = await prisma.manufacturer.findUniqueOrThrow({ + where: { id }, + include: { successors: { select: { slug: true } } }, + }) + revalidatePath(trimTrailingSlash(FrontRoutes.Brands)) + revalidatePath(FrontRoutes.Brands + slug) + successors.forEach(({ slug }) => { + revalidatePath(FrontRoutes.Brands + slug) + }) + return { slug, name } +}) + +const deleteBrand = withPrisma(async (prisma, slug: string) => { + const manufacturer = await prisma.manufacturer.findUniqueOrThrow({ where: { slug } }) + await prisma.manufacturer.delete({ + where: { + id: manufacturer.id, + }, + }) + revalidatePath(trimTrailingSlash(FrontRoutes.Brands)) + revalidatePath(FrontRoutes.Brands + slug) +}) + +export { createBrand, updateBrand, deleteBrand } diff --git a/lib/brands/queries.ts b/lib/brands/queries.ts new file mode 100644 index 00000000..958315dc --- /dev/null +++ b/lib/brands/queries.ts @@ -0,0 +1,150 @@ +import { DEFAULT_PAGE_SIZE } from '@/constants' +import { withPrisma } from '@/prisma/prismaClient' + +export const getAllBrands = withPrisma(async prisma => { + return prisma.manufacturer.findMany({ + select: { + name: true, + firstName: true, + id: true, + }, + orderBy: [ + { + name: 'asc', + }, + { + createdAt: 'asc', + }, + ], + }) +}) + +export const getBrands = withPrisma(async (prisma, page?: number, size?: number) => { + const pageNo = Math.max(page || 1, 1) + const pageSize = Math.max(size ?? DEFAULT_PAGE_SIZE, 0) + const skip = pageSize ? (pageNo - 1) * pageSize : undefined + const [manufacturers, totalItems] = await Promise.all([ + prisma.manufacturer.findMany({ + orderBy: [ + { + name: 'asc', + }, + { + createdAt: 'asc', + }, + ], + skip, + take: pageSize || undefined, + include: { + countries: true, + images: { + select: { + url: true, + id: true, + blurData: true, + order: true, + name: true, + }, + }, + predecessors: { + select: { + id: true, + name: true, + slug: true, + }, + }, + successors: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + }), + prisma.manufacturer.count(), + ]) + return { + manufacturers, + page: pageSize ? page : 1, + totalPages: pageSize ? Math.ceil(totalItems / pageSize) : 1, + totalItems, + pageSize, + } +}) + +export const getBrand = withPrisma((prisma, slug: string) => + prisma.manufacturer.findUniqueOrThrow({ + where: { + slug, + }, + include: { + predecessors: { + select: { + id: true, + firstName: true, + name: true, + slug: true, + }, + }, + successors: { + select: { + id: true, + firstName: true, + name: true, + slug: true, + }, + }, + images: true, + countries: true, + }, + }), +) + +export const getBrandsByCountry = withPrisma(async prisma => { + return prisma.country.findMany({ + orderBy: { + name: 'asc', + }, + where: { + manufacturers: { + some: {}, + }, + }, + include: { + manufacturers: { + orderBy: { + name: 'asc', + }, + include: { + predecessors: { + select: { + id: true, + firstName: true, + name: true, + slug: true, + }, + }, + successors: { + select: { + id: true, + firstName: true, + name: true, + slug: true, + }, + }, + images: true, + countries: true, + }, + }, + }, + }) +}) + +export type BrandDTO = Awaited> + +export type BrandListDTO = Awaited> + +export type AllBrandsDTO = Awaited> + +export type BrandsByCountryDTO = Awaited> diff --git a/lib/conditions/queries.ts b/lib/conditions/queries.ts new file mode 100644 index 00000000..83bb31ac --- /dev/null +++ b/lib/conditions/queries.ts @@ -0,0 +1,17 @@ +import { withPrisma } from '@/prisma/prismaClient' + +export const getConditions = withPrisma(prisma => + prisma.condition.findMany({ + orderBy: { + value: 'asc', + }, + select: { + id: true, + name: true, + value: true, + description: true, + }, + }), +) + +export type ConditionsDTO = Awaited> diff --git a/lib/counties/queries.ts b/lib/counties/queries.ts new file mode 100644 index 00000000..2fb515e0 --- /dev/null +++ b/lib/counties/queries.ts @@ -0,0 +1,11 @@ +import { withPrisma } from '@/prisma/prismaClient' + +export const getCountries = withPrisma(prisma => + prisma.country.findMany({ + orderBy: { + name: 'asc', + }, + }), +) + +export type CountryListDTO = Awaited> diff --git a/lib/materials/queries.ts b/lib/materials/queries.ts new file mode 100644 index 00000000..c1141970 --- /dev/null +++ b/lib/materials/queries.ts @@ -0,0 +1,11 @@ +import { withPrisma } from '@/prisma/prismaClient' + +export const getMaterials = withPrisma(prisma => + prisma.material.findMany({ + orderBy: { + name: 'asc', + }, + }), +) + +export type MaterialList = Awaited> diff --git a/lib/movements/queries.ts b/lib/movements/queries.ts new file mode 100644 index 00000000..237654c0 --- /dev/null +++ b/lib/movements/queries.ts @@ -0,0 +1,15 @@ +import { withPrisma } from '@/prisma/prismaClient' + +export const getMovements = withPrisma(async prisma => { + const subCats = await prisma.subCategory.findMany({ + orderBy: [ + { + name: 'asc', + }, + ], + }) + // case insensitive sorting is not supported in Prisma on the DB level + return subCats.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) +}) + +export type MovementListDTO = Awaited> diff --git a/package.json b/package.json index 37366672..f9370516 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@prisma/adapter-pg": "^6.6.0", "@prisma/client": "^6.6.0", "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", diff --git a/prisma/prismaClient.ts b/prisma/prismaClient.ts index 9a20364c..ccdc2d6b 100644 --- a/prisma/prismaClient.ts +++ b/prisma/prismaClient.ts @@ -1,28 +1,10 @@ import { PrismaClient } from '@prisma/client' -const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined -} +const globalForPrisma = global as unknown as { prisma: PrismaClient } -function getPrismaClient(): PrismaClient { - if (globalForPrisma.prisma) return globalForPrisma.prisma - const prisma = new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : [], - }) - globalForPrisma.prisma = prisma - return prisma -} +const prisma = globalForPrisma.prisma || new PrismaClient() -/** - * Type definition for an asynchronous function that uses a PrismaClient instance. - * - * @template T - The return type of the asynchronous function. - * @template Args - A tuple representing the arguments passed to the function (excluding `prisma`). - * - * This type is used to define functions that need access to a PrismaClient instance and - * additional arguments. - */ -type AsyncFunction = (prisma: PrismaClient, ...args: Args) => Promise +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma /** * Higher-order function to automatically manage PrismaClient connections. @@ -41,16 +23,10 @@ type AsyncFunction = (prisma: PrismaClient, ...args: * }); * ``` */ -export function withPrisma(fn: AsyncFunction) { - return async function wrappedWithParams(...args: Args): Promise { - const prisma = getPrismaClient() - try { - return await fn(prisma, ...args) - } finally { - // Only disconnect in development to avoid connection issues during build - if (process.env.NODE_ENV === 'development') { - await prisma.$disconnect() - } - } +export function withPrisma( + fn: (prisma: PrismaClient, ...args: Args) => Promise, +) { + return async (...args: Args): Promise => { + return fn(prisma, ...args) } } diff --git a/services/api.ts b/services/api.ts index 426ce51b..d42aa568 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,4 +1,3 @@ -export { getBarometer } from '@/app/api/v2/barometers/[slug]/getters' export { getBarometersByParams } from '@/app/api/v2/barometers/getters' export { getCategory } from '@/app/api/v2/categories/[name]/getters' export { getCategories } from '@/app/api/v2/categories/getters' diff --git a/services/fetch.ts b/services/fetch.ts index c3669c27..18d32a42 100644 --- a/services/fetch.ts +++ b/services/fetch.ts @@ -1,8 +1,6 @@ import type { InaccuracyReport, Manufacturer } from '@prisma/client' import { ApiRoutes } from '@/constants/routes-back' import type { - BarometerDTO, - BarometerListDTO, CategoryDTO, CategoryListDTO, ConditionListDTO, @@ -12,7 +10,6 @@ import type { ManufacturerDTO, ManufacturerListDTO, MaterialListDTO, - SearchResultsDTO, SubcategoryListDTO, UrlDto, } from '@/types' @@ -33,56 +30,6 @@ export async function handleApiError(res: Response): Promise { } } -/******* Barometers ********/ -export async function fetchBarometer(slug: string): Promise { - const res = await fetch(ApiRoutes.Barometers + slug) - return res.json() -} -export async function fetchBarometerList( - searchParams: Record, -): Promise { - const res = await fetch(`${ApiRoutes.Barometers}?${new URLSearchParams(searchParams)}`, { - cache: 'no-cache', - }) - return res.json() -} -export async function searchBarometers( - searchParams: Record, -): Promise { - const pageSize = '10' - const url = `${ApiRoutes.BarometerSearch}?${new URLSearchParams({ ...searchParams, size: pageSize })}` - const res = await fetch(url, { cache: 'no-cache' }) - return res.json() -} -export async function createBarometer(barometer: T): Promise<{ id: string }> { - const res = await fetch(ApiRoutes.Barometers, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(barometer), - }) - if (!res.ok) await handleApiError(res) - return res.json() -} -export async function updateBarometer(barometer: T): Promise<{ slug: string }> { - const res = await fetch(ApiRoutes.Barometers, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(barometer), - }) - if (!res.ok) await handleApiError(res) - return res.json() -} -export async function deleteBarometer(slug: string): Promise<{ message: string }> { - const res = await fetch(`${ApiRoutes.Barometers}/${slug}`, { - method: 'DELETE', - }) - if (!res.ok) await handleApiError(res) - return res.json() -} /******* Categories ********/ export async function fetchCategoryList(): Promise { const res = await fetch(ApiRoutes.Categories) From 5ebf412d31aa1d34c26af5ce0c9e6920f79913c6 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 8 Sep 2025 02:13:28 +0200 Subject: [PATCH 04/11] feat: brand editing functionality - Updated the BrandEdit component to include a multi-select for countries and successors, improving user experience. - Integrated a new ManufacturerImageEdit component for better image management, allowing drag-and-drop functionality and image uploads. - Refactored form handling to utilize Zod for validation, ensuring type safety and cleaner code. - Added a RequiredFieldMark component for indicating mandatory fields in forms. - Implemented deleteImages function for handling image deletions from storage. - Cleaned up imports and improved overall code organization across several files. --- app/admin/add-barometer/page.tsx | 2 +- app/admin/add-document/page.tsx | 2 +- app/brands/brand-edit.tsx | 339 +++++++++-- .../manufacturer-image-edit.tsx | 82 ++- app/brands/page.tsx | 36 +- .../edit-fields/manufacturer-edit.tsx | 527 ------------------ app/privacy/page.tsx | 2 - components/elements/RequiredFieldMark.tsx | 5 + components/elements/index.ts | 1 + lib/images/actions.ts | 19 + 10 files changed, 394 insertions(+), 621 deletions(-) rename app/{collection/items/[slug]/components/edit-fields => brands}/manufacturer-image-edit.tsx (69%) delete mode 100644 app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx create mode 100644 components/elements/RequiredFieldMark.tsx create mode 100644 lib/images/actions.ts diff --git a/app/admin/add-barometer/page.tsx b/app/admin/add-barometer/page.tsx index 4f7f8fc5..adbecf12 100644 --- a/app/admin/add-barometer/page.tsx +++ b/app/admin/add-barometer/page.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useEffect, useTransition } from 'react' -import { FormProvider, useForm } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { diff --git a/app/admin/add-document/page.tsx b/app/admin/add-document/page.tsx index a7873158..e01fecb3 100644 --- a/app/admin/add-document/page.tsx +++ b/app/admin/add-document/page.tsx @@ -4,7 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' import { useEffect, useTransition } from 'react' -import { FormProvider, useForm } from 'react-hook-form' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as yup from 'yup' import { Button } from '@/components/ui/button' diff --git a/app/brands/brand-edit.tsx b/app/brands/brand-edit.tsx index 46ea2d7d..9b3cc949 100644 --- a/app/brands/brand-edit.tsx +++ b/app/brands/brand-edit.tsx @@ -6,14 +6,20 @@ import { useCallback, useEffect, useMemo, useState, useTransition } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' +import { RequiredFieldMark } from '@/components/elements' import * as UI from '@/components/ui' +import { imageStorage } from '@/constants' import { deleteBrand, updateBrand } from '@/lib/brands/actions' -import type { BrandDTO } from '@/lib/brands/queries' +import type { AllBrandsDTO, BrandDTO } from '@/lib/brands/queries' import type { CountryListDTO } from '@/lib/counties/queries' +import { deleteImages } from '@/lib/images/actions' +import { cn, getThumbnailBase64 } from '@/utils' +import { ManufacturerImageEdit } from './manufacturer-image-edit' interface Props { brand: BrandDTO countries: CountryListDTO + brands: AllBrandsDTO } // Schema for form validation (input) @@ -24,36 +30,24 @@ const brandFormSchema = z.object({ .min(1, 'Name is required') .min(2, 'Name should be longer than 2 symbols') .max(100, 'Name should be shorter than 100 symbols'), - firstName: z.string(), + firstName: z.string().max(100, 'Name should be shorter than 100 symbols'), city: z.string().max(100, 'City should be shorter than 100 symbols'), countries: z.array(z.number().int()), - url: z.string().url('URL should be valid internet domain').or(z.literal('')), + url: z.url('URL should be valid internet domain').or(z.literal('')), description: z.string(), successors: z.array(z.string()), - images: z.array( - z.object({ - id: z.string(), - url: z.string(), - }), - ), -}) - -// Schema for API submission (output with transforms) -const brandApiSchema = brandFormSchema.extend({ - firstName: z.string().transform(val => (val === '' ? null : val)), - city: z.string().transform(val => (val === '' ? null : val)), - url: z.string().transform(val => (val === '' ? null : val)), - description: z.string().transform(val => (val === '' ? null : val)), + images: z.array(z.string()), }) type BrandForm = z.infer -export function BrandEdit({ brand, countries }: Props) { +export function BrandEdit({ brand, countries, brands }: Props) { const [openBrandDialog, setOpenBrandDialog] = useState(false) - const closeBrandDialog = () => setOpenBrandDialog(false) + const closeBrandDialog = useCallback(() => setOpenBrandDialog(false), []) const [openDeleteDialog, setOpenDeleteDialog] = useState(false) - const closeDeleteDialog = () => setOpenDeleteDialog(false) + const closeDeleteDialog = useCallback(() => setOpenDeleteDialog(false), []) const [isPending, startTransition] = useTransition() + const brandImages = useMemo(() => brand.images.map(img => img.url), [brand.images]) const form = useForm({ resolver: zodResolver(brandFormSchema), @@ -61,27 +55,57 @@ export function BrandEdit({ brand, countries }: Props) { reValidateMode: 'onChange', }) - const cleanUpOnClose = useCallback(() => {}, []) + const cleanUpOnClose = useCallback(() => { + startTransition(async () => { + try { + // Clean up temporary uploaded images on dialog close + const uploadedImages = form.getValues('images') + const extraImages = uploadedImages.filter(img => !brandImages?.includes(img)) + await deleteImages(extraImages) + } catch (_error) {} + }) + }, [brandImages, form.getValues]) - // biome-ignore lint/correctness/useExhaustiveDependencies: exclude closeDialog const onUpdate = useCallback( - (values: BrandForm) => { + ({ images, countries, successors, ...values }: BrandForm) => { startTransition(async () => { try { - // Transform form data to API format (empty strings -> null) - const apiData = brandApiSchema.parse(values) + if (!form.formState.isDirty) { + toast.info(`${values.name} was not updated`) + closeBrandDialog() + return + } + + // Remove images that were deleted from form + const extraImages = brandImages.filter(brandImage => !images.includes(brandImage)) + if (extraImages) deleteImages(extraImages) + + const imageData = await Promise.all( + images.map(async (url, i) => { + const blurData = await getThumbnailBase64(imageStorage + url) + return { + url, + order: i, + name: values.name, + blurData, + } + }), + ) + const { name } = await updateBrand({ - ...apiData, - countries: { - set: apiData.countries.map(id => ({ id })), - }, + ...values, successors: { - set: apiData.successors.map(id => ({ id })), + set: successors.map(id => ({ id })), + }, + countries: { + set: countries.map(id => ({ id })), }, images: { - set: apiData.images, + deleteMany: {}, + create: imageData, }, }) + toast.success(`Brand ${name} was updated`) closeBrandDialog() } catch (error) { @@ -91,14 +115,13 @@ export function BrandEdit({ brand, countries }: Props) { } }) }, - [brand.name], + [brandImages, closeBrandDialog, form.formState.isDirty], ) - // biome-ignore lint/correctness/useExhaustiveDependencies: exclude closeDialog const onDelete = useCallback(() => { startTransition(async () => { try { - //await deleteBrand(brand.slug) + await deleteBrand(brand.slug) toast.success(`Brand ${brand.name} was deleted`) closeDeleteDialog() closeBrandDialog() @@ -106,7 +129,7 @@ export function BrandEdit({ brand, countries }: Props) { toast.error(error instanceof Error ? error.message : `Error deleting brand ${brand.name}.`) } }) - }, [brand]) + }, [brand, closeBrandDialog, closeDeleteDialog]) // Update form values when selected manufacturer changes useEffect(() => { @@ -118,11 +141,11 @@ export function BrandEdit({ brand, countries }: Props) { city: brand.city ?? '', url: brand.url ?? '', description: brand.description ?? '', - images: brand.images, + images: brandImages, successors: brand.successors.map(({ id }) => id), countries: brand.countries.map(({ id }) => id), }) - }, [openBrandDialog, brand, form.reset, cleanUpOnClose]) + }, [openBrandDialog, brand, brandImages, form.reset, cleanUpOnClose]) return ( @@ -170,9 +193,6 @@ export function BrandEdit({ brand, countries }: Props) {
- - Update - )} /> + + ( + + + Last name + + + + + + )} + /> + + ( + + City + + + + + )} + /> + + ( + + Countries + + ({ id, name })) ?? []} + onChange={vals => field.onChange(vals)} + placeholder={ + ((field.value as number[]) ?? []).length === 0 + ? 'Select countries' + : undefined + } + /> + + + + )} + /> + + ( + + External URL + + + + + + )} + /> + + ( + + Successors + + ({ id, name }))} + onChange={vals => field.onChange(vals)} + /> + + + + )} + /> + + + + ( + + Description + + + + + + )} + /> + + + + Update + +
) } + +// Countries multiselect (numbers) +function CountriesMultiSelect({ + selected, + options, + onChange, + placeholder, +}: { + selected: number[] + options: { id: number; name: string }[] + onChange: (values: number[]) => void + placeholder?: string +}) { + const [open, setOpen] = useState(false) + return ( + + + + {selected.length === 0 + ? placeholder || 'Select countries' + : `${selected.length} selected`} + + + + + + + No country found. + + {options.map(opt => { + const isActive = selected.includes(opt.id) + return ( + { + const next = isActive + ? selected.filter(v => v !== opt.id) + : [...selected, opt.id] + onChange(next) + }} + > +
+ {isActive ? '✓' : ''} +
+ {opt.name} +
+ ) + })} +
+
+
+
+
+ ) +} + +// Successors multiselect (strings) +function SuccessorsMultiSelect({ + selected, + options, + onChange, +}: { + selected: string[] + options: { id: string; name: string }[] + onChange: (values: string[]) => void +}) { + const [open, setOpen] = useState(false) + return ( + + + + {selected.length === 0 ? 'Select brands' : `${selected.length} selected`} + + + + + + + No brand found. + + {options.map(opt => { + const isActive = selected.includes(opt.id) + return ( + { + const next = isActive + ? selected.filter(v => v !== opt.id) + : [...selected, opt.id] + onChange(next) + }} + > +
+ {isActive ? '✓' : ''} +
+ {opt.name} +
+ ) + })} +
+
+
+
+
+ ) +} diff --git a/app/collection/items/[slug]/components/edit-fields/manufacturer-image-edit.tsx b/app/brands/manufacturer-image-edit.tsx similarity index 69% rename from app/collection/items/[slug]/components/edit-fields/manufacturer-image-edit.tsx rename to app/brands/manufacturer-image-edit.tsx index 10663818..34593f37 100644 --- a/app/collection/items/[slug]/components/edit-fields/manufacturer-image-edit.tsx +++ b/app/brands/manufacturer-image-edit.tsx @@ -10,16 +10,16 @@ import { import { CSS } from '@dnd-kit/utilities' import { ImagePlus, X } from 'lucide-react' import NextImage from 'next/image' -import { useCallback } from 'react' +import { type TransitionStartFunction, useCallback } from 'react' import type { UseFormReturn } from 'react-hook-form' import { Button } from '@/components/ui/button' import { createImageUrls, deleteImage, uploadFileToCloud } from '@/services/fetch' -import type { ManufacturerForm } from './types' +import type { ManufacturerForm } from '../collection/items/[slug]/components/edit-fields/types' interface Props { imageUrls: string[] form: UseFormReturn - setLoading: (loading: boolean) => void + startTransition: TransitionStartFunction } function SortableImage({ @@ -65,56 +65,54 @@ function SortableImage({ ) } -export function ManufacturerImageEdit({ imageUrls, form, setLoading }: Props) { +export function ManufacturerImageEdit({ imageUrls, form, startTransition }: Props) { /** * Upload images to storage */ const uploadImages = useCallback( - async (files: File[]) => { + (files: File[]) => { if (!files || !Array.isArray(files) || files.length === 0) return - setLoading(true) - try { - const urlsDto = await createImageUrls( - files.map(file => ({ - fileName: file.name, - contentType: file.type, - })), - ) - await Promise.all( - urlsDto.urls.map((urlObj, index) => uploadFileToCloud(urlObj.signed, files[index])), - ) + startTransition(async () => { + try { + const urlsDto = await createImageUrls( + files.map(file => ({ + fileName: file.name, + contentType: file.type, + })), + ) + await Promise.all( + urlsDto.urls.map((urlObj, index) => uploadFileToCloud(urlObj.signed, files[index])), + ) - const newImages = urlsDto.urls.map(url => url.public).filter(url => Boolean(url)) - const prev = form.getValues('images') || [] - form.setValue('images', [...prev, ...newImages], { shouldDirty: true }) - } catch (error) { - console.error(error instanceof Error ? error.message : 'Error uploading files') - } finally { - setLoading(false) - } + const newImages = urlsDto.urls.map(url => url.public).filter(url => Boolean(url)) + const prev = form.getValues('images') || [] + form.setValue('images', [...prev, ...newImages], { shouldDirty: true }) + } catch (error) { + console.error(error instanceof Error ? error.message : 'Error uploading files') + } + }) }, - [form.getValues, form.setValue, setLoading], + [form.getValues, form.setValue, startTransition], ) const handleDeleteFile = useCallback( - async (img: string) => { - setLoading(true) - try { - // if the image file was uploaded but not yet added to the entity - if (!imageUrls?.includes(img)) await deleteImage(img) - const old = form.getValues('images') || [] - form.setValue( - 'images', - old.filter(file => !file.includes(img)), - { shouldDirty: true }, - ) - } catch (error) { - console.error(error instanceof Error ? error.message : 'Error deleting file') - } finally { - setLoading(false) - } + (img: string) => { + startTransition(async () => { + try { + // if the image file was uploaded but not yet added to the entity + if (!imageUrls?.includes(img)) await deleteImage(img) + const old = form.getValues('images') || [] + form.setValue( + 'images', + old.filter(file => !file.includes(img)), + { shouldDirty: true }, + ) + } catch (error) { + console.error(error instanceof Error ? error.message : 'Error deleting file') + } + }) }, - [imageUrls, form.getValues, form.setValue, setLoading], + [imageUrls, form.getValues, form.setValue, startTransition], ) const handleDragEnd = useCallback( diff --git a/app/brands/page.tsx b/app/brands/page.tsx index 5c01333a..f35d3fa4 100644 --- a/app/brands/page.tsx +++ b/app/brands/page.tsx @@ -7,7 +7,12 @@ import Link from 'next/link' import { Card } from '@/components/ui/card' import { Separator } from '@/components/ui/separator' import { FrontRoutes } from '@/constants/routes-front' -import { type BrandsByCountryDTO, getBrandsByCountry } from '@/lib/brands/queries' +import { + type AllBrandsDTO, + type BrandsByCountryDTO, + getAllBrands, + getBrandsByCountry, +} from '@/lib/brands/queries' import { type CountryListDTO, getCountries } from '@/lib/counties/queries' import { title } from '../../constants/metadata' import type { DynamicOptions } from '../../types' @@ -19,14 +24,16 @@ export const metadata: Metadata = { title: `${title} - Manufacturers`, } +const width = 32 const BrandsOfCountry = ({ country, countries, + allBrands, }: { country: BrandsByCountryDTO[number] countries: CountryListDTO + allBrands: AllBrandsDTO }) => { - const width = 32 return (

{country.name}

@@ -39,7 +46,7 @@ const BrandsOfCountry = ({ const image = base64 ? `data:image/png;base64,${base64}` : null return (
- +
{image ? ( @@ -68,9 +75,12 @@ const BrandsOfCountry = ({ ) } -export default async function Manufacturers() { - const brandsByCountry = await getBrandsByCountry() - const countries = await getCountries() +export default async function Brands() { + const [brandsByCountry, countries, allBrands] = await Promise.all([ + getBrandsByCountry(), + getCountries(), + getAllBrands(), + ]) const firstColStates = ['France', 'Great Britain'] const firstColumn = brandsByCountry.filter(({ name }) => firstColStates.includes(name)) const secondColumn = brandsByCountry.filter(({ name }) => !firstColStates.includes(name)) @@ -87,13 +97,23 @@ export default async function Manufacturers() {
{firstColumn.map(country => ( - + ))}
{secondColumn.map(country => ( - + ))}
diff --git a/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx b/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx deleted file mode 100644 index 9fafe824..00000000 --- a/app/collection/items/[slug]/components/edit-fields/manufacturer-edit.tsx +++ /dev/null @@ -1,527 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import { Edit, Trash2 } from 'lucide-react' -import { - type ComponentProps, - useCallback, - useEffect, - useMemo, - useState, - useTransition, -} from 'react' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import { z } from 'zod' -import * as UI from '@/components/ui' -import { imageStorage } from '@/constants/globals' -import { updateBarometer } from '@/lib/barometers/actions' -import type { BarometerDTO } from '@/lib/barometers/queries' -import { deleteBrand, updateBrand } from '@/lib/brands/actions' -import type { AllBrandsDTO } from '@/lib/brands/queries' -import type { CountryListDTO } from '@/lib/counties/queries' -import { deleteImage } from '@/services/fetch' -import { cn, getThumbnailBase64 } from '@/utils' -import { ManufacturerImageEdit } from './manufacturer-image-edit' - -interface ManufacturerEditProps extends ComponentProps<'button'> { - size?: string | number - barometer: NonNullable - brands: AllBrandsDTO - countries: CountryListDTO -} - -const validationSchema = z.object({ - id: z.string(), - name: z - .string() - .min(1, 'Name is required') - .min(2, 'Name should be longer than 2 symbols') - .max(100, 'Name should be shorter than 100 symbols'), - firstName: z.string(), - city: z.string().max(100, 'City should be shorter than 100 symbols'), - countries: z.array(z.number().int()), - url: z.string().refine(value => !value || /^(https?:\/\/).+/i.test(value), 'Must be a valid URL'), - description: z.string(), - successors: z.array(z.string()), - images: z.array(z.string()), -}) - -type ManufacturerForm = z.output - -export function ManufacturerEdit({ - size = 18, - barometer, - brands, - countries, - className, - ...props -}: ManufacturerEditProps) { - const [open, setOpen] = useState(false) - const [isPending, startTransition] = useTransition() - const [isLoading, setIsLoading] = useState(false) - const [selectedManufacturerIndex, setSelectedManufacturerIndex] = useState(0) - const currentBrand = useMemo( - () => brands[selectedManufacturerIndex], - [brands, selectedManufacturerIndex], - ) - const brandImages = useMemo( - () => currentBrand?.images?.map(({ url }) => url), - [currentBrand?.images], - ) - - // Prepare form-friendly data derived from the current brand - const currentBrandFormData = useMemo(() => { - return { - id: currentBrand?.id ?? '', - name: currentBrand?.name ?? '', - firstName: currentBrand?.firstName ?? '', - city: currentBrand?.city ?? '', - countries: currentBrand?.countries?.map(({ id }) => id) ?? [], - description: currentBrand?.description ?? '', - url: currentBrand?.url ?? '', - successors: currentBrand?.successors?.map(({ id }) => id) ?? [], - images: currentBrand?.images?.map(({ url }) => url) ?? [], - } - }, [currentBrand]) - - const form = useForm({ - resolver: zodResolver(validationSchema), - defaultValues: { - id: '', - name: '', - firstName: '', - city: '', - countries: [], - url: '', - description: '', - successors: [], - images: [], - }, - mode: 'onSubmit', - reValidateMode: 'onChange', - }) - - // biome-ignore lint/correctness/useExhaustiveDependencies: form not gonna change - const cleanupOnClose = useCallback(async () => { - // delete unused files from storage - try { - setIsLoading(true) - const currentImages = form.getValues('images') - const extraImages = currentImages.filter(img => !brandImages?.includes(img)) - await Promise.all(extraImages.map(deleteImage)) - } catch (_error) { - // do nothing - } finally { - setIsLoading(false) - } - }, [brandImages]) - - // Reset selected manufacturer index only - const resetManufacturerIndex = useCallback(() => { - const manufacturerIndex = brands.findIndex(({ id }) => id === barometer.manufacturer.id) - setSelectedManufacturerIndex(manufacturerIndex) - }, [barometer.manufacturer.id, brands]) - - // when dialog opens we'll reset index; form will be updated by effect below - - // Update form values when selected manufacturer changes - useEffect(() => { - if (currentBrand) { - form.reset(currentBrandFormData) - } - }, [currentBrandFormData, currentBrand, form.reset]) - - const update = useCallback( - (formValues: ManufacturerForm) => { - startTransition(async () => { - try { - // Check if manufacturer changed - if (currentBrand.id !== barometer.manufacturer.id) { - toast.info(`Brand was not updated`) - return setOpen(false) - } - - // erase deleted images - const extraFiles = brandImages?.filter(url => !formValues.images.includes(url)) - if (extraFiles) - await Promise.all( - extraFiles?.map(async file => { - try { - await deleteImage(file) - } catch (_error) { - // don't mind if it was not possible to delete the file - } - }), - ) - - const updatedBarometer = { - id: barometer.id, - manufacturerId: currentBrand.id, - } - - const imageData = await Promise.all( - formValues.images.map(async (url, i) => { - const blurData = await getThumbnailBase64(imageStorage + url) - return { - url, - order: i, - name: barometer.name, - blurData, - } - }), - ) - - const updatedManufacturer = { - ...formValues, - successors: { - set: formValues.successors.map(id => ({ id })), - }, - countries: { - set: formValues.countries.map(id => ({ id })), - }, - images: { - deleteMany: {}, - create: imageData, - }, - } - - const [{ name: barometerName }, { name: manufacturerName }] = await Promise.all([ - updateBarometer(updatedBarometer), - updateBrand(updatedManufacturer), - ]) - - setOpen(false) - toast.success(`Updated manufacturer to ${manufacturerName} in ${barometerName}.`) - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Error updating manufacturer') - } - }) - }, - [barometer.id, barometer.name, barometer.manufacturer.id, brandImages, currentBrand?.id], - ) - // reset form on open and cleanup on close - useEffect(() => { - if (open) { - resetManufacturerIndex() - } else { - cleanupOnClose() - } - }, [open, resetManufacturerIndex, cleanupOnClose]) - - return ( - - - - - - - - {isLoading ? ( -
-
-
- ) : null} - -
- -
- Edit Manufacturer - deleteBrand(currentBrand?.slug)} - > - - -
- - Edit manufacturer information and update barometer details. - -
- -
- Manufacturer - setSelectedManufacturerIndex(Number(val))} - > - - - - - {brands.map(({ name, id }, i) => ( - - {name} - - ))} - - -
- -
- ( - - First name - - - - - - )} - /> - ( - - Name / Company name - - - - - - )} - /> -
- - ( - - Countries - - ({ id, name })) ?? []} - onChange={vals => field.onChange(vals)} - placeholder={ - ((field.value as number[]) ?? []).length === 0 - ? 'Select countries' - : undefined - } - /> - - - - )} - /> - -
- ( - - City - - - - - - )} - /> - ( - - External URL - - - - - - )} - /> -
- - ( - - Successors - - ({ id, name }))} - onChange={vals => field.onChange(vals)} - /> - - - - )} - /> - - - - ( - - Description - - - - - - )} - /> - - - Update - - -
- - - ) -} - -// Countries multiselect (numbers) -function CountriesMultiSelect({ - selected, - options, - onChange, - placeholder, -}: { - selected: number[] - options: { id: number; name: string }[] - onChange: (values: number[]) => void - placeholder?: string -}) { - const [open, setOpen] = useState(false) - return ( - - - - {selected.length === 0 - ? placeholder || 'Select countries' - : `${selected.length} selected`} - - - - - - - No country found. - - {options.map(opt => { - const isActive = selected.includes(opt.id) - return ( - { - const next = isActive - ? selected.filter(v => v !== opt.id) - : [...selected, opt.id] - onChange(next) - }} - > -
- {isActive ? '✓' : ''} -
- {opt.name} -
- ) - })} -
-
-
-
-
- ) -} - -// Successors multiselect (strings) -function SuccessorsMultiSelect({ - selected, - options, - onChange, -}: { - selected: string[] - options: { id: string; name: string }[] - onChange: (values: string[]) => void -}) { - const [open, setOpen] = useState(false) - return ( - - - - {selected.length === 0 ? 'Select brands' : `${selected.length} selected`} - - - - - - - No brand found. - - {options.map(opt => { - const isActive = selected.includes(opt.id) - return ( - { - const next = isActive - ? selected.filter(v => v !== opt.id) - : [...selected, opt.id] - onChange(next) - }} - > -
- {isActive ? '✓' : ''} -
- {opt.name} -
- ) - })} -
-
-
-
-
- ) -} diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index f88a5be7..669aa19f 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -1,5 +1,3 @@ -import React from 'react' - export default function PrivacyPolicy() { return
PrivacyPolicy
} diff --git a/components/elements/RequiredFieldMark.tsx b/components/elements/RequiredFieldMark.tsx new file mode 100644 index 00000000..fd3a3387 --- /dev/null +++ b/components/elements/RequiredFieldMark.tsx @@ -0,0 +1,5 @@ +import { Asterisk } from 'lucide-react' + +export const RequiredFieldMark = () => ( + +) diff --git a/components/elements/index.ts b/components/elements/index.ts index 09df09c4..05b01d6d 100644 --- a/components/elements/index.ts +++ b/components/elements/index.ts @@ -11,6 +11,7 @@ export { ImageLightbox } from './modal' export { ModeToggle } from './mode-toggle' export { NewArrivals } from './new-arrivals' export { PayPalStackedButton } from './paypal-button' +export { RequiredFieldMark } from './RequiredFieldMark' export { SearchField } from './search-field' export { ShowMore } from './showmore' export { Table } from './table' diff --git a/lib/images/actions.ts b/lib/images/actions.ts new file mode 100644 index 00000000..a2d12ac2 --- /dev/null +++ b/lib/images/actions.ts @@ -0,0 +1,19 @@ +'use server' + +import { minioBucket, minioClient } from '@/services/minio' + +async function deleteImages(fileNames?: string[]) { + if (!Array.isArray(fileNames) || fileNames.length === 0) return + await Promise.all( + fileNames.map(async file => { + try { + await minioClient.removeObject(minioBucket, file) + } catch (error) { + console.error('Unable to delete image', error) + // don't mind if it was not possible to delete the file + } + }), + ) +} + +export { deleteImages } From ac1bc3626a74c1c0c8cbe7002947d76a8b8695a4 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 8 Sep 2025 02:38:50 +0200 Subject: [PATCH 05/11] chore: add 'use server' and 'server-only' imports to multiple action and query files for server-side rendering support --- lib/barometers/queries.ts | 2 ++ lib/barometers/search.ts | 2 ++ lib/brands/actions.ts | 2 ++ lib/brands/queries.ts | 2 ++ lib/conditions/queries.ts | 2 ++ lib/counties/queries.ts | 2 ++ lib/documents/queries.ts | 2 +- lib/materials/queries.ts | 2 ++ lib/movements/queries.ts | 2 ++ 9 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/barometers/queries.ts b/lib/barometers/queries.ts index 784547ce..6f5f96c3 100644 --- a/lib/barometers/queries.ts +++ b/lib/barometers/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Prisma } from '@prisma/client' import { DEFAULT_PAGE_SIZE } from '@/constants' import { withPrisma } from '@/prisma/prismaClient' diff --git a/lib/barometers/search.ts b/lib/barometers/search.ts index f977da89..f85cff37 100644 --- a/lib/barometers/search.ts +++ b/lib/barometers/search.ts @@ -1,3 +1,5 @@ +import 'server-only' + import type { Prisma } from '@prisma/client' import { withPrisma } from '@/prisma/prismaClient' diff --git a/lib/brands/actions.ts b/lib/brands/actions.ts index 2a0b2a12..0c5ce3c8 100644 --- a/lib/brands/actions.ts +++ b/lib/brands/actions.ts @@ -1,3 +1,5 @@ +'use server' + import type { Prisma } from '@prisma/client' import { revalidatePath } from 'next/cache' import { FrontRoutes } from '@/constants' diff --git a/lib/brands/queries.ts b/lib/brands/queries.ts index 958315dc..8765126c 100644 --- a/lib/brands/queries.ts +++ b/lib/brands/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { DEFAULT_PAGE_SIZE } from '@/constants' import { withPrisma } from '@/prisma/prismaClient' diff --git a/lib/conditions/queries.ts b/lib/conditions/queries.ts index 83bb31ac..82921ea0 100644 --- a/lib/conditions/queries.ts +++ b/lib/conditions/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { withPrisma } from '@/prisma/prismaClient' export const getConditions = withPrisma(prisma => diff --git a/lib/counties/queries.ts b/lib/counties/queries.ts index 2fb515e0..5987d530 100644 --- a/lib/counties/queries.ts +++ b/lib/counties/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { withPrisma } from '@/prisma/prismaClient' export const getCountries = withPrisma(prisma => diff --git a/lib/documents/queries.ts b/lib/documents/queries.ts index c7cf499a..6df58f28 100644 --- a/lib/documents/queries.ts +++ b/lib/documents/queries.ts @@ -1,4 +1,4 @@ -'use server' +import 'server-only' import { cache } from 'react' import { withPrisma } from '@/prisma/prismaClient' diff --git a/lib/materials/queries.ts b/lib/materials/queries.ts index c1141970..7eeb9862 100644 --- a/lib/materials/queries.ts +++ b/lib/materials/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { withPrisma } from '@/prisma/prismaClient' export const getMaterials = withPrisma(prisma => diff --git a/lib/movements/queries.ts b/lib/movements/queries.ts index 237654c0..8804c1cc 100644 --- a/lib/movements/queries.ts +++ b/lib/movements/queries.ts @@ -1,3 +1,5 @@ +import 'server-only' + import { withPrisma } from '@/prisma/prismaClient' export const getMovements = withPrisma(async prisma => { From 233ab0822fc07ce752e4af5e571b942f5ad80124 Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 8 Sep 2025 04:43:26 +0200 Subject: [PATCH 06/11] feat: enhance BrandEdit component with improved image handling and submission state management - Added state management for form submission success to prevent unnecessary cleanup of temporary images. - Refactored image deletion logic to ensure only removed images are deleted from storage. - Cleaned up the component by consolidating the cleanup logic on dialog close. --- app/brands/brand-edit.tsx | 34 +++++++++++++++++++--------------- lib/brands/actions.ts | 7 +++---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/app/brands/brand-edit.tsx b/app/brands/brand-edit.tsx index 9b3cc949..c64ba4d3 100644 --- a/app/brands/brand-edit.tsx +++ b/app/brands/brand-edit.tsx @@ -48,6 +48,7 @@ export function BrandEdit({ brand, countries, brands }: Props) { const closeDeleteDialog = useCallback(() => setOpenDeleteDialog(false), []) const [isPending, startTransition] = useTransition() const brandImages = useMemo(() => brand.images.map(img => img.url), [brand.images]) + const [isSubmitSuccessful, setIsSubmitSuccessful] = useState(false) const form = useForm({ resolver: zodResolver(brandFormSchema), @@ -55,17 +56,6 @@ export function BrandEdit({ brand, countries, brands }: Props) { reValidateMode: 'onChange', }) - const cleanUpOnClose = useCallback(() => { - startTransition(async () => { - try { - // Clean up temporary uploaded images on dialog close - const uploadedImages = form.getValues('images') - const extraImages = uploadedImages.filter(img => !brandImages?.includes(img)) - await deleteImages(extraImages) - } catch (_error) {} - }) - }, [brandImages, form.getValues]) - const onUpdate = useCallback( ({ images, countries, successors, ...values }: BrandForm) => { startTransition(async () => { @@ -76,10 +66,6 @@ export function BrandEdit({ brand, countries, brands }: Props) { return } - // Remove images that were deleted from form - const extraImages = brandImages.filter(brandImage => !images.includes(brandImage)) - if (extraImages) deleteImages(extraImages) - const imageData = await Promise.all( images.map(async (url, i) => { const blurData = await getThumbnailBase64(imageStorage + url) @@ -106,7 +92,12 @@ export function BrandEdit({ brand, countries, brands }: Props) { }, }) + // Remove images that were deleted from form + const extraImages = brandImages.filter(brandImage => !images.includes(brandImage)) + if (extraImages.length > 0) deleteImages(extraImages) + toast.success(`Brand ${name} was updated`) + setIsSubmitSuccessful(true) closeBrandDialog() } catch (error) { toast.error( @@ -131,9 +122,22 @@ export function BrandEdit({ brand, countries, brands }: Props) { }) }, [brand, closeBrandDialog, closeDeleteDialog]) + const cleanUpOnClose = useCallback(() => { + if (isSubmitSuccessful) return + startTransition(async () => { + try { + // Clean up temporary uploaded images on dialog close + const uploadedImages = form.getValues('images') + const extraImages = uploadedImages.filter(img => !brandImages?.includes(img)) + await deleteImages(extraImages) + } catch (_error) {} + }) + }, [brandImages, form.getValues, isSubmitSuccessful]) + // Update form values when selected manufacturer changes useEffect(() => { if (!openBrandDialog) return cleanUpOnClose() + setIsSubmitSuccessful(false) form.reset({ id: brand.id, name: brand.name, diff --git a/lib/brands/actions.ts b/lib/brands/actions.ts index 0c5ce3c8..0ce6152c 100644 --- a/lib/brands/actions.ts +++ b/lib/brands/actions.ts @@ -24,10 +24,9 @@ const createBrand = withPrisma(async (prisma, data: Prisma.ManufacturerUnchecked const updateBrand = withPrisma(async (prisma, data: Prisma.ManufacturerUncheckedUpdateInput) => { const oldBrand = await prisma.manufacturer.findUniqueOrThrow({ where: { id: data.id as string } }) - const slug = - data.name && data.firstName - ? getBrandSlug(data.name as string, data.firstName as string) - : oldBrand.slug + const slug = data.name + ? getBrandSlug(data.name as string, data.firstName as string | undefined) + : oldBrand.slug const { id, name } = await prisma.manufacturer.update({ where: { id: data.id as string, From d032975516dbb8a98b5a06793c91fb96809bbdcd Mon Sep 17 00:00:00 2001 From: Cybervoid Date: Mon, 8 Sep 2025 19:56:35 +0200 Subject: [PATCH 07/11] refactor: streamline AddBarometer and AddDocument components for improved data fetching and form handling - Replaced existing form handling logic with dedicated BarometerForm and DocumentForm components for better separation of concerns. - Enhanced data fetching by utilizing async functions to retrieve conditions, categories, movements, brands, and materials in AddBarometer and conditions and all barometers in AddDocument. - Updated type definitions for materials and movements to ensure consistency across the application. - Cleaned up imports and improved overall code organization in related files. --- app/admin/add-barometer/barometer-form.tsx | 393 +++++++++++++ app/admin/add-barometer/page.tsx | 428 +------------- app/admin/add-document/document-form.tsx | 549 ++++++++++++++++++ app/admin/add-document/page.tsx | 465 +-------------- .../components/edit-fields/materials-edit.tsx | 4 +- .../components/edit-fields/movements-edit.tsx | 4 +- lib/barometers/queries.ts | 22 +- lib/categories/queries.ts | 62 ++ lib/documents/actions.ts | 53 +- lib/materials/queries.ts | 2 +- lib/movements/queries.ts | 2 +- 11 files changed, 1069 insertions(+), 915 deletions(-) create mode 100644 app/admin/add-barometer/barometer-form.tsx create mode 100644 app/admin/add-document/document-form.tsx create mode 100644 lib/categories/queries.ts diff --git a/app/admin/add-barometer/barometer-form.tsx b/app/admin/add-barometer/barometer-form.tsx new file mode 100644 index 00000000..a449f5f1 --- /dev/null +++ b/app/admin/add-barometer/barometer-form.tsx @@ -0,0 +1,393 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormProvider, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { createBarometer } from '@/lib/barometers/actions' +import type { AllBrandsDTO } from '@/lib/brands/queries' +import type { CategoriesDTO } from '@/lib/categories/queries' +import type { ConditionsDTO } from '@/lib/conditions/queries' +import type { MaterialsDTO } from '@/lib/materials/queries' +import type { MovementsDTO } from '@/lib/movements/queries' +import { + type BarometerFormData, + BarometerFormTransformSchema, + BarometerFormValidationSchema, +} from '@/lib/schemas/barometer-form.schema' +import { AddManufacturer } from './add-manufacturer' +import { MaterialsMultiSelect } from './add-materials' +import { Dimensions } from './dimensions' +import { FileUpload } from './file-upload' + +interface Props { + conditions: ConditionsDTO + categories: CategoriesDTO + movements: MovementsDTO + materials: MaterialsDTO + brands: AllBrandsDTO +} + +export default function BarometerForm({ + categories, + conditions, + movements, + materials, + brands, +}: Props) { + const [isPending, startTransition] = useTransition() + + const methods = useForm({ + resolver: zodResolver(BarometerFormValidationSchema), + defaultValues: { + collectionId: '', + name: '', + categoryId: '', + date: '1900', + dateDescription: '', + manufacturerId: '', + conditionId: '', + description: '', + dimensions: [], + images: [], + purchasedAt: '', + serial: '', + estimatedPrice: '', + subCategoryId: 'none', + materials: [], + }, + }) + + const { handleSubmit, setValue, reset, control } = methods + + const submitForm = (values: BarometerFormData) => { + startTransition(async () => { + try { + // Transform schema does ALL the heavy lifting - validation AND transformation! + const transformedData = await BarometerFormTransformSchema.parseAsync(values) + const { id } = await createBarometer(transformedData) + reset() + toast.success(`Added ${id} to the database`) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Error adding barometer') + } + }) + } + + // Set default values when data loads + useEffect(() => { + reset({ + categoryId: categories[0].id, + manufacturerId: brands[0].id, + conditionId: conditions.at(-1)?.id, + }) + }, [categories, conditions, brands, reset]) + + const handleAddManufacturer = (id: string) => { + setValue('manufacturerId', id) + } + + return ( + + +
+ ( + + Catalogue No. * + + + + + + )} + /> + + ( + + Serial Number + + + + + + )} + /> + + ( + + Title * + + + + + + )} + /> + + ( + + Year * + + { + const year = e.target.value.replace(/\D/g, '').slice(0, 4) + field.onChange(year) + }} + /> + + + + )} + /> + + ( + + Date description * + + + + + + )} + /> + + ( + + Purchase Date + +
+ + +
+
+ +
+ )} + /> + + ( + + Estimated Price, € + + + + + + )} + /> + + ( + + Materials + + + + + + )} + /> + + ( + + Category * + + + + )} + /> + + ( + + Movement Type + + + + )} + /> + + ( + + Manufacturer * +
+ + +
+ +
+ )} + /> + + ( + + Condition * + + + + )} + /> + + + + + + ( + + Description + +