Add password modal and VIP product controls on clothes list.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
1041ab20c5
commit
4a516e31db
+288
-30
@@ -8,6 +8,7 @@ import IconButton, {
|
||||
DiscountIcon,
|
||||
EditIcon,
|
||||
FilterIcon,
|
||||
SubscriptionIcon,
|
||||
TrashIcon,
|
||||
VariantIcon,
|
||||
} from "@/components/ui/IconButton";
|
||||
@@ -94,6 +95,14 @@ export default function ProductListPage() {
|
||||
const [discountProduct, setDiscountProduct] = useState<Product | null>(null);
|
||||
const [discountRows, setDiscountRows] = useState<ProductVariantPrice[]>([]);
|
||||
|
||||
const [vipProduct, setVipProduct] = useState<Product | null>(null);
|
||||
const [vipEnabled, setVipEnabled] = useState(false);
|
||||
const [vipOnly, setVipOnly] = useState(false);
|
||||
const [vipSpecialPrice, setVipSpecialPrice] = useState(false);
|
||||
const [vipRows, setVipRows] = useState<ProductVariantPrice[]>([]);
|
||||
const [vipSaving, setVipSaving] = useState(false);
|
||||
const [vipError, setVipError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
@@ -178,6 +187,99 @@ export default function ProductListPage() {
|
||||
setDiscountProduct(null);
|
||||
}
|
||||
|
||||
function openVip(product: Product) {
|
||||
setVipProduct(product);
|
||||
setVipEnabled(Boolean(product.vipEnabled));
|
||||
setVipOnly(Boolean(product.vipOnly));
|
||||
setVipSpecialPrice(Boolean(product.vipSpecialPrice));
|
||||
if (product.variantPrices?.length) {
|
||||
setVipRows(product.variantPrices.map((row) => ({ ...row })));
|
||||
} else {
|
||||
setVipRows([
|
||||
{
|
||||
...emptyVariantRow(String(product.price)),
|
||||
vipPrice:
|
||||
typeof product.vipPrice === "number"
|
||||
? String(product.vipPrice)
|
||||
: "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
setVipError(null);
|
||||
}
|
||||
|
||||
function closeVip() {
|
||||
setVipProduct(null);
|
||||
setVipRows([]);
|
||||
setVipError(null);
|
||||
setVipSaving(false);
|
||||
}
|
||||
|
||||
function updateVipPrice(id: string, value: string) {
|
||||
setVipRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, vipPrice: value } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
async function saveVip(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!vipProduct) return;
|
||||
setVipSaving(true);
|
||||
setVipError(null);
|
||||
|
||||
const enabled = vipEnabled;
|
||||
const only = enabled && vipOnly;
|
||||
const special = enabled && vipSpecialPrice;
|
||||
const hadVariants = Boolean(vipProduct.variantPrices?.length);
|
||||
|
||||
let nextVipPrice: number | null = null;
|
||||
let nextVariants: ReturnType<typeof toVariantPayload> | undefined;
|
||||
|
||||
if (special) {
|
||||
const nextRows = vipRows.map((row) => ({ ...row }));
|
||||
const priced = nextRows
|
||||
.map((row) => Number(row.vipPrice))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0);
|
||||
const baseVip = nextRows.find((row) => isBaseRow(row))?.vipPrice;
|
||||
nextVipPrice = baseVip
|
||||
? Number(baseVip) || null
|
||||
: priced.length
|
||||
? Math.min(...priced)
|
||||
: null;
|
||||
if (hadVariants) {
|
||||
nextVariants = toVariantPayload(nextRows);
|
||||
}
|
||||
} else if (hadVariants) {
|
||||
nextVariants = toVariantPayload(
|
||||
(vipProduct.variantPrices ?? []).map((row) => ({
|
||||
...row,
|
||||
vipPrice: "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateProduct(vipProduct.id, {
|
||||
vipEnabled: enabled,
|
||||
vipOnly: only,
|
||||
vipSpecialPrice: special,
|
||||
vipPrice: special ? nextVipPrice : null,
|
||||
...(nextVariants ? { variants: nextVariants } : {}),
|
||||
});
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
if (detailProduct?.id === updated.id) setDetailProduct(updated);
|
||||
closeVip();
|
||||
} catch (err) {
|
||||
setVipError(
|
||||
err instanceof ApiError ? err.message : "ذخیره دسترسی VIP ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setVipSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRow(
|
||||
id: string,
|
||||
field: keyof Omit<ProductVariantPrice, "id">,
|
||||
@@ -483,6 +585,16 @@ export default function ProductListPage() {
|
||||
>
|
||||
<DiscountIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="دسترسی VIP"
|
||||
variant="ghost"
|
||||
className={
|
||||
product.vipEnabled ? "customer-sub-premium" : undefined
|
||||
}
|
||||
onClick={() => openVip(product)}
|
||||
>
|
||||
<SubscriptionIcon premium={Boolean(product.vipEnabled)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -845,42 +957,188 @@ export default function ProductListPage() {
|
||||
: "بدون تنوع";
|
||||
|
||||
return (
|
||||
<div key={row.id} className="variant-price-row">
|
||||
<div className="mb-2 text-sm text-ink">{label}</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="clothes-field">
|
||||
<span className="block text-xs text-muted">قیمت فعلی</span>
|
||||
<p className="mt-1.5 text-sm text-ink">
|
||||
<span className="font-montserrat">
|
||||
{Number.isNaN(currentPrice)
|
||||
? row.price || "—"
|
||||
: formatPrice(currentPrice)}
|
||||
</span>
|
||||
{!Number.isNaN(currentPrice) && row.price ? (
|
||||
<span className="ms-1">تومان</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`disc-new-${row.id}`}>قیمت تخفیف</label>
|
||||
<input
|
||||
id={`disc-new-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.discountPrice ?? ""}
|
||||
onChange={(e) =>
|
||||
updateDiscountPrice(row.id, e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
/>
|
||||
</div>
|
||||
<div key={row.id} className="variant-price-row variant-price-row-inline">
|
||||
<div className="variant-price-label">{label}</div>
|
||||
<div className="clothes-field">
|
||||
<span className="block text-xs text-muted">قیمت فعلی</span>
|
||||
<p className="text-sm text-ink">
|
||||
<span className="font-montserrat">
|
||||
{Number.isNaN(currentPrice)
|
||||
? row.price || "—"
|
||||
: formatPrice(currentPrice)}
|
||||
</span>
|
||||
{!Number.isNaN(currentPrice) && row.price ? (
|
||||
<span className="ms-1">تومان</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`disc-new-${row.id}`}>قیمت تخفیف</label>
|
||||
<input
|
||||
id={`disc-new-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.discountPrice ?? ""}
|
||||
onChange={(e) =>
|
||||
updateDiscountPrice(row.id, e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(vipProduct)}
|
||||
title={`دسترسی VIP — ${vipProduct?.nameFa ?? ""}`}
|
||||
onClose={closeVip}
|
||||
onSubmit={(e) => void saveVip(e)}
|
||||
submitLabel={vipSaving ? "..." : "ذخیره"}
|
||||
wide="xl"
|
||||
>
|
||||
{vipError ? (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{vipError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label className="mana-switch">
|
||||
<span className="mana-switch-copy">
|
||||
<span className="mana-switch-title">فعالسازی VIP</span>
|
||||
<span className="mana-switch-hint">
|
||||
{vipEnabled
|
||||
? "تنظیمات VIP برای این محصول فعال است."
|
||||
: "برای تنظیم دسترسی و قیمت VIP، این گزینه را روشن کنید."}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="mana-switch-track"
|
||||
data-on={vipEnabled ? "true" : "false"}
|
||||
>
|
||||
<span className="mana-switch-thumb" />
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={vipEnabled}
|
||||
checked={vipEnabled}
|
||||
onChange={(e) => setVipEnabled(e.target.checked)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="vip-modal-options"
|
||||
data-disabled={vipEnabled ? "false" : "true"}
|
||||
aria-disabled={!vipEnabled}
|
||||
>
|
||||
<label className="mana-switch">
|
||||
<span className="mana-switch-copy">
|
||||
<span className="mana-switch-title">
|
||||
نمایش ندادن به مشتری عادی
|
||||
</span>
|
||||
<span className="mana-switch-hint">
|
||||
محصول اصلاً برای مشتریان عادی نمایش داده نمیشود.
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="mana-switch-track"
|
||||
data-on={vipOnly ? "true" : "false"}
|
||||
>
|
||||
<span className="mana-switch-thumb" />
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={vipOnly}
|
||||
checked={vipOnly}
|
||||
disabled={!vipEnabled}
|
||||
onChange={(e) => setVipOnly(e.target.checked)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="mana-switch">
|
||||
<span className="mana-switch-copy">
|
||||
<span className="mana-switch-title">قیمت ویژه VIP</span>
|
||||
<span className="mana-switch-hint">
|
||||
برای اعضای VIP قیمت جداگانه تعریف کنید.
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="mana-switch-track"
|
||||
data-on={vipSpecialPrice ? "true" : "false"}
|
||||
>
|
||||
<span className="mana-switch-thumb" />
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked={vipSpecialPrice}
|
||||
checked={vipSpecialPrice}
|
||||
disabled={!vipEnabled}
|
||||
onChange={(e) => setVipSpecialPrice(e.target.checked)}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{vipSpecialPrice ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
تنوعهای موجود و قیمت فعلی را ببینید و قیمت VIP را وارد کنید.
|
||||
</p>
|
||||
{vipRows.map((row) => {
|
||||
const currentPrice = Number(row.price);
|
||||
const label = vipProduct
|
||||
? getVariantLabel(
|
||||
row,
|
||||
vipProduct.categoryId,
|
||||
categoryTree,
|
||||
)
|
||||
: "بدون تنوع";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className="variant-price-row variant-price-row-inline"
|
||||
>
|
||||
<div className="variant-price-label">{label}</div>
|
||||
<div className="clothes-field">
|
||||
<span className="block text-xs text-muted">قیمت فعلی</span>
|
||||
<p className="text-sm text-ink">
|
||||
<span className="font-montserrat">
|
||||
{Number.isNaN(currentPrice)
|
||||
? row.price || "—"
|
||||
: formatPrice(currentPrice)}
|
||||
</span>
|
||||
{!Number.isNaN(currentPrice) && row.price ? (
|
||||
<span className="ms-1">تومان</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`vip-price-${row.id}`}>قیمت VIP</label>
|
||||
<input
|
||||
id={`vip-price-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.vipPrice ?? ""}
|
||||
disabled={!vipEnabled}
|
||||
onChange={(e) =>
|
||||
updateVipPrice(row.id, e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+193
-32
@@ -7,6 +7,7 @@
|
||||
--muted: #6b6b6b;
|
||||
--line: #e4e4e4;
|
||||
--ink: #0a0a0a;
|
||||
--radius-card: 4px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -297,7 +298,7 @@ body {
|
||||
justify-content: space-between;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
padding: 1.15rem 1rem;
|
||||
align-items: center;
|
||||
@@ -366,7 +367,7 @@ body {
|
||||
}
|
||||
|
||||
.dash-chart {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* Clothes admin */
|
||||
@@ -378,7 +379,7 @@ body {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
padding: 0.65rem 1rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
transition:
|
||||
color 0.35s ease,
|
||||
background-color 0.35s ease,
|
||||
@@ -411,7 +412,7 @@ body {
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ink);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
@@ -445,7 +446,7 @@ body {
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.35rem 0;
|
||||
@@ -533,7 +534,7 @@ body {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
transition:
|
||||
border-color 0.3s ease,
|
||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
@@ -548,7 +549,7 @@ body {
|
||||
|
||||
.product-card-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@@ -566,7 +567,7 @@ body {
|
||||
.tree-box {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -583,7 +584,7 @@ body {
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.85rem 0.95rem;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
@@ -612,7 +613,7 @@ body {
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
@@ -637,7 +638,7 @@ body {
|
||||
content: attr(data-tooltip);
|
||||
bottom: calc(100% + 8px);
|
||||
white-space: nowrap;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
@@ -702,7 +703,7 @@ body {
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
@@ -726,7 +727,7 @@ body {
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.85rem;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
@@ -741,7 +742,7 @@ body {
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.9rem 1rem;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
@@ -789,7 +790,7 @@ body {
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.75rem;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
@@ -804,7 +805,7 @@ body {
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #f3f3f1;
|
||||
}
|
||||
|
||||
@@ -816,7 +817,7 @@ body {
|
||||
|
||||
.rich-text {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -878,7 +879,7 @@ body {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
@@ -908,7 +909,7 @@ body {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
@@ -960,7 +961,7 @@ body {
|
||||
max-height: min(92dvh, 40rem);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.16);
|
||||
animation: modal-in 0.22s ease;
|
||||
@@ -1014,7 +1015,7 @@ body {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
@@ -1036,7 +1037,7 @@ body {
|
||||
|
||||
.modal-btn-ghost,
|
||||
.modal-btn-solid {
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.55rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
@@ -1092,7 +1093,7 @@ body {
|
||||
z-index: 1;
|
||||
width: min(100%, 22rem);
|
||||
border: 1px solid #e8d0d2;
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
padding: 1.25rem 1.15rem 1.1rem;
|
||||
box-shadow: 0 24px 60px rgba(120, 10, 20, 0.22);
|
||||
@@ -1123,7 +1124,7 @@ body {
|
||||
.confirm-btn-danger {
|
||||
min-width: 5.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -1165,7 +1166,7 @@ body {
|
||||
width: 4.5rem;
|
||||
height: 4.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px dashed var(--line);
|
||||
background: #f3f3f1;
|
||||
flex-shrink: 0;
|
||||
@@ -1189,7 +1190,7 @@ body {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.7rem 1.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
@@ -1273,7 +1274,7 @@ body {
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
transition: aspect-ratio 0.25s ease;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
@@ -1307,14 +1308,14 @@ body {
|
||||
|
||||
.address-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.variant-type-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
padding: 0.75rem;
|
||||
background: #fafaf8;
|
||||
}
|
||||
@@ -1391,11 +1392,40 @@ body {
|
||||
|
||||
.variant-price-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.variant-price-row-inline {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.variant-price-row-inline {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.variant-price-row-inline .variant-price-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.variant-price-row-inline .clothes-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.variant-price-row-inline .clothes-field label,
|
||||
.variant-price-row-inline .clothes-field > span {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.variant-price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1480,7 +1510,7 @@ body {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font-size: 1.1rem;
|
||||
@@ -1517,7 +1547,7 @@ body {
|
||||
.gallery-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1543,3 +1573,134 @@ body {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mana-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
padding: 0.95rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mana-switch-copy {
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.mana-switch-title {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mana-switch-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mana-switch-track {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 3.1rem;
|
||||
height: 1.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: #f3f3f1;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.mana-switch-track[data-on="true"] {
|
||||
border-color: var(--ink);
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.mana-switch-track::before,
|
||||
.mana-switch-track::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
transform: translateY(-50%);
|
||||
font-family: var(--font-montserrat), system-ui, sans-serif;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.mana-switch-track::before {
|
||||
content: "0";
|
||||
inset-inline-start: 0.35rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mana-switch-track::after {
|
||||
content: "1";
|
||||
inset-inline-end: 0.4rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mana-switch-track[data-on="true"]::before {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.mana-switch-track[data-on="true"]::after {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mana-switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
inset-inline-start: 2px;
|
||||
z-index: 1;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border-radius: calc(var(--radius-card) - 1px);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
transition: inset-inline-start 0.2s ease;
|
||||
}
|
||||
|
||||
.mana-switch-track[data-on="true"] .mana-switch-thumb {
|
||||
inset-inline-start: calc(100% - 1.15rem - 2px);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.mana-switch input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vip-modal-options {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
filter 0.25s ease,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.vip-modal-options[data-disabled="true"] {
|
||||
filter: blur(2.5px);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vip-modal-options[data-disabled="true"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormModal } from "@/components/ui/Modal";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
type ChangePasswordModalProps = {
|
||||
open: boolean;
|
||||
userName?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ChangePasswordModal({
|
||||
open,
|
||||
userName,
|
||||
onClose,
|
||||
}: ChangePasswordModalProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setError(null);
|
||||
setSaving(false);
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const next = password.trim();
|
||||
if (next.length < 4) {
|
||||
setError("رمز عبور باید حداقل ۴ کاراکتر باشد");
|
||||
return;
|
||||
}
|
||||
if (next !== confirmPassword) {
|
||||
setError("تکرار رمز عبور مطابقت ندارد");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api("/auth/password", {
|
||||
method: "PATCH",
|
||||
body: { password: next },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "تغییر رمز عبور ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
open={open}
|
||||
title="تغییر رمز عبور"
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : "ذخیره"}
|
||||
>
|
||||
{userName ? (
|
||||
<p className="text-sm text-muted">{userName}</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="change-password-new">رمز عبور جدید</label>
|
||||
<input
|
||||
id="change-password-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
className="!text-end"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="change-password-confirm">تکرار رمز عبور جدید</label>
|
||||
<input
|
||||
id="change-password-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
className="!text-end"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ChangePasswordModal from "@/components/dashboard/ChangePasswordModal";
|
||||
import ManaLogo from "@/components/ui/ManaLogo";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
@@ -18,6 +19,7 @@ export default function DashboardHeader({
|
||||
const displayName = userName ?? user?.name ?? "کاربر";
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [userOpen, setUserOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
const userRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,15 +89,18 @@ export default function DashboardHeader({
|
||||
<UserIcon />
|
||||
پروفایل
|
||||
</Link>
|
||||
<Link
|
||||
href="/change-password"
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="dash-menu-item"
|
||||
onClick={() => setUserOpen(false)}
|
||||
className="dash-menu-item w-full"
|
||||
onClick={() => {
|
||||
setUserOpen(false);
|
||||
setPasswordOpen(true);
|
||||
}}
|
||||
>
|
||||
<KeyIcon />
|
||||
تغییر رمز عبور
|
||||
</Link>
|
||||
</button>
|
||||
<div className="h-px bg-line" />
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,6 +141,12 @@ export default function DashboardHeader({
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<ChangePasswordModal
|
||||
open={passwordOpen}
|
||||
userName={displayName}
|
||||
onClose={() => setPasswordOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,22 +115,16 @@ export function AddChildIcon() {
|
||||
}
|
||||
|
||||
export function SubscriptionIcon({ premium = false }: { premium?: boolean }) {
|
||||
if (premium) {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
{...stroke}
|
||||
d="M5.5 9.5 8.2 12l3.8-5.5 3.8 5.5 2.7-2.5V17.5H5.5V9.5Z"
|
||||
/>
|
||||
<path {...stroke} d="M5.5 17.5h13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
|
||||
<circle {...stroke} cx="12" cy="9" r="3.2" />
|
||||
<path {...stroke} d="M6.5 18.5c1.4-2.6 3.3-3.9 5.5-3.9s4.1 1.3 5.5 3.9" />
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill={premium ? "currentColor" : "none"}
|
||||
d="M12 4.5 13.9 9.2l5.1.4-3.9 3.3 1.2 4.9L12 15.4 7.7 17.8l1.2-4.9-3.9-3.3 5.1-.4L12 4.5Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ProductVariantPrice = {
|
||||
packageId: string;
|
||||
price: string;
|
||||
discountPrice?: string;
|
||||
vipPrice?: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
@@ -41,6 +42,10 @@ export type Product = {
|
||||
image: string;
|
||||
gallery?: string[];
|
||||
tags: string[];
|
||||
vipEnabled?: boolean;
|
||||
vipOnly?: boolean;
|
||||
vipSpecialPrice?: boolean;
|
||||
vipPrice?: number;
|
||||
variantPrices?: ProductVariantPrice[];
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type ApiProductVariant = {
|
||||
packageId: string;
|
||||
price: number;
|
||||
discountPrice: number | null;
|
||||
vipPrice: number | null;
|
||||
};
|
||||
|
||||
export type ApiProduct = {
|
||||
@@ -28,6 +29,10 @@ export type ApiProduct = {
|
||||
imageUrl: string | null;
|
||||
galleryUrls?: string[];
|
||||
tags: string[];
|
||||
vipEnabled?: boolean;
|
||||
vipOnly?: boolean;
|
||||
vipSpecialPrice?: boolean;
|
||||
vipPrice?: number | null;
|
||||
variants: ApiProductVariant[];
|
||||
};
|
||||
|
||||
@@ -52,6 +57,7 @@ export function mapVariant(v: ApiProductVariant): ProductVariantPrice {
|
||||
price: String(v.price ?? ""),
|
||||
discountPrice:
|
||||
v.discountPrice != null ? String(v.discountPrice) : undefined,
|
||||
vipPrice: v.vipPrice != null ? String(v.vipPrice) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +75,10 @@ export function mapProduct(p: ApiProduct): Product {
|
||||
image: p.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
|
||||
gallery: p.galleryUrls ?? [],
|
||||
tags: p.tags ?? [],
|
||||
vipEnabled: Boolean(p.vipEnabled),
|
||||
vipOnly: Boolean(p.vipOnly),
|
||||
vipSpecialPrice: Boolean(p.vipSpecialPrice),
|
||||
vipPrice: p.vipPrice ?? undefined,
|
||||
variantPrices: (p.variants ?? []).map(mapVariant),
|
||||
};
|
||||
}
|
||||
@@ -83,6 +93,7 @@ export function toVariantPayload(rows: ProductVariantPrice[]) {
|
||||
discountPrice: row.discountPrice
|
||||
? Number(row.discountPrice) || undefined
|
||||
: undefined,
|
||||
vipPrice: row.vipPrice ? Number(row.vipPrice) || undefined : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user