init
This commit is contained in:
27
src/features/products/api/mutations.ts
Normal file
27
src/features/products/api/mutations.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { createProduct, updateProduct, deleteProduct } from './service';
|
||||
import { productKeys } from './queries';
|
||||
import type { ProductMutationPayload } from './types';
|
||||
|
||||
export const createProductMutation = mutationOptions({
|
||||
mutationFn: (data: ProductMutationPayload) => createProduct(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProductMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: number; values: ProductMutationPayload }) =>
|
||||
updateProduct(id, values),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteProductMutation = mutationOptions({
|
||||
mutationFn: (id: number) => deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: productKeys.all });
|
||||
}
|
||||
});
|
||||
23
src/features/products/api/queries.ts
Normal file
23
src/features/products/api/queries.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getProducts, getProductById } from './service';
|
||||
import type { Product, ProductFilters } from './types';
|
||||
|
||||
export type { Product };
|
||||
|
||||
export const productKeys = {
|
||||
all: ['products'] as const,
|
||||
list: (filters: ProductFilters) => [...productKeys.all, 'list', filters] as const,
|
||||
detail: (id: number) => [...productKeys.all, 'detail', id] as const
|
||||
};
|
||||
|
||||
export const productsQueryOptions = (filters: ProductFilters) =>
|
||||
queryOptions({
|
||||
queryKey: productKeys.list(filters),
|
||||
queryFn: () => getProducts(filters)
|
||||
});
|
||||
|
||||
export const productByIdOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: productKeys.detail(id),
|
||||
queryFn: () => getProductById(id)
|
||||
});
|
||||
43
src/features/products/api/service.ts
Normal file
43
src/features/products/api/service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
ProductByIdResponse,
|
||||
ProductFilters,
|
||||
ProductMutationPayload,
|
||||
ProductsResponse
|
||||
} from './types';
|
||||
|
||||
export async function getProducts(filters: ProductFilters): Promise<ProductsResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.categories) searchParams.set('categories', filters.categories);
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
return apiClient<ProductsResponse>(`/products?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export async function getProductById(id: number): Promise<ProductByIdResponse> {
|
||||
return apiClient<ProductByIdResponse>(`/products/${id}`);
|
||||
}
|
||||
|
||||
export async function createProduct(data: ProductMutationPayload) {
|
||||
return apiClient<ProductByIdResponse>('/products', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProduct(id: number, data: ProductMutationPayload) {
|
||||
return apiClient<ProductByIdResponse>(`/products/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: number) {
|
||||
return apiClient<{ success: boolean; message: string }>(`/products/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
44
src/features/products/api/types.ts
Normal file
44
src/features/products/api/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface Product {
|
||||
id: number;
|
||||
organization_id: string;
|
||||
photo_url: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
price: number;
|
||||
category: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
categories?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
total_products: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export interface ProductByIdResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
product: Product;
|
||||
}
|
||||
|
||||
export interface ProductMutationPayload {
|
||||
name: string;
|
||||
category: string;
|
||||
price: number;
|
||||
description: string;
|
||||
photoDataUrl?: string;
|
||||
}
|
||||
165
src/features/products/components/product-form.tsx
Normal file
165
src/features/products/components/product-form.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { createProductMutation, updateProductMutation } from '../api/mutations';
|
||||
import type { Product } from '../api/types';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
import { productSchema, type ProductFormValues } from '@/features/products/schemas/product';
|
||||
import { categoryOptions } from '@/features/products/constants/product-options';
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('Unable to read image file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProductForm({
|
||||
initialData,
|
||||
pageTitle
|
||||
}: {
|
||||
initialData: Product | null;
|
||||
pageTitle: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const isEdit = !!initialData;
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product created successfully');
|
||||
router.push('/dashboard/product');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create product');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product updated successfully');
|
||||
router.push('/dashboard/product');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update product');
|
||||
}
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
image: undefined,
|
||||
name: initialData?.name ?? '',
|
||||
category: initialData?.category ?? '',
|
||||
price: initialData?.price,
|
||||
description: initialData?.description ?? ''
|
||||
} as ProductFormValues,
|
||||
validators: {
|
||||
onSubmit: productSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const selectedImage = value.image?.[0];
|
||||
const photoDataUrl = selectedImage ? await fileToDataUrl(selectedImage) : undefined;
|
||||
|
||||
const payload = {
|
||||
name: value.name,
|
||||
category: value.category,
|
||||
price: value.price!,
|
||||
description: value.description,
|
||||
...(photoDataUrl ? { photoDataUrl } : {})
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate({ id: initialData.id, values: payload });
|
||||
} else {
|
||||
createMutation.mutate(payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { FormTextField, FormSelectField, FormTextareaField, FormFileUploadField } =
|
||||
useFormFields<ProductFormValues>();
|
||||
|
||||
return (
|
||||
<Card className='mx-auto w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-left text-2xl font-bold'>{pageTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form.AppForm>
|
||||
<form.Form className='space-y-8'>
|
||||
<FormFileUploadField
|
||||
name='image'
|
||||
label='Product Image'
|
||||
description='Upload a product image'
|
||||
maxSize={5 * 1024 * 1024}
|
||||
maxFiles={4}
|
||||
/>
|
||||
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Product Name'
|
||||
required
|
||||
placeholder='Enter product name'
|
||||
validators={{
|
||||
onBlur: z.string().min(2, 'Product name must be at least 2 characters.')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name='category'
|
||||
label='Category'
|
||||
required
|
||||
options={categoryOptions}
|
||||
placeholder='Select category'
|
||||
validators={{
|
||||
onBlur: z.string().min(1, 'Please select a category')
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name='price'
|
||||
label='Price'
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
step={0.01}
|
||||
placeholder='Enter price'
|
||||
validators={{
|
||||
onBlur: z.number({ message: 'Price is required' })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
required
|
||||
placeholder='Enter product description'
|
||||
maxLength={500}
|
||||
rows={4}
|
||||
validators={{
|
||||
onBlur: z.string().min(10, 'Description must be at least 10 characters.')
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='button' variant='outline' onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
<form.SubmitButton>{isEdit ? 'Update Product' : 'Add Product'}</form.SubmitButton>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
37
src/features/products/components/product-listing.tsx
Normal file
37
src/features/products/components/product-listing.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import { productsQueryOptions } from '../api/queries';
|
||||
import { ProductTable } from './product-tables';
|
||||
|
||||
type ProductListingPageProps = {
|
||||
canPrefetch?: boolean;
|
||||
};
|
||||
|
||||
export default function ProductListingPage({ canPrefetch = true }: ProductListingPageProps) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const search = searchParamsCache.get('name');
|
||||
const pageLimit = searchParamsCache.get('perPage');
|
||||
const categories = searchParamsCache.get('category');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
|
||||
const filters = {
|
||||
page,
|
||||
limit: pageLimit,
|
||||
...(search && { search }),
|
||||
...(categories && { categories }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canPrefetch) {
|
||||
void queryClient.prefetchQuery(productsQueryOptions(filters));
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ProductTable />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
import { AlertModal } from '@/components/modal/alert-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { deleteProductMutation } from '../../api/mutations';
|
||||
import type { Product } from '../../api/types';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CellActionProps {
|
||||
data: Product;
|
||||
}
|
||||
|
||||
export function CellAction({ data }: CellActionProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteProductMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Product deleted successfully');
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete product');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModal
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<span className='sr-only'>Open menu</span>
|
||||
<Icons.ellipsis className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/product/${data.id}`)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Update
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setOpen(true)}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/features/products/components/product-tables/columns.tsx
Normal file
77
src/features/products/components/product-tables/columns.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import type { Product } from '../../api/types';
|
||||
import { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { CellAction } from './cell-action';
|
||||
import { CATEGORY_OPTIONS } from './options';
|
||||
import { ProductImagePreview } from './product-image-preview';
|
||||
|
||||
export const columns: ColumnDef<Product>[] = [
|
||||
{
|
||||
accessorKey: 'photo_url',
|
||||
header: 'IMAGE',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ProductImagePreview
|
||||
src={row.getValue('photo_url')}
|
||||
alt={row.getValue('name')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Name' />
|
||||
),
|
||||
cell: ({ cell }) => <div>{cell.getValue<Product['name']>()}</div>,
|
||||
meta: {
|
||||
label: 'Name',
|
||||
placeholder: 'Search products...',
|
||||
variant: 'text',
|
||||
icon: Icons.text
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
accessorKey: 'category',
|
||||
enableSorting: false,
|
||||
header: ({ column }: { column: Column<Product, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Category' />
|
||||
),
|
||||
cell: ({ cell }) => {
|
||||
const status = cell.getValue<Product['category']>();
|
||||
const Icon = status === 'active' ? Icons.circleCheck : Icons.xCircle;
|
||||
|
||||
return (
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
<Icon />
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: 'categories',
|
||||
variant: 'multiSelect',
|
||||
options: CATEGORY_OPTIONS
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'PRICE'
|
||||
},
|
||||
{
|
||||
accessorKey: 'description',
|
||||
header: 'DESCRIPTION'
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <CellAction data={row.original} />
|
||||
}
|
||||
];
|
||||
51
src/features/products/components/product-tables/index.tsx
Normal file
51
src/features/products/components/product-tables/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { productsQueryOptions } from '../../api/queries';
|
||||
import { columns } from './columns';
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function ProductTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
category: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.category && { categories: params.category }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(productsQueryOptions(filters));
|
||||
|
||||
const pageCount = Math.ceil(data.total_products / params.perPage);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.products,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: {
|
||||
columnPinning: { right: ['actions'] }
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
10
src/features/products/components/product-tables/options.tsx
Normal file
10
src/features/products/components/product-tables/options.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'Electronics', label: 'Electronics' },
|
||||
{ value: 'Furniture', label: 'Furniture' },
|
||||
{ value: 'Clothing', label: 'Clothing' },
|
||||
{ value: 'Toys', label: 'Toys' },
|
||||
{ value: 'Groceries', label: 'Groceries' },
|
||||
{ value: 'Books', label: 'Books' },
|
||||
{ value: 'Jewelry', label: 'Jewelry' },
|
||||
{ value: 'Beauty Products', label: 'Beauty Products' }
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface ProductImagePreviewProps {
|
||||
alt: string;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function ProductImagePreview({
|
||||
alt,
|
||||
src
|
||||
}: ProductImagePreviewProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='group relative h-[150px] w-[150px] overflow-hidden rounded-xl border bg-muted/20 shadow-sm transition hover:shadow-md focus-visible:ring-ring cursor-zoom-in focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden'
|
||||
aria-label={`Preview image for ${alt}`}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes='150px'
|
||||
className='object-cover transition duration-300 group-hover:scale-[1.03]'
|
||||
/>
|
||||
<div className='bg-background/70 text-foreground absolute inset-x-0 bottom-0 flex items-center justify-center py-1 text-xs font-medium opacity-0 backdrop-blur-sm transition group-hover:opacity-100'>
|
||||
Click to preview
|
||||
</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='max-w-4xl border-none bg-transparent p-0 shadow-none'>
|
||||
<DialogHeader className='sr-only'>
|
||||
<DialogTitle>{alt}</DialogTitle>
|
||||
<DialogDescription>Preview product image in a larger size.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='bg-background relative overflow-hidden rounded-2xl border shadow-xl'>
|
||||
<div className='relative aspect-square max-h-[80vh] w-full'>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes='(max-width: 768px) 90vw, 70vw'
|
||||
className='object-contain bg-muted/10'
|
||||
priority={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
29
src/features/products/components/product-view-page.tsx
Normal file
29
src/features/products/components/product-view-page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import type { Product } from '../api/types';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ProductForm from './product-form';
|
||||
import { productByIdOptions } from '../api/queries';
|
||||
|
||||
type TProductViewPageProps = {
|
||||
productId: string;
|
||||
};
|
||||
|
||||
export default function ProductViewPage({ productId }: TProductViewPageProps) {
|
||||
if (productId === 'new') {
|
||||
return <ProductForm initialData={null} pageTitle='Create New Product' />;
|
||||
}
|
||||
|
||||
return <EditProductView productId={Number(productId)} />;
|
||||
}
|
||||
|
||||
function EditProductView({ productId }: { productId: number }) {
|
||||
const { data } = useSuspenseQuery(productByIdOptions(productId));
|
||||
|
||||
if (!data?.success || !data?.product) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ProductForm initialData={data.product as Product} pageTitle='Edit Product' />;
|
||||
}
|
||||
6
src/features/products/constants/product-options.ts
Normal file
6
src/features/products/constants/product-options.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const categoryOptions = [
|
||||
{ value: 'beauty', label: 'Beauty Products' },
|
||||
{ value: 'electronics', label: 'Electronics' },
|
||||
{ value: 'home', label: 'Home & Garden' },
|
||||
{ value: 'sports', label: 'Sports & Outdoors' }
|
||||
];
|
||||
27
src/features/products/schemas/product.ts
Normal file
27
src/features/products/schemas/product.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
const MAX_FILE_SIZE = 5_000_000;
|
||||
const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
|
||||
export const productSchema = z.object({
|
||||
image: z
|
||||
.any()
|
||||
.optional()
|
||||
.refine((files) => !files?.length || files?.[0]?.size <= MAX_FILE_SIZE, 'Max file size is 5MB.')
|
||||
.refine(
|
||||
(files) => !files?.length || ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
|
||||
'.jpg, .jpeg, .png and .webp files are accepted.'
|
||||
),
|
||||
name: z.string().min(2, 'Product name must be at least 2 characters.'),
|
||||
category: z.string().min(1, 'Please select a category'),
|
||||
price: z.number({ message: 'Price is required' }),
|
||||
description: z.string().min(10, 'Description must be at least 10 characters.')
|
||||
});
|
||||
|
||||
export type ProductFormValues = {
|
||||
image?: File[];
|
||||
name: string;
|
||||
category: string;
|
||||
price: number | undefined;
|
||||
description: string;
|
||||
};
|
||||
Reference in New Issue
Block a user