diff --git a/app/components/product/ProductDetailView.tsx b/app/components/product/ProductDetailView.tsx index 334c564..6a28cc5 100644 --- a/app/components/product/ProductDetailView.tsx +++ b/app/components/product/ProductDetailView.tsx @@ -43,6 +43,7 @@ import { useToast } from "~/hooks/use-toast"; import { useRootData } from "~/hooks/use-root-data"; import { LoginRequiredDialog } from "../LoginRequiredDialog"; import AIPromoteModal from "../AIPromoteModal"; +import { SizeGuideDialog } from "./SizeGuideDialog"; interface ProductDetailViewProps { product: ProductDetailType; @@ -258,6 +259,12 @@ export const ProductDetailView = memo(function ProductDetailView({ const [qty, setQty] = useState(1); const [isAIOpen, setIsAIOpen] = useState(false); const [isLoginOpen, setIsLoginOpen] = useState(false); + const [isSizeGuideOpen, setIsSizeGuideOpen] = useState(false); + const hasSizeChart = !!( + product.sizeChart && + product.sizeChart.columns?.length && + product.sizeChart.rows?.length + ); const { data: ratingData } = useServiceGetProductRating(product.id || ""); const { data: commentsData } = useServiceGetComments(1, product.id || ""); @@ -572,6 +579,11 @@ export const ProductDetailView = memo(function ProductDetailView({ sizes={inStockSizes.map((size) => size.value || "")} selectedSize={selectedSize} setSelectedSize={setSelectedSize} + onSizeGuide={ + hasSizeChart + ? () => setIsSizeGuideOpen(true) + : undefined + } /> )} @@ -758,6 +770,13 @@ export const ProductDetailView = memo(function ProductDetailView({ onClose={() => setIsAIOpen(false)} productId={product.id || ""} /> + {hasSizeChart && product.sizeChart && ( + + )} ); }); diff --git a/app/components/product/SizeGuideDialog.tsx b/app/components/product/SizeGuideDialog.tsx new file mode 100644 index 0000000..8805f1c --- /dev/null +++ b/app/components/product/SizeGuideDialog.tsx @@ -0,0 +1,80 @@ +import { Dialog, DialogContent, DialogOverlay } from "../ui/dialog"; +import type { SizeChart } from "../../../src/api/types"; + +interface SizeGuideDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + sizeChart: SizeChart; +} + +const UNIT_LABELS: Record = { + cm: "سانتی‌متر", + inch: "اینچ", +}; + +export function SizeGuideDialog({ + isOpen, + onOpenChange, + sizeChart, +}: SizeGuideDialogProps) { + const { columns = [], rows = [], unit = "cm" } = sizeChart; + const unitLabel = UNIT_LABELS[unit] || unit; + + return ( + + + +
+
+

راهنمای سایز

+ اندازه‌ها به {unitLabel} +
+ +
+ + + + + {columns.map((col, ci) => ( + + ))} + + + + {rows.map((row, ri) => ( + + + {columns.map((_, ci) => ( + + ))} + + ))} + +
+ سایز + + {col} +
+ {row.label} + + {row.values[ci]?.trim() ? row.values[ci] : "—"} +
+
+ +

+ اندازه‌ها ممکن است بسته به روش اندازه‌گیری کمی متفاوت باشند. +

+
+
+
+ ); +} + +export default SizeGuideDialog; diff --git a/app/components/product/SizeSelector.tsx b/app/components/product/SizeSelector.tsx index 82a5fce..93b79f3 100644 --- a/app/components/product/SizeSelector.tsx +++ b/app/components/product/SizeSelector.tsx @@ -1,16 +1,33 @@ +import { Ruler } from "lucide-react"; + interface SizeSelectorProps { sizes: string[] | undefined; selectedSize: string | null; setSelectedSize: (size: string) => void; + /** When provided, shows a "راهنمای سایز" link that opens the size guide. */ + onSizeGuide?: () => void; } export const SizeSelector = ({ sizes, selectedSize, setSelectedSize, + onSizeGuide, }: SizeSelectorProps) => (
-

انتخاب سایز

+
+

انتخاب سایز

+ {onSizeGuide && ( + + )} +
{sizes?.map((size: string, index: number) => (
void; + categoryName?: string | null; + /** Sizes the product currently offers (from its variants), used to seed rows. */ + productSizes?: string[]; + /** Product offers sizes → the chart is expected (visual emphasis + error). */ + required?: boolean; + /** Show the "must fill" error (set when the seller tried to proceed). */ + showError?: boolean; +} + +const UNITS: { value: string; label: string }[] = [ + { value: "cm", label: "سانتی‌متر" }, + { value: "inch", label: "اینچ" }, +]; + +export function SizeChartSection({ + value, + onChange, + categoryName, + productSizes = [], + required = false, + showError = false, +}: SizeChartSectionProps) { + const [customColumn, setCustomColumn] = useState(""); + + const columns = value?.columns ?? []; + const rows = value?.rows ?? []; + const unit = value?.unit ?? "cm"; + + // Quick-add chips: category suggestions not already used as a column. + const suggestionChips = useMemo(() => { + const used = new Set(columns.map((c) => c.trim())); + return getSizeSuggestions(categoryName).filter((s) => !used.has(s)); + }, [categoryName, columns]); + + // Product sizes that don't yet have a row (offer to add them). + const missingSizes = useMemo(() => { + const present = new Set(rows.map((r) => r.label.trim())); + return productSizes.filter((s) => s && s.trim() && !present.has(s.trim())); + }, [productSizes, rows]); + + // Offered sizes still lacking a row or any filled measurement (blocks publish). + const incompleteSizes = useMemo(() => { + return productSizes.filter((s) => { + const row = rows.find((r) => r.label.trim() === s.trim()); + return !row || !row.values.some((v) => v && v.trim() !== ""); + }); + }, [productSizes, rows]); + + const emit = (next: Partial) => { + onChange({ unit, columns, rows, ...next }); + }; + + const initialize = () => { + const seededColumns = getDefaultColumns(categoryName); + const seededRows = (productSizes.length ? productSizes : [""]).map((s) => ({ + label: s, + values: seededColumns.map(() => ""), + })); + onChange({ unit: "cm", columns: seededColumns, rows: seededRows }); + }; + + const addColumn = (name: string) => { + const trimmed = name.trim(); + if (!trimmed || columns.includes(trimmed)) return; + emit({ + columns: [...columns, trimmed], + rows: rows.map((r) => ({ ...r, values: [...r.values, ""] })), + }); + }; + + const renameColumn = (index: number, name: string) => { + emit({ columns: columns.map((c, i) => (i === index ? name : c)) }); + }; + + const removeColumn = (index: number) => { + emit({ + columns: columns.filter((_, i) => i !== index), + rows: rows.map((r) => ({ + ...r, + values: r.values.filter((_, i) => i !== index), + })), + }); + }; + + const addRow = (label = "") => { + emit({ rows: [...rows, { label, values: columns.map(() => "") }] }); + }; + + const addMissingSizes = () => { + emit({ + rows: [ + ...rows, + ...missingSizes.map((label) => ({ + label, + values: columns.map(() => ""), + })), + ], + }); + }; + + const setRowLabel = (index: number, label: string) => { + emit({ rows: rows.map((r, i) => (i === index ? { ...r, label } : r)) }); + }; + + const setCell = (rowIndex: number, colIndex: number, val: string) => { + emit({ + rows: rows.map((r, i) => + i === rowIndex + ? { + ...r, + values: r.values.map((v, j) => (j === colIndex ? val : v)), + } + : r + ), + }); + }; + + const removeRow = (index: number) => { + emit({ rows: rows.filter((_, i) => i !== index) }); + }; + + const cellInput = + "w-20 rounded-lg border border-GRAY3 bg-WHITE px-2 py-1.5 text-center text-R12 outline-none focus:border-VITROWN_BLUE"; + + return ( + +

+ اندازه‌های هر سایز را وارد کنید تا خریدار بداند مثلاً «لارج» برای این محصول + چه اندازه‌ای است. +

+ + {value == null ? ( + <> + + {required && showError && ( +

+ این محصول سایز دارد؛ پر کردن راهنمای سایز الزامی است. +

+ )} + + ) : ( +
+ {required && showError && incompleteSizes.length > 0 && ( +

+ برای این سایزها هنوز اندازه‌ای وارد نشده: {incompleteSizes.join("، ")} +

+ )} + + {/* Unit toggle */} +
+ واحد اندازه‌گیری +
+ {UNITS.map((u) => ( + + ))} +
+
+ + {/* Grid */} +
+ + + + + {columns.map((col, ci) => ( + + ))} + + + + {rows.map((row, ri) => ( + + + {columns.map((_, ci) => ( + + ))} + + ))} + +
+ سایز + +
+ renameColumn(ci, e.target.value)} + placeholder="نام اندازه" + className="w-24 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE" + /> + +
+
+
+ setRowLabel(ri, e.target.value)} + placeholder="سایز" + className="w-16 rounded-lg border border-GRAY3 bg-WHITE3 px-2 py-1.5 text-center text-R12 font-bold outline-none focus:border-VITROWN_BLUE" + /> + +
+
+ setCell(ri, ci, e.target.value)} + inputMode="numeric" + placeholder="—" + dir="ltr" + className={cellInput} + /> +
+
+ + {/* Add row / add missing product sizes */} +
+ + {missingSizes.length > 0 && ( + + )} +
+ + {/* Quick-add measurement columns */} + {suggestionChips.length > 0 && ( +
+ اندازه‌های پیشنهادی: +
+ {suggestionChips.map((chip) => ( + + ))} +
+
+ )} + + {/* Custom column */} +
+
+ + setCustomColumn(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addColumn(customColumn); + setCustomColumn(""); + } + }} + placeholder="اندازه دلخواه (مثلاً دور یقه)" + className="w-full rounded-lg border border-GRAY3 bg-WHITE pr-7 pl-2 py-1.5 text-R12 outline-none focus:border-VITROWN_BLUE" + /> +
+ +
+ + {/* Remove whole chart */} + +
+ )} +
+ ); +} + +export default SizeChartSection; diff --git a/app/lib/sizeChartSuggestions.ts b/app/lib/sizeChartSuggestions.ts new file mode 100644 index 0000000..2e9988b --- /dev/null +++ b/app/lib/sizeChartSuggestions.ts @@ -0,0 +1,71 @@ +/** + * Category-aware measurement suggestions for the product size chart (راهنمای سایز). + * + * Sellers can always type their own columns; these just power the quick-add + * chips and seed a sensible default column set per garment type. Matching is by + * Persian keyword substring against the product's category name, most-specific + * groups first, falling back to a general garment set. + */ + +interface SuggestionGroup { + keys: string[]; + suggestions: string[]; +} + +// Order matters: the first group whose keyword appears in the category name wins. +const GROUPS: SuggestionGroup[] = [ + // ---- bottoms ---- + { keys: ["شلوار", "شلوارک"], suggestions: ["دور کمر", "دور باسن", "فاق", "دور ران", "قد", "دمپا"] }, + { keys: ["دامن"], suggestions: ["دور کمر", "دور باسن", "قد", "دمپا"] }, + { keys: ["مایو"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد"] }, + + // ---- footwear ---- + { keys: ["کفش", "صندل", "دمپایی", "پاشنه"], suggestions: ["سایز اروپایی", "طول کف پا"] }, + + // ---- small accessories with their own sizing ---- + { keys: ["جوراب"], suggestions: ["سایز", "طول ساق"] }, + { keys: ["کلاه"], suggestions: ["دور سر"] }, + { keys: ["کمربند"], suggestions: ["طول", "عرض"] }, + { keys: ["دستکش"], suggestions: ["دور مچ", "طول دست"] }, + { keys: ["انگشتر"], suggestions: ["سایز انگشتر", "دور انگشت"] }, + { keys: ["دستبند", "پابند"], suggestions: ["دور مچ", "طول"] }, + { keys: ["گردنبند", "آویز"], suggestions: ["طول زنجیر"] }, + { keys: ["گوشواره", "گل سینه", "زیورآلات", "سردست"], suggestions: ["طول", "عرض"] }, + { keys: ["شال", "روسری", "دستمال"], suggestions: ["طول", "عرض"] }, + { keys: ["عینک"], suggestions: ["عرض فریم", "طول دسته"] }, + + // ---- bags ---- + { keys: ["کیف", "کوله"], suggestions: ["طول", "عرض", "ارتفاع", "طول بند"] }, + + // ---- one-piece / manteau ---- + { keys: ["مانتو", "کیمونو"], suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "دور کمر"] }, + { keys: ["اورال", "پیراهن و اورال"], suggestions: ["دور سینه", "دور کمر", "دور باسن", "قد", "آستین"] }, + + // ---- tops & outerwear (broad garment fallback) ---- + { + keys: [ + "کاپشن", "بارانی", "پالتو", "کت", "ژاکت", "بامبر", "جلیقه", "دورس", + "سویشرت", "هودی", "پلیور", "بلوز", "شومیز", "تیشرت", "تاپ", "پلوشرت", "پیراهن", + ], + suggestions: ["دور سینه", "قد", "سرشانه", "آستین", "عرض سینه", "دور کمر"], + }, +]; + +// Generic fallback for anything unmatched (also the base defaults the seller named). +const DEFAULT_SUGGESTIONS = ["عرض سینه", "قد", "دور کمر", "دور باسن", "سرشانه", "آستین"]; + +/** All measurement suggestions for a category (used for the quick-add chips). */ +export function getSizeSuggestions(categoryName?: string | null): string[] { + const name = (categoryName || "").trim(); + if (name) { + for (const group of GROUPS) { + if (group.keys.some((k) => name.includes(k))) return group.suggestions; + } + } + return DEFAULT_SUGGESTIONS; +} + +/** The columns to seed a brand-new (empty) chart with for this category. */ +export function getDefaultColumns(categoryName?: string | null): string[] { + return getSizeSuggestions(categoryName).slice(0, 2); +} diff --git a/app/routes/store.add.manual.$storeId.tsx b/app/routes/store.add.manual.$storeId.tsx index 64deadb..f3a3e2c 100644 --- a/app/routes/store.add.manual.$storeId.tsx +++ b/app/routes/store.add.manual.$storeId.tsx @@ -11,6 +11,7 @@ import { ImageEditor } from "~/components/store/ImageEditor"; import { ImageUploadSection } from "~/components/store/ImageUploadSection"; import { ProductDetailsForm } from "~/components/store/ProductDetailsForm"; import { ProductVariantsSection } from "~/components/store/ProductVariantsSection"; +import { SizeChartSection } from "~/components/store/SizeChartSection"; import { PricingForm } from "~/components/store/PricingForm"; import { useCreateFullProduct, @@ -30,6 +31,7 @@ import { ProductVariant as ApiProductVariant, ProductAttributeItem, MediaUploadImageUsageEnum, + SizeChart, } from "../../src/api/types"; import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog"; import { useNavigate, useParams } from "@remix-run/react"; @@ -150,6 +152,7 @@ export default function AddProduct() { setProductTitle(existingProduct.title || ""); setProductDescription(existingProduct.description || ""); setSelectedCategory(existingProduct.category || ""); + setSizeChart(existingProduct.sizeChart || null); setProductPrice( existingProduct.basePrice ? parseInt(existingProduct.basePrice).toString() @@ -412,6 +415,29 @@ export default function AddProduct() { }> >([]); const [productVariants, setProductVariants] = useState([]); + const [sizeChart, setSizeChart] = useState(null); + + // Distinct sizes this product offers (drives the size-chart rows & requirement). + const productSizes = [ + ...new Set( + productVariants + .map((v) => v.size) + .filter((s) => s && s !== "unselected" && s.trim() !== "") + ), + ]; + const hasSizes = productSizes.length > 0; + const selectedCategoryName = + categoryOptions.find((o) => o.value === selectedCategory)?.label || ""; + + // Complete = has columns and every offered size has a row with ≥1 filled value. + const isSizeChartComplete = () => { + if (!hasSizes) return true; // only required when the product has sizes + if (!sizeChart || sizeChart.columns.length === 0) return false; + return productSizes.every((s) => { + const row = sizeChart.rows.find((r) => r.label.trim() === s.trim()); + return !!row && row.values.some((v) => v && v.trim() !== ""); + }); + }; // Function to update variant quantity const updateVariantQuantity = (index: number, newQuantity: number) => { @@ -429,7 +455,8 @@ export default function AddProduct() { productTitle.trim() !== "" && productDescription.trim() !== "" && selectedCategory !== "" && - productVariants.length > 0 + productVariants.length > 0 && + isSizeChartComplete() ); }; @@ -575,6 +602,11 @@ export default function AddProduct() { // Get final image URLs (only upload edited/new images) const imageUrls = await getFinalImageUrls(); + const sizeChartPayload = + sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0 + ? sizeChart + : null; + if (isEditMode && editProductId) { // Update existing product const updateData = { @@ -585,6 +617,7 @@ export default function AddProduct() { category: selectedCategory, attributes: prepareProductAttributes(productVariants), variants: prepareProductVariants(productVariants), + sizeChart: sizeChartPayload, }; await updateProductMutation.mutateAsync({ productId: editProductId, @@ -602,6 +635,7 @@ export default function AddProduct() { attributes: prepareProductAttributes(productVariants), variants: prepareProductVariants(productVariants), instagramPostId: postId ? parseInt(postId) : undefined, + sizeChart: sizeChartPayload, }; const product = await createProductMutation.mutateAsync(productData); @@ -814,6 +848,18 @@ export default function AddProduct() { productVariants={productVariants} onUpdateVariantQuantity={updateVariantQuantity} /> + + {/* Size Chart (راهنمای سایز) */} +
+ +
)} @@ -862,6 +908,9 @@ export default function AddProduct() { {productVariants.length === 0 && (
•حداقل یک ویژگی اضافه کنید
)} + {hasSizes && !isSizeChartComplete() && ( +
•راهنمای سایز را برای همه سایزها کامل کنید
+ )}
)} diff --git a/src/api/types/models/index.ts b/src/api/types/models/index.ts index 82777d4..6487d61 100644 --- a/src/api/types/models/index.ts +++ b/src/api/types/models/index.ts @@ -1727,11 +1727,17 @@ export interface FullProductCreate { */ variants?: Array; /** - * + * * @type {number} * @memberof FullProductCreate */ instagramPostId?: number; + /** + * Seller-filled size guide (راهنمای سایز). + * @type {SizeChart} + * @memberof FullProductCreate + */ + sizeChart?: SizeChart | null; } /** * @@ -1782,11 +1788,17 @@ export interface FullProductUpdate { */ attributes?: Array; /** - * + * * @type {Array} * @memberof FullProductUpdate */ variants?: Array; + /** + * Seller-filled size guide (راهنمای سایز). + * @type {SizeChart} + * @memberof FullProductUpdate + */ + sizeChart?: SizeChart | null; } /** * @@ -3733,14 +3745,66 @@ export interface ProductDetail { */ readonly averageRating?: string; /** - * + * * @type {string} * @memberof ProductDetail */ readonly rateCount?: string; + /** + * Seller-filled size guide (راهنمای سایز). + * @type {SizeChart} + * @memberof ProductDetail + */ + sizeChart?: SizeChart | null; } /** - * + * One row of a size chart — a size label plus measurement values aligned by + * index to the chart's `columns`. + * @export + * @interface SizeChartRow + */ +export interface SizeChartRow { + /** + * Size label, e.g. "L" or "42". + * @type {string} + * @memberof SizeChartRow + */ + label: string; + /** + * Measurement values, aligned by index to SizeChart.columns. + * @type {Array} + * @memberof SizeChartRow + */ + values: Array; +} +/** + * A seller-filled size guide. `columns` are measurement names (عرض سینه، قد، + * plus any custom ones); each row aligns its `values` to those columns. + * @export + * @interface SizeChart + */ +export interface SizeChart { + /** + * Measurement unit, e.g. "cm". + * @type {string} + * @memberof SizeChart + */ + unit?: string; + /** + * Ordered measurement column names. + * @type {Array} + * @memberof SizeChart + */ + columns: Array; + /** + * Size rows. + * @type {Array} + * @memberof SizeChart + */ + rows: Array; +} +/** + * * @export * @interface ProductGenerationResponse */