feat(seller): size chart editor + buyer size guide (راهنمای سایز)
Sellers fill a size grid (rows = sizes, columns = measurements) in the product create/edit wizard; buyers see it via a راهنمای سایز dialog. - SizeChartSection editor: rows seeded from the product's sizes, columns seeded from category-aware defaults, category-specific quick-add chips (tops→دور سینه/سرشانه/آستین, pants→دور کمر/دور باسن/فاق, shoes→سایز اروپایی, bags→طول/عرض/ارتفاع, …), custom columns, cm/inch toggle, scrollable grid - required-if-the-product-has-sizes gate on publish (with per-size hint); optional otherwise so accessories/one-size and imports aren't blocked - buyer SizeGuideDialog + راهنمای سایز link in the size selector header - SizeChart types on ProductDetail / FullProductCreate / FullProductUpdate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
05cbbd8d5e
commit
d4316381d5
@ -43,6 +43,7 @@ import { useToast } from "~/hooks/use-toast";
|
|||||||
import { useRootData } from "~/hooks/use-root-data";
|
import { useRootData } from "~/hooks/use-root-data";
|
||||||
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
import { LoginRequiredDialog } from "../LoginRequiredDialog";
|
||||||
import AIPromoteModal from "../AIPromoteModal";
|
import AIPromoteModal from "../AIPromoteModal";
|
||||||
|
import { SizeGuideDialog } from "./SizeGuideDialog";
|
||||||
|
|
||||||
interface ProductDetailViewProps {
|
interface ProductDetailViewProps {
|
||||||
product: ProductDetailType;
|
product: ProductDetailType;
|
||||||
@ -258,6 +259,12 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
const [qty, setQty] = useState(1);
|
const [qty, setQty] = useState(1);
|
||||||
const [isAIOpen, setIsAIOpen] = useState(false);
|
const [isAIOpen, setIsAIOpen] = useState(false);
|
||||||
const [isLoginOpen, setIsLoginOpen] = 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: ratingData } = useServiceGetProductRating(product.id || "");
|
||||||
const { data: commentsData } = useServiceGetComments(1, 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 || "")}
|
sizes={inStockSizes.map((size) => size.value || "")}
|
||||||
selectedSize={selectedSize}
|
selectedSize={selectedSize}
|
||||||
setSelectedSize={setSelectedSize}
|
setSelectedSize={setSelectedSize}
|
||||||
|
onSizeGuide={
|
||||||
|
hasSizeChart
|
||||||
|
? () => setIsSizeGuideOpen(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -758,6 +770,13 @@ export const ProductDetailView = memo(function ProductDetailView({
|
|||||||
onClose={() => setIsAIOpen(false)}
|
onClose={() => setIsAIOpen(false)}
|
||||||
productId={product.id || ""}
|
productId={product.id || ""}
|
||||||
/>
|
/>
|
||||||
|
{hasSizeChart && product.sizeChart && (
|
||||||
|
<SizeGuideDialog
|
||||||
|
isOpen={isSizeGuideOpen}
|
||||||
|
onOpenChange={setIsSizeGuideOpen}
|
||||||
|
sizeChart={product.sizeChart}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
80
app/components/product/SizeGuideDialog.tsx
Normal file
80
app/components/product/SizeGuideDialog.tsx
Normal file
@ -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<string, string> = {
|
||||||
|
cm: "سانتیمتر",
|
||||||
|
inch: "اینچ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SizeGuideDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
sizeChart,
|
||||||
|
}: SizeGuideDialogProps) {
|
||||||
|
const { columns = [], rows = [], unit = "cm" } = sizeChart;
|
||||||
|
const unitLabel = UNIT_LABELS[unit] || unit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-B18 font-bold">راهنمای سایز</h2>
|
||||||
|
<span className="text-R12 text-GRAY">اندازهها به {unitLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-center">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky right-0 z-10 bg-WHITE2 border border-GRAY3 px-3 py-2 text-R12 font-bold text-BLACK2">
|
||||||
|
سایز
|
||||||
|
</th>
|
||||||
|
{columns.map((col, ci) => (
|
||||||
|
<th
|
||||||
|
key={ci}
|
||||||
|
className="border border-GRAY3 bg-WHITE2 px-3 py-2 text-R12 font-bold text-BLACK2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
<td className="sticky right-0 z-10 bg-WHITE border border-GRAY3 px-3 py-2 text-R12 font-bold">
|
||||||
|
{row.label}
|
||||||
|
</td>
|
||||||
|
{columns.map((_, ci) => (
|
||||||
|
<td
|
||||||
|
key={ci}
|
||||||
|
className="border border-GRAY3 px-3 py-2 text-R12 text-BLACK2"
|
||||||
|
dir="ltr"
|
||||||
|
>
|
||||||
|
{row.values[ci]?.trim() ? row.values[ci] : "—"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-R12 text-GRAY leading-6">
|
||||||
|
اندازهها ممکن است بسته به روش اندازهگیری کمی متفاوت باشند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SizeGuideDialog;
|
||||||
@ -1,16 +1,33 @@
|
|||||||
|
import { Ruler } from "lucide-react";
|
||||||
|
|
||||||
interface SizeSelectorProps {
|
interface SizeSelectorProps {
|
||||||
sizes: string[] | undefined;
|
sizes: string[] | undefined;
|
||||||
selectedSize: string | null;
|
selectedSize: string | null;
|
||||||
setSelectedSize: (size: string) => void;
|
setSelectedSize: (size: string) => void;
|
||||||
|
/** When provided, shows a "راهنمای سایز" link that opens the size guide. */
|
||||||
|
onSizeGuide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SizeSelector = ({
|
export const SizeSelector = ({
|
||||||
sizes,
|
sizes,
|
||||||
selectedSize,
|
selectedSize,
|
||||||
setSelectedSize,
|
setSelectedSize,
|
||||||
|
onSizeGuide,
|
||||||
}: SizeSelectorProps) => (
|
}: SizeSelectorProps) => (
|
||||||
<div className="flex flex-col gap-4 mt-10 px-4">
|
<div className="flex flex-col gap-4 mt-10 px-4">
|
||||||
<p className="text-B18 font-bold">انتخاب سایز</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-B18 font-bold">انتخاب سایز</p>
|
||||||
|
{onSizeGuide && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSizeGuide}
|
||||||
|
className="flex items-center gap-1 text-R12 text-VITROWN_BLUE hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<Ruler className="h-3.5 w-3.5" />
|
||||||
|
راهنمای سایز
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="gap-4 flex">
|
<div className="gap-4 flex">
|
||||||
{sizes?.map((size: string, index: number) => (
|
{sizes?.map((size: string, index: number) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
352
app/components/store/SizeChartSection.tsx
Normal file
352
app/components/store/SizeChartSection.tsx
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, X, Trash2, Ruler } from "lucide-react";
|
||||||
|
import type { SizeChart } from "../../../src/api/types";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { FormField } from "../ui/FormField";
|
||||||
|
import {
|
||||||
|
getDefaultColumns,
|
||||||
|
getSizeSuggestions,
|
||||||
|
} from "../../lib/sizeChartSuggestions";
|
||||||
|
|
||||||
|
interface SizeChartSectionProps {
|
||||||
|
value: SizeChart | null;
|
||||||
|
onChange: (value: SizeChart | null) => 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<SizeChart>) => {
|
||||||
|
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 (
|
||||||
|
<FormField label="راهنمای سایز" required={required}>
|
||||||
|
<p className="text-R12 text-GRAY -mt-1 mb-2">
|
||||||
|
اندازههای هر سایز را وارد کنید تا خریدار بداند مثلاً «لارج» برای این محصول
|
||||||
|
چه اندازهای است.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{value == null ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={initialize}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed py-4 text-R14 transition-colors cursor-pointer",
|
||||||
|
required && showError
|
||||||
|
? "border-RED text-RED"
|
||||||
|
: "border-GRAY3 text-BLACK2 hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
افزودن جدول سایز
|
||||||
|
</button>
|
||||||
|
{required && showError && (
|
||||||
|
<p className="mt-2 text-R12 text-RED">
|
||||||
|
این محصول سایز دارد؛ پر کردن راهنمای سایز الزامی است.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-GRAY3 bg-WHITE p-3">
|
||||||
|
{required && showError && incompleteSizes.length > 0 && (
|
||||||
|
<p className="rounded-lg bg-RED/10 px-3 py-2 text-R12 text-RED">
|
||||||
|
برای این سایزها هنوز اندازهای وارد نشده: {incompleteSizes.join("، ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unit toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-R12 text-GRAY">واحد اندازهگیری</span>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-GRAY3">
|
||||||
|
{UNITS.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => emit({ unit: u.value })}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-R12 transition-colors cursor-pointer",
|
||||||
|
unit === u.value
|
||||||
|
? "bg-BLACK text-WHITE"
|
||||||
|
: "bg-WHITE text-BLACK2 hover:bg-WHITE3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{u.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-separate border-spacing-1">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky right-0 z-10 bg-WHITE text-R12 text-GRAY font-normal">
|
||||||
|
سایز
|
||||||
|
</th>
|
||||||
|
{columns.map((col, ci) => (
|
||||||
|
<th key={ci} className="min-w-[6rem]">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={col}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeColumn(ci)}
|
||||||
|
aria-label={`حذف ستون ${col}`}
|
||||||
|
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
<td className="sticky right-0 z-10 bg-WHITE">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={row.label}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRow(ri)}
|
||||||
|
aria-label={`حذف سایز ${row.label}`}
|
||||||
|
className="text-GRAY hover:text-RED transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{columns.map((_, ci) => (
|
||||||
|
<td key={ci} className="text-center">
|
||||||
|
<input
|
||||||
|
value={row.values[ci] ?? ""}
|
||||||
|
onChange={(e) => setCell(ri, ci, e.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="—"
|
||||||
|
dir="ltr"
|
||||||
|
className={cellInput}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add row / add missing product sizes */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addRow()}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-GRAY3 px-3 py-1.5 text-R12 text-BLACK2 transition-colors hover:border-VITROWN_BLUE hover:text-VITROWN_BLUE cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
افزودن سایز
|
||||||
|
</button>
|
||||||
|
{missingSizes.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMissingSizes}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-VITROWN_BLUE/10 px-3 py-1.5 text-R12 text-VITROWN_BLUE transition-colors hover:bg-VITROWN_BLUE/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
افزودن سایزهای محصول ({missingSizes.join("، ")})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick-add measurement columns */}
|
||||||
|
{suggestionChips.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-R12 text-GRAY">اندازههای پیشنهادی:</span>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{suggestionChips.map((chip) => (
|
||||||
|
<button
|
||||||
|
key={chip}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addColumn(chip)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-WHITE3 px-3 py-1 text-R12 text-BLACK2 transition-colors hover:bg-VITROWN_BLUE/10 hover:text-VITROWN_BLUE cursor-pointer"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{chip}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom column */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Ruler className="absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-GRAY" />
|
||||||
|
<input
|
||||||
|
value={customColumn}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
addColumn(customColumn);
|
||||||
|
setCustomColumn("");
|
||||||
|
}}
|
||||||
|
disabled={!customColumn.trim()}
|
||||||
|
className="rounded-lg bg-BLACK px-3 py-1.5 text-R12 text-WHITE transition-colors hover:bg-BLACK2 disabled:opacity-40 cursor-pointer disabled:cursor-default"
|
||||||
|
>
|
||||||
|
افزودن ستون
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remove whole chart */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
className="self-start text-R12 text-RED hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
حذف جدول سایز
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SizeChartSection;
|
||||||
71
app/lib/sizeChartSuggestions.ts
Normal file
71
app/lib/sizeChartSuggestions.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { ImageEditor } from "~/components/store/ImageEditor";
|
|||||||
import { ImageUploadSection } from "~/components/store/ImageUploadSection";
|
import { ImageUploadSection } from "~/components/store/ImageUploadSection";
|
||||||
import { ProductDetailsForm } from "~/components/store/ProductDetailsForm";
|
import { ProductDetailsForm } from "~/components/store/ProductDetailsForm";
|
||||||
import { ProductVariantsSection } from "~/components/store/ProductVariantsSection";
|
import { ProductVariantsSection } from "~/components/store/ProductVariantsSection";
|
||||||
|
import { SizeChartSection } from "~/components/store/SizeChartSection";
|
||||||
import { PricingForm } from "~/components/store/PricingForm";
|
import { PricingForm } from "~/components/store/PricingForm";
|
||||||
import {
|
import {
|
||||||
useCreateFullProduct,
|
useCreateFullProduct,
|
||||||
@ -30,6 +31,7 @@ import {
|
|||||||
ProductVariant as ApiProductVariant,
|
ProductVariant as ApiProductVariant,
|
||||||
ProductAttributeItem,
|
ProductAttributeItem,
|
||||||
MediaUploadImageUsageEnum,
|
MediaUploadImageUsageEnum,
|
||||||
|
SizeChart,
|
||||||
} from "../../src/api/types";
|
} from "../../src/api/types";
|
||||||
import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog";
|
import { Dialog, DialogContent, DialogOverlay } from "../components/ui/dialog";
|
||||||
import { useNavigate, useParams } from "@remix-run/react";
|
import { useNavigate, useParams } from "@remix-run/react";
|
||||||
@ -150,6 +152,7 @@ export default function AddProduct() {
|
|||||||
setProductTitle(existingProduct.title || "");
|
setProductTitle(existingProduct.title || "");
|
||||||
setProductDescription(existingProduct.description || "");
|
setProductDescription(existingProduct.description || "");
|
||||||
setSelectedCategory(existingProduct.category || "");
|
setSelectedCategory(existingProduct.category || "");
|
||||||
|
setSizeChart(existingProduct.sizeChart || null);
|
||||||
setProductPrice(
|
setProductPrice(
|
||||||
existingProduct.basePrice
|
existingProduct.basePrice
|
||||||
? parseInt(existingProduct.basePrice).toString()
|
? parseInt(existingProduct.basePrice).toString()
|
||||||
@ -412,6 +415,29 @@ export default function AddProduct() {
|
|||||||
}>
|
}>
|
||||||
>([]);
|
>([]);
|
||||||
const [productVariants, setProductVariants] = useState<ProductVariant[]>([]);
|
const [productVariants, setProductVariants] = useState<ProductVariant[]>([]);
|
||||||
|
const [sizeChart, setSizeChart] = useState<SizeChart | null>(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
|
// Function to update variant quantity
|
||||||
const updateVariantQuantity = (index: number, newQuantity: number) => {
|
const updateVariantQuantity = (index: number, newQuantity: number) => {
|
||||||
@ -429,7 +455,8 @@ export default function AddProduct() {
|
|||||||
productTitle.trim() !== "" &&
|
productTitle.trim() !== "" &&
|
||||||
productDescription.trim() !== "" &&
|
productDescription.trim() !== "" &&
|
||||||
selectedCategory !== "" &&
|
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)
|
// Get final image URLs (only upload edited/new images)
|
||||||
const imageUrls = await getFinalImageUrls();
|
const imageUrls = await getFinalImageUrls();
|
||||||
|
|
||||||
|
const sizeChartPayload =
|
||||||
|
sizeChart && sizeChart.columns.length > 0 && sizeChart.rows.length > 0
|
||||||
|
? sizeChart
|
||||||
|
: null;
|
||||||
|
|
||||||
if (isEditMode && editProductId) {
|
if (isEditMode && editProductId) {
|
||||||
// Update existing product
|
// Update existing product
|
||||||
const updateData = {
|
const updateData = {
|
||||||
@ -585,6 +617,7 @@ export default function AddProduct() {
|
|||||||
category: selectedCategory,
|
category: selectedCategory,
|
||||||
attributes: prepareProductAttributes(productVariants),
|
attributes: prepareProductAttributes(productVariants),
|
||||||
variants: prepareProductVariants(productVariants),
|
variants: prepareProductVariants(productVariants),
|
||||||
|
sizeChart: sizeChartPayload,
|
||||||
};
|
};
|
||||||
await updateProductMutation.mutateAsync({
|
await updateProductMutation.mutateAsync({
|
||||||
productId: editProductId,
|
productId: editProductId,
|
||||||
@ -602,6 +635,7 @@ export default function AddProduct() {
|
|||||||
attributes: prepareProductAttributes(productVariants),
|
attributes: prepareProductAttributes(productVariants),
|
||||||
variants: prepareProductVariants(productVariants),
|
variants: prepareProductVariants(productVariants),
|
||||||
instagramPostId: postId ? parseInt(postId) : undefined,
|
instagramPostId: postId ? parseInt(postId) : undefined,
|
||||||
|
sizeChart: sizeChartPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
const product = await createProductMutation.mutateAsync(productData);
|
const product = await createProductMutation.mutateAsync(productData);
|
||||||
@ -814,6 +848,18 @@ export default function AddProduct() {
|
|||||||
productVariants={productVariants}
|
productVariants={productVariants}
|
||||||
onUpdateVariantQuantity={updateVariantQuantity}
|
onUpdateVariantQuantity={updateVariantQuantity}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Size Chart (راهنمای سایز) */}
|
||||||
|
<div className="px-4 mt-4">
|
||||||
|
<SizeChartSection
|
||||||
|
value={sizeChart}
|
||||||
|
onChange={setSizeChart}
|
||||||
|
categoryName={selectedCategoryName}
|
||||||
|
productSizes={productSizes}
|
||||||
|
required={hasSizes}
|
||||||
|
showError={hasSizes && !isSizeChartComplete()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -862,6 +908,9 @@ export default function AddProduct() {
|
|||||||
{productVariants.length === 0 && (
|
{productVariants.length === 0 && (
|
||||||
<div>•حداقل یک ویژگی اضافه کنید</div>
|
<div>•حداقل یک ویژگی اضافه کنید</div>
|
||||||
)}
|
)}
|
||||||
|
{hasSizes && !isSizeChartComplete() && (
|
||||||
|
<div>•راهنمای سایز را برای همه سایزها کامل کنید</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1727,11 +1727,17 @@ export interface FullProductCreate {
|
|||||||
*/
|
*/
|
||||||
variants?: Array<ProductVariant>;
|
variants?: Array<ProductVariant>;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {number}
|
* @type {number}
|
||||||
* @memberof FullProductCreate
|
* @memberof FullProductCreate
|
||||||
*/
|
*/
|
||||||
instagramPostId?: number;
|
instagramPostId?: number;
|
||||||
|
/**
|
||||||
|
* Seller-filled size guide (راهنمای سایز).
|
||||||
|
* @type {SizeChart}
|
||||||
|
* @memberof FullProductCreate
|
||||||
|
*/
|
||||||
|
sizeChart?: SizeChart | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -1782,11 +1788,17 @@ export interface FullProductUpdate {
|
|||||||
*/
|
*/
|
||||||
attributes?: Array<ProductAttributeName>;
|
attributes?: Array<ProductAttributeName>;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {Array<ProductVariant>}
|
* @type {Array<ProductVariant>}
|
||||||
* @memberof FullProductUpdate
|
* @memberof FullProductUpdate
|
||||||
*/
|
*/
|
||||||
variants?: Array<ProductVariant>;
|
variants?: Array<ProductVariant>;
|
||||||
|
/**
|
||||||
|
* Seller-filled size guide (راهنمای سایز).
|
||||||
|
* @type {SizeChart}
|
||||||
|
* @memberof FullProductUpdate
|
||||||
|
*/
|
||||||
|
sizeChart?: SizeChart | null;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -3733,14 +3745,66 @@ export interface ProductDetail {
|
|||||||
*/
|
*/
|
||||||
readonly averageRating?: string;
|
readonly averageRating?: string;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
* @memberof ProductDetail
|
* @memberof ProductDetail
|
||||||
*/
|
*/
|
||||||
readonly rateCount?: string;
|
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<string>}
|
||||||
|
* @memberof SizeChartRow
|
||||||
|
*/
|
||||||
|
values: Array<string>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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<string>}
|
||||||
|
* @memberof SizeChart
|
||||||
|
*/
|
||||||
|
columns: Array<string>;
|
||||||
|
/**
|
||||||
|
* Size rows.
|
||||||
|
* @type {Array<SizeChartRow>}
|
||||||
|
* @memberof SizeChart
|
||||||
|
*/
|
||||||
|
rows: Array<SizeChartRow>;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
* @export
|
* @export
|
||||||
* @interface ProductGenerationResponse
|
* @interface ProductGenerationResponse
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user