Add password modal and VIP product controls on clothes list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-08 00:51:25 +03:30
co-authored by Cursor
parent 1041ab20c5
commit 4a516e31db
7 changed files with 630 additions and 81 deletions
+288 -30
View File
@@ -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
View File
@@ -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>
);
}
+16 -5
View File
@@ -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)}
/>
</>
);
}
+8 -14
View File
@@ -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>
);
}
+5
View File
@@ -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[];
};
+11
View File
@@ -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,
}));
}