Files
ember-market-frontend/components/modals/product-modal.tsx
g fe01f31538
Some checks failed
Build Frontend / build (push) Failing after 7s
Refactor UI imports and update component paths
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
2026-01-13 05:02:13 +00:00

490 lines
17 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/common/dialog";
import { Button } from "@/components/common/button";
import { Input } from "@/components/common/input";
import { Textarea } from "@/components/common/textarea";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/common/select";
import { ImageUpload } from "@/components/forms/image-upload";
import { PricingTiers } from "@/components/forms/pricing-tiers";
import type { ProductModalProps, ProductData } from "@/lib/types";
import { toast } from "sonner";
import type React from "react";
import { Plus } from "lucide-react";
import { apiRequest } from "@/lib/api";
import { Switch } from "@/components/common/switch";
import { Badge } from "@/components/common/badge";
type CategorySelectProps = {
categories: { _id: string; name: string; parentId?: string }[];
value: string;
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
};
export const ProductModal: React.FC<ProductModalProps> = ({
open,
onClose,
onSave,
productData,
categories,
editing,
handleChange,
handleRemoveTier,
setProductData,
}) => {
/**
* 1) Store the selected file *separately* from productData.image
*/
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageDimensions, setImageDimensions] = useState({ width: 300, height: 200 });
const [localCategories, setLocalCategories] = useState(categories);
// If productData.image is a *URL* (string), show it as a default preview
useEffect(() => {
if (productData.image && typeof productData.image === "string" && productData._id) {
setImagePreview(`${process.env.NEXT_PUBLIC_API_URL}/products/${productData._id}/image`);
} else if (productData.image && typeof productData.image === "string") {
// Image exists but no ID, this is probably a new product
setImagePreview(null);
}
}, [productData.image, productData._id]);
useEffect(() => {
setLocalCategories(categories);
}, [categories]);
// Reset image state when modal is closed
useEffect(() => {
if (!open) {
setSelectedFile(null);
setImagePreview(null);
}
}, [open]);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
// no file selected
setSelectedFile(null);
setImagePreview(null);
return;
}
// For preview
const objectUrl = URL.createObjectURL(file);
setSelectedFile(file);
setImagePreview(objectUrl);
const image = new Image();
image.onload = () => {
const aspectRatio = image.naturalWidth / image.naturalHeight;
const width = aspectRatio > 1 ? 300 : 200 * aspectRatio;
const height = aspectRatio > 1 ? 300 / aspectRatio : 200;
setImageDimensions({ width, height });
};
image.src = objectUrl;
};
const handleAddTier = () => {
setProductData((prev) => ({
...prev,
pricing: [
...prev.pricing,
{
minQuantity: 0,
pricePerUnit: 0,
// tempId ensures stable identity before backend assigns _id
// Using crypto.randomUUID if available, otherwise a timestamp fallback
tempId:
(typeof crypto !== "undefined" && (crypto as any).randomUUID
? (crypto as any).randomUUID()
: `${Date.now()}-${Math.random()}`),
},
],
}));
};
const handleSave = async () => {
try {
// Validate required fields
if (!productData.name || !productData.category || !productData.unitType) {
toast.error("Please fill in all required fields");
return;
}
// Validate pricing tiers
if (!productData.pricing || productData.pricing.length === 0) {
toast.error("At least one pricing tier is required");
return;
}
// Make sure stock values are numbers
let stockData = { ...productData };
if (stockData.stockTracking) {
stockData.currentStock = Number(stockData.currentStock) || 0;
stockData.lowStockThreshold = Number(stockData.lowStockThreshold) || 10;
}
await onSave(stockData, selectedFile);
onClose();
} catch (error) {
console.error("Error saving product:", error);
toast.error("Failed to save product");
}
};
const handleAddCategory = (newCategory: { _id: string; name: string; parentId?: string }) => {
setLocalCategories((prev) => [...prev, newCategory]);
};
const handleTierChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const { name, value } = e.target;
setProductData((prev) => ({
...prev,
pricing: prev.pricing.map((tier, i) =>
i === index
? { ...tier, [name]: value === "" ? 0 : parseFloat(value) || 0 }
: tier
),
}));
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] lg:max-w-6xl w-full overflow-y-auto max-h-[90vh] z-[80]">
<DialogHeader>
<DialogTitle className="text-base">
{editing ? "Edit Product" : "Add Product"}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 xl:grid-cols-[2fr_1fr] gap-6 lg:gap-8 py-4">
<ProductBasicInfo
productData={productData}
handleChange={handleChange}
categories={localCategories}
setProductData={setProductData}
onAddCategory={handleAddCategory}
/>
<div className="space-y-6">
<ImageUpload
imagePreview={imagePreview}
handleImageChange={handleImageChange}
imageDimensions={imageDimensions}
/>
<PricingTiers
pricing={productData.pricing}
handleTierChange={handleTierChange}
handleRemoveTier={handleRemoveTier}
handleAddTier={handleAddTier}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>
{editing ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const ProductBasicInfo: React.FC<{
productData: ProductData;
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
categories: { _id: string; name: string; parentId?: string }[];
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
}> = ({ productData, handleChange, categories, setProductData, onAddCategory }) => (
<div className="space-y-8">
<div className="space-y-4">
<div className="grid gap-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Product Name
</label>
<Input
id="name"
name="name"
value={productData.name}
onChange={handleChange}
placeholder="e.g. Premium Wireless Headphones"
className="border-border/50 bg-background/50 focus:bg-background transition-colors"
/>
</div>
<div className="grid gap-2">
<label htmlFor="description" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Description
</label>
<textarea
id="description"
name="description"
value={productData.description}
onChange={handleChange}
placeholder="Describe your product features and benefits..."
className="flex w-full rounded-md border border-border/50 bg-background/50 px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y focus:bg-background transition-colors"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium leading-none">Category</label>
<CategorySelect
categories={categories}
value={productData.category}
setProductData={setProductData}
onAddCategory={onAddCategory}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium leading-none">Unit Type</label>
<UnitTypeSelect
value={productData.unitType}
setProductData={setProductData}
categories={categories}
/>
</div>
</div>
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
<div className="p-4 border-b border-border/40 bg-muted/30">
<h3 className="text-sm font-semibold flex items-center gap-2">
Inventory Management
</h3>
</div>
<div className="p-5 space-y-6">
<div className="flex items-center justify-between p-3 rounded-lg border border-border/40 bg-background/40">
<div className="space-y-0.5">
<label htmlFor="stockTracking" className="text-sm font-medium cursor-pointer">Track Stock Quantity</label>
<p className="text-xs text-muted-foreground">Automatically update stock when orders are placed</p>
</div>
<Switch
id="stockTracking"
checked={productData.stockTracking !== false}
onCheckedChange={(checked) => {
setProductData({
...productData,
stockTracking: checked
});
}}
/>
</div>
{productData.stockTracking !== false && (
<div className="grid grid-cols-2 gap-5 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="space-y-2">
<label htmlFor="currentStock" className="text-sm font-medium text-muted-foreground">
Current Quantity
</label>
<Input
id="currentStock"
name="currentStock"
type="number"
min="0"
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
value={productData.currentStock || 0}
onChange={handleChange}
placeholder="0"
className="font-mono"
/>
</div>
<div className="space-y-2">
<label htmlFor="lowStockThreshold" className="text-sm font-medium text-muted-foreground">
Low Stock Alert At
</label>
<Input
id="lowStockThreshold"
name="lowStockThreshold"
type="number"
min="1"
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
value={productData.lowStockThreshold || 10}
onChange={handleChange}
placeholder="10"
className="font-mono"
/>
</div>
</div>
)}
</div>
</div>
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
<div className="p-4 border-b border-border/40 bg-muted/30 flex justify-between items-center">
<h3 className="text-sm font-semibold">Cost Analysis</h3>
<Badge variant="outline" className="text-[10px] font-normal">Optional</Badge>
</div>
<div className="p-5">
<div className="space-y-2">
<label htmlFor="costPerUnit" className="text-sm font-medium">
Cost Per Unit
</label>
<div className="relative">
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
<Input
id="costPerUnit"
name="costPerUnit"
type="number"
min="0"
step="0.01"
value={productData.costPerUnit || ''}
onChange={handleChange}
placeholder="0.00"
className="pl-7 font-mono"
/>
</div>
<p className="text-[11px] text-muted-foreground mt-1.5">
Enter your cost to calculate profit margins automatically. This is never shown to customers.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between p-4 rounded-xl border border-border/40 bg-blue-500/5">
<div className="space-y-0.5">
<label htmlFor="enabled" className="text-sm font-medium text-foreground">Product Visibility</label>
<p className="text-xs text-muted-foreground">Make this product visible in your store</p>
</div>
<Switch
id="enabled"
checked={productData.enabled !== false}
onCheckedChange={(checked) => {
setProductData({
...productData,
enabled: checked
});
}}
/>
</div>
</div>
);
const CategorySelect: React.FC<CategorySelectProps> = ({
categories,
value,
setProductData,
}) => {
const [selectedMainCategory, setSelectedMainCategory] = useState<string>("");
// Get root categories (those without parentId)
const rootCategories = categories.filter(cat => !cat.parentId);
// Get subcategories for a given parent
const getSubcategories = (parentId: string) =>
categories.filter(cat => cat.parentId === parentId);
return (
<div className="space-y-2">
<label className="text-sm font-medium">Category</label>
<div className="space-y-2">
{/* Main Category Select */}
<Select
value={selectedMainCategory}
onValueChange={(val) => {
setSelectedMainCategory(val);
setProductData((prev) => ({ ...prev, category: "" }));
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select main category..." />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="uncategorized">Select main category...</SelectItem>
{rootCategories.map((cat) => (
<SelectItem key={cat._id} value={cat._id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Subcategory Select */}
{selectedMainCategory && selectedMainCategory !== "uncategorized" && (
<Select
value={value || "uncategorized"}
onValueChange={(val) =>
setProductData((prev) => ({
...prev,
category: val === "uncategorized" ? "" : val,
}))
}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select subcategory (optional)..." />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="uncategorized">No subcategory</SelectItem>
{getSubcategories(selectedMainCategory).map((subCat) => (
<SelectItem key={subCat._id} value={subCat._id}>
{subCat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
};
const UnitTypeSelect: React.FC<{
value: string;
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
categories: { _id: string; name: string; parentId?: string }[];
}> = ({ value, setProductData, categories }) => (
<div className="space-y-2">
<label className="text-sm font-medium">Unit Type</label>
<div className="flex items-center space-x-2">
<Select
value={value || "placeholder"}
onValueChange={(val) => {
if (val === "placeholder") return;
setProductData((prev) => ({
...prev,
unitType: val as 'pcs' | 'gr' | 'kg' | 'ml' | 'oz' | 'lb',
}));
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select unit type" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="placeholder" disabled>
Select unit type
</SelectItem>
<SelectItem value="pcs">Pieces (pcs)</SelectItem>
<SelectItem value="gr">Grams (gr)</SelectItem>
<SelectItem value="kg">Kilograms (kg)</SelectItem>
<SelectItem value="ml">Milliliters (ml)</SelectItem>
<SelectItem value="oz">Ounces (oz)</SelectItem>
<SelectItem value="lb">Pounds (lb)</SelectItem>
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" disabled>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
</div>
</div>
);