Compare commits

..

2 Commits

Author SHA1 Message Date
d694040bf7 fix(pwa): apply top safe-area inset to page headers (iOS notch)
MyLayout only pads the bottom inset globally; each top bar must add its own
top inset. Several inline page headers omitted it, so their content slid
under the notch in the iOS standalone PWA (home was fine — its bar has it).

Wrap the back-button headers the same way ProfilePagesHeader already does
(outer sticky element carries pt-[env(safe-area-inset-top)]; inner relative
bar holds the content) so the absolute back arrow stays out of the notch.
Fixed: /profile hub, /discounts, /collections, /collection/:id, /sellers,
seller store index, and financial transactions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 13:21:23 +03:30
d4316381d5 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>
2026-07-13 13:21:23 +03:30
14 changed files with 730 additions and 64 deletions

View File

@ -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 && (
<SizeGuideDialog
isOpen={isSizeGuideOpen}
onOpenChange={setIsSizeGuideOpen}
sizeChart={product.sizeChart}
/>
)}
</div>
);
});

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

View File

@ -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) => (
<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">
{sizes?.map((size: string, index: number) => (
<div

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

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

View File

@ -61,12 +61,14 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Loading Skeleton */}
@ -94,12 +96,14 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Error State */}
@ -130,12 +134,14 @@ export default function CollectionDetail() {
return (
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">جزئیات کالکشن</p>
</div>
</div>
{/* Content */}

View File

@ -118,12 +118,14 @@ export default function Collections() {
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">کالکشنها</p>
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">کالکشنها</p>
</div>
</div>
{/* Content */}

View File

@ -25,12 +25,14 @@ export default function Discounts() {
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">تخفیفها</p>
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">تخفیفها</p>
</div>
</div>
{/* Content */}

View File

@ -199,7 +199,7 @@ export default function Profile() {
{/* Mobile: account hub */}
<div className="flex flex-col gap-0 lg:hidden">
{/* Header: avatar + name + phone + edit */}
<div className="flex items-center gap-3.5 px-4 pt-4 pb-3">
<div className="flex items-center gap-3.5 px-4 pt-[calc(1rem_+_env(safe-area-inset-top))] pb-3">
<CircleUserRound size={56} className="text-GRAY shrink-0" />
<div className="min-w-0 flex-1">
<h2 className="text-B16 font-bold truncate">

View File

@ -62,12 +62,14 @@ export default function Sellers() {
return (
<div className="flex flex-col bg-WHITE pb-20">
{/* Header (mobile) */}
<div className="lg:hidden flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">فروشگاهها</p>
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
<ArrowRight
onClick={handleBack}
className="absolute top-5 right-5 cursor-pointer"
/>
<p className="text-B16 font-bold">فروشگاهها</p>
</div>
</div>
{/* Content */}

View File

@ -558,7 +558,7 @@ function SellerDetailHeader({
};
return (
<div className="flex px-4 w-full justify-between items-center sticky top-0 z-30 bg-WHITE">
<div className="flex px-4 w-full justify-between items-center sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
<ArrowRight size={24} onClick={() => safeGoBack(navigate)} />
<div className="flex flex-col gap-2 py-4 items-center">
<SellerLogo src={sellerData?.storeLogo} alt="Seller Logo" />

View File

@ -136,27 +136,29 @@ export default function Transactions() {
return (
<div className="bg-WHITE lg:max-w-[900px] lg:mx-auto lg:w-full lg:pt-6">
{/* Header (keeps the filter button; just constrained on desktop) */}
<div className="flex flex-row h-[65px] sticky top-0 z-30 bg-WHITE w-full items-center justify-center border-b border-inner-border lg:static lg:justify-start lg:gap-3">
<ArrowRight onClick={handleBack} className="absolute top-5 right-5 lg:hidden" />
<p className={"text-B16 lg:text-[28px] font-bold lg:font-extrabold"}>لیست تراکنشها</p>
<div
onClick={() => {
filterIconClicked();
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)] lg:static lg:pt-0">
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border lg:justify-start lg:gap-3">
<ArrowRight onClick={handleBack} className="absolute top-5 right-5 lg:hidden" />
<p className={"text-B16 lg:text-[28px] font-bold lg:font-extrabold"}>لیست تراکنشها</p>
<div
onClick={() => {
filterIconClicked();
}
}}
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center"
>
<img
src={theme === "dark" ? filterIconDark : filterIcon}
alt="filter"
className="w-6 h-6"
/>
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter") {
filterIconClicked();
}
}}
className="absolute top-4 h-8 w-8 left-5 rounded-full bg-WHITE shadow-xl flex items-center justify-center"
>
<img
src={theme === "dark" ? filterIconDark : filterIcon}
alt="filter"
className="w-6 h-6"
/>
</div>
</div>
</div>
<div className="flex flex-col w-full">

View File

@ -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<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
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 (راهنمای سایز) */}
<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 && (
<div>حداقل یک ویژگی اضافه کنید</div>
)}
{hasSizes && !isSizeChartComplete() && (
<div>راهنمای سایز را برای همه سایزها کامل کنید</div>
)}
</div>
)}

View File

@ -1732,6 +1732,12 @@ export interface FullProductCreate {
* @memberof FullProductCreate
*/
instagramPostId?: number;
/**
* Seller-filled size guide (راهنمای سایز).
* @type {SizeChart}
* @memberof FullProductCreate
*/
sizeChart?: SizeChart | null;
}
/**
*
@ -1787,6 +1793,12 @@ export interface FullProductUpdate {
* @memberof FullProductUpdate
*/
variants?: Array<ProductVariant>;
/**
* Seller-filled size guide (راهنمای سایز).
* @type {SizeChart}
* @memberof FullProductUpdate
*/
sizeChart?: SizeChart | null;
}
/**
*
@ -3738,6 +3750,58 @@ export interface ProductDetail {
* @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<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>;
}
/**
*