This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View 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 });
}
});

View 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)
});

View 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'
});
}

View 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;
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View 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} />
}
];

View 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>
);
}

View 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' }
];

View File

@@ -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>
);
}

View 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' />;
}

View 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' }
];

View 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;
};