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
+262 -4
View File
@@ -8,6 +8,7 @@ import IconButton, {
DiscountIcon, DiscountIcon,
EditIcon, EditIcon,
FilterIcon, FilterIcon,
SubscriptionIcon,
TrashIcon, TrashIcon,
VariantIcon, VariantIcon,
} from "@/components/ui/IconButton"; } from "@/components/ui/IconButton";
@@ -94,6 +95,14 @@ export default function ProductListPage() {
const [discountProduct, setDiscountProduct] = useState<Product | null>(null); const [discountProduct, setDiscountProduct] = useState<Product | null>(null);
const [discountRows, setDiscountRows] = useState<ProductVariantPrice[]>([]); 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 () => { const reload = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
@@ -178,6 +187,99 @@ export default function ProductListPage() {
setDiscountProduct(null); 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( function updateRow(
id: string, id: string,
field: keyof Omit<ProductVariantPrice, "id">, field: keyof Omit<ProductVariantPrice, "id">,
@@ -483,6 +585,16 @@ export default function ProductListPage() {
> >
<DiscountIcon /> <DiscountIcon />
</IconButton> </IconButton>
<IconButton
label="دسترسی VIP"
variant="ghost"
className={
product.vipEnabled ? "customer-sub-premium" : undefined
}
onClick={() => openVip(product)}
>
<SubscriptionIcon premium={Boolean(product.vipEnabled)} />
</IconButton>
</div> </div>
</article> </article>
); );
@@ -845,12 +957,11 @@ export default function ProductListPage() {
: "بدون تنوع"; : "بدون تنوع";
return ( return (
<div key={row.id} className="variant-price-row"> <div key={row.id} className="variant-price-row variant-price-row-inline">
<div className="mb-2 text-sm text-ink">{label}</div> <div className="variant-price-label">{label}</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="clothes-field"> <div className="clothes-field">
<span className="block text-xs text-muted">قیمت فعلی</span> <span className="block text-xs text-muted">قیمت فعلی</span>
<p className="mt-1.5 text-sm text-ink"> <p className="text-sm text-ink">
<span className="font-montserrat"> <span className="font-montserrat">
{Number.isNaN(currentPrice) {Number.isNaN(currentPrice)
? row.price || "—" ? row.price || "—"
@@ -876,10 +987,157 @@ export default function ProductListPage() {
/> />
</div> </div>
</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>
); );
})} })}
</div> </div>
) : null}
</div>
</FormModal> </FormModal>
</> </>
); );
+193 -32
View File
@@ -7,6 +7,7 @@
--muted: #6b6b6b; --muted: #6b6b6b;
--line: #e4e4e4; --line: #e4e4e4;
--ink: #0a0a0a; --ink: #0a0a0a;
--radius-card: 4px;
} }
@theme inline { @theme inline {
@@ -297,7 +298,7 @@ body {
justify-content: space-between; justify-content: space-between;
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: var(--surface); background: var(--surface);
padding: 1.15rem 1rem; padding: 1.15rem 1rem;
align-items: center; align-items: center;
@@ -366,7 +367,7 @@ body {
} }
.dash-chart { .dash-chart {
border-radius: 8px; border-radius: var(--radius-card);
} }
/* Clothes admin */ /* Clothes admin */
@@ -378,7 +379,7 @@ body {
background: var(--ink); background: var(--ink);
color: #fff; color: #fff;
padding: 0.65rem 1rem; padding: 0.65rem 1rem;
border-radius: 8px; border-radius: var(--radius-card);
transition: transition:
color 0.35s ease, color 0.35s ease,
background-color 0.35s ease, background-color 0.35s ease,
@@ -411,7 +412,7 @@ body {
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--ink); color: var(--ink);
border-radius: 6px; border-radius: var(--radius-card);
outline: none; outline: none;
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
} }
@@ -445,7 +446,7 @@ body {
max-height: 16rem; max-height: 16rem;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: #fff; background: #fff;
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.1); box-shadow: 0 14px 36px rgba(0, 0, 0, 0.1);
padding: 0.35rem 0; padding: 0.35rem 0;
@@ -533,7 +534,7 @@ body {
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface); background: var(--surface);
border-radius: 8px; border-radius: var(--radius-card);
transition: transition:
border-color 0.3s ease, border-color 0.3s ease,
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
@@ -548,7 +549,7 @@ body {
.product-card-actions { .product-card-actions {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(5, 1fr);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
@@ -566,7 +567,7 @@ body {
.tree-box { .tree-box {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface); background: var(--surface);
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.75rem; padding: 0.75rem;
} }
@@ -583,7 +584,7 @@ body {
gap: 1rem; gap: 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.85rem 0.95rem; padding: 0.85rem 0.95rem;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
} }
@@ -612,7 +613,7 @@ body {
justify-content: center; justify-content: center;
width: 2.75rem; width: 2.75rem;
height: 2.75rem; height: 2.75rem;
border-radius: 6px; border-radius: var(--radius-card);
cursor: pointer; cursor: pointer;
transition: transition:
color 0.2s ease, color 0.2s ease,
@@ -637,7 +638,7 @@ body {
content: attr(data-tooltip); content: attr(data-tooltip);
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
white-space: nowrap; white-space: nowrap;
border-radius: 6px; border-radius: var(--radius-card);
background: var(--ink); background: var(--ink);
color: #fff; color: #fff;
font-size: 0.7rem; font-size: 0.7rem;
@@ -702,7 +703,7 @@ body {
gap: 0.5rem; gap: 0.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface); background: var(--surface);
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.65rem; padding: 0.65rem;
} }
@@ -726,7 +727,7 @@ body {
gap: 1rem; gap: 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.85rem; padding: 0.85rem;
transition: border-color 0.25s ease; transition: border-color 0.25s ease;
} }
@@ -741,7 +742,7 @@ body {
gap: 1rem; gap: 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
transition: border-color 0.25s ease; transition: border-color 0.25s ease;
} }
@@ -789,7 +790,7 @@ body {
gap: 1rem; gap: 1rem;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.75rem; padding: 0.75rem;
transition: border-color 0.25s ease; transition: border-color 0.25s ease;
} }
@@ -804,7 +805,7 @@ body {
flex-shrink: 0; flex-shrink: 0;
aspect-ratio: 3 / 2; aspect-ratio: 3 / 2;
overflow: hidden; overflow: hidden;
border-radius: 6px; border-radius: var(--radius-card);
background: #f3f3f1; background: #f3f3f1;
} }
@@ -816,7 +817,7 @@ body {
.rich-text { .rich-text {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: #fff; background: #fff;
overflow: hidden; overflow: hidden;
} }
@@ -878,7 +879,7 @@ body {
display: block; display: block;
max-width: 100%; max-width: 100%;
margin: 0.75rem 0; margin: 0.75rem 0;
border-radius: 6px; border-radius: var(--radius-card);
} }
.pagination { .pagination {
@@ -908,7 +909,7 @@ body {
height: 2.25rem; height: 2.25rem;
padding: 0 0.7rem; padding: 0 0.7rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: var(--radius-card);
background: #fff; background: #fff;
color: var(--muted); color: var(--muted);
font-size: 0.8rem; font-size: 0.8rem;
@@ -960,7 +961,7 @@ body {
max-height: min(92dvh, 40rem); max-height: min(92dvh, 40rem);
overflow: hidden; overflow: hidden;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 10px; border-radius: var(--radius-card);
background: #fff; background: #fff;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.16); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.16);
animation: modal-in 0.22s ease; animation: modal-in 0.22s ease;
@@ -1014,7 +1015,7 @@ body {
font-size: 1.35rem; font-size: 1.35rem;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: var(--radius-card);
} }
.modal-close:hover { .modal-close:hover {
@@ -1036,7 +1037,7 @@ body {
.modal-btn-ghost, .modal-btn-ghost,
.modal-btn-solid { .modal-btn-solid {
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.55rem 1rem; padding: 0.55rem 1rem;
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
@@ -1092,7 +1093,7 @@ body {
z-index: 1; z-index: 1;
width: min(100%, 22rem); width: min(100%, 22rem);
border: 1px solid #e8d0d2; border: 1px solid #e8d0d2;
border-radius: 10px; border-radius: var(--radius-card);
background: #fff; background: #fff;
padding: 1.25rem 1.15rem 1.1rem; padding: 1.25rem 1.15rem 1.1rem;
box-shadow: 0 24px 60px rgba(120, 10, 20, 0.22); box-shadow: 0 24px 60px rgba(120, 10, 20, 0.22);
@@ -1123,7 +1124,7 @@ body {
.confirm-btn-danger { .confirm-btn-danger {
min-width: 5.5rem; min-width: 5.5rem;
padding: 0.55rem 1rem; padding: 0.55rem 1rem;
border-radius: 6px; border-radius: var(--radius-card);
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -1165,7 +1166,7 @@ body {
width: 4.5rem; width: 4.5rem;
height: 4.5rem; height: 4.5rem;
overflow: hidden; overflow: hidden;
border-radius: 8px; border-radius: var(--radius-card);
border: 1px dashed var(--line); border: 1px dashed var(--line);
background: #f3f3f1; background: #f3f3f1;
flex-shrink: 0; flex-shrink: 0;
@@ -1189,7 +1190,7 @@ body {
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.7rem 1.35rem; padding: 0.7rem 1.35rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
@@ -1273,7 +1274,7 @@ body {
0 8px, 0 8px,
8px -8px, 8px -8px,
-8px 0; -8px 0;
border-radius: 8px; border-radius: var(--radius-card);
transition: aspect-ratio 0.25s ease; transition: aspect-ratio 0.25s ease;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
@@ -1307,14 +1308,14 @@ body {
.address-block { .address-block {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: #fff; background: #fff;
padding: 0.9rem; padding: 0.9rem;
} }
.variant-type-block { .variant-type-block {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
padding: 0.75rem; padding: 0.75rem;
background: #fafaf8; background: #fafaf8;
} }
@@ -1391,11 +1392,40 @@ body {
.variant-price-row { .variant-price-row {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: #fff; background: #fff;
padding: 0.75rem; 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 { .variant-price-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1480,7 +1510,7 @@ body {
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 6px; border-radius: var(--radius-card);
background: #fff; background: #fff;
color: var(--ink); color: var(--ink);
font-size: 1.1rem; font-size: 1.1rem;
@@ -1517,7 +1547,7 @@ body {
.gallery-slot { .gallery-slot {
aspect-ratio: 1; aspect-ratio: 1;
border: 1px dashed var(--line); border: 1px dashed var(--line);
border-radius: 8px; border-radius: var(--radius-card);
background: #fff; background: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1543,3 +1573,134 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--ink); 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 Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import ChangePasswordModal from "@/components/dashboard/ChangePasswordModal";
import ManaLogo from "@/components/ui/ManaLogo"; import ManaLogo from "@/components/ui/ManaLogo";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
@@ -18,6 +19,7 @@ export default function DashboardHeader({
const displayName = userName ?? user?.name ?? "کاربر"; const displayName = userName ?? user?.name ?? "کاربر";
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [userOpen, setUserOpen] = useState(false); const [userOpen, setUserOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const userRef = useRef<HTMLDivElement>(null); const userRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -87,15 +89,18 @@ export default function DashboardHeader({
<UserIcon /> <UserIcon />
پروفایل پروفایل
</Link> </Link>
<Link <button
href="/change-password" type="button"
role="menuitem" role="menuitem"
className="dash-menu-item" className="dash-menu-item w-full"
onClick={() => setUserOpen(false)} onClick={() => {
setUserOpen(false);
setPasswordOpen(true);
}}
> >
<KeyIcon /> <KeyIcon />
تغییر رمز عبور تغییر رمز عبور
</Link> </button>
<div className="h-px bg-line" /> <div className="h-px bg-line" />
<button <button
type="button" type="button"
@@ -136,6 +141,12 @@ export default function DashboardHeader({
</div> </div>
</nav> </nav>
)} )}
<ChangePasswordModal
open={passwordOpen}
userName={displayName}
onClose={() => setPasswordOpen(false)}
/>
</> </>
); );
} }
+6 -12
View File
@@ -115,22 +115,16 @@ export function AddChildIcon() {
} }
export function SubscriptionIcon({ premium = false }: { premium?: boolean }) { export function SubscriptionIcon({ premium = false }: { premium?: boolean }) {
if (premium) {
return ( return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden> <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
<path <path
{...stroke} stroke="currentColor"
d="M5.5 9.5 8.2 12l3.8-5.5 3.8 5.5 2.7-2.5V17.5H5.5V9.5Z" 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"
/> />
<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" />
</svg> </svg>
); );
} }
+5
View File
@@ -26,6 +26,7 @@ export type ProductVariantPrice = {
packageId: string; packageId: string;
price: string; price: string;
discountPrice?: string; discountPrice?: string;
vipPrice?: string;
}; };
export type Product = { export type Product = {
@@ -41,6 +42,10 @@ export type Product = {
image: string; image: string;
gallery?: string[]; gallery?: string[];
tags: string[]; tags: string[];
vipEnabled?: boolean;
vipOnly?: boolean;
vipSpecialPrice?: boolean;
vipPrice?: number;
variantPrices?: ProductVariantPrice[]; variantPrices?: ProductVariantPrice[];
}; };
+11
View File
@@ -13,6 +13,7 @@ export type ApiProductVariant = {
packageId: string; packageId: string;
price: number; price: number;
discountPrice: number | null; discountPrice: number | null;
vipPrice: number | null;
}; };
export type ApiProduct = { export type ApiProduct = {
@@ -28,6 +29,10 @@ export type ApiProduct = {
imageUrl: string | null; imageUrl: string | null;
galleryUrls?: string[]; galleryUrls?: string[];
tags: string[]; tags: string[];
vipEnabled?: boolean;
vipOnly?: boolean;
vipSpecialPrice?: boolean;
vipPrice?: number | null;
variants: ApiProductVariant[]; variants: ApiProductVariant[];
}; };
@@ -52,6 +57,7 @@ export function mapVariant(v: ApiProductVariant): ProductVariantPrice {
price: String(v.price ?? ""), price: String(v.price ?? ""),
discountPrice: discountPrice:
v.discountPrice != null ? String(v.discountPrice) : undefined, 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", image: p.imageUrl || "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
gallery: p.galleryUrls ?? [], gallery: p.galleryUrls ?? [],
tags: p.tags ?? [], tags: p.tags ?? [],
vipEnabled: Boolean(p.vipEnabled),
vipOnly: Boolean(p.vipOnly),
vipSpecialPrice: Boolean(p.vipSpecialPrice),
vipPrice: p.vipPrice ?? undefined,
variantPrices: (p.variants ?? []).map(mapVariant), variantPrices: (p.variants ?? []).map(mapVariant),
}; };
} }
@@ -83,6 +93,7 @@ export function toVariantPayload(rows: ProductVariantPrice[]) {
discountPrice: row.discountPrice discountPrice: row.discountPrice
? Number(row.discountPrice) || undefined ? Number(row.discountPrice) || undefined
: undefined, : undefined,
vipPrice: row.vipPrice ? Number(row.vipPrice) || undefined : undefined,
})); }));
} }