Vitron-Front/app/components/store/SizeChartSection.tsx
Arda Samadi 2cf1224107 change(size-chart): centimeter only (drop cm/inch toggle)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:09:01 +03:30

330 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
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>
)}
<div className="flex items-center justify-end">
<span className="text-R12 text-GRAY">اندازهها به سانتیمتر</span>
</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;