diff --git a/src/app/clothes/list/page.tsx b/src/app/clothes/list/page.tsx index 7ffe933..4cd4ef2 100644 --- a/src/app/clothes/list/page.tsx +++ b/src/app/clothes/list/page.tsx @@ -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(null); const [discountRows, setDiscountRows] = useState([]); + const [vipProduct, setVipProduct] = useState(null); + const [vipEnabled, setVipEnabled] = useState(false); + const [vipOnly, setVipOnly] = useState(false); + const [vipSpecialPrice, setVipSpecialPrice] = useState(false); + const [vipRows, setVipRows] = useState([]); + const [vipSaving, setVipSaving] = useState(false); + const [vipError, setVipError] = useState(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) { + 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 | 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, @@ -483,6 +585,16 @@ export default function ProductListPage() { > + openVip(product)} + > + + ); @@ -845,42 +957,188 @@ export default function ProductListPage() { : "بدون تنوع"; return ( -
-
{label}
-
-
- قیمت فعلی -

- - {Number.isNaN(currentPrice) - ? row.price || "—" - : formatPrice(currentPrice)} - - {!Number.isNaN(currentPrice) && row.price ? ( - تومان - ) : null} -

-
-
- - - updateDiscountPrice(row.id, e.target.value) - } - placeholder="3570000" - /> -
+
+
{label}
+
+ قیمت فعلی +

+ + {Number.isNaN(currentPrice) + ? row.price || "—" + : formatPrice(currentPrice)} + + {!Number.isNaN(currentPrice) && row.price ? ( + تومان + ) : null} +

+
+
+ + + updateDiscountPrice(row.id, e.target.value) + } + placeholder="3570000" + />
); })}
+ + void saveVip(e)} + submitLabel={vipSaving ? "..." : "ذخیره"} + wide="xl" + > + {vipError ? ( +

+ {vipError} +

+ ) : null} + + + +
+ + + + + {vipSpecialPrice ? ( +
+

+ تنوع‌های موجود و قیمت فعلی را ببینید و قیمت VIP را وارد کنید. +

+ {vipRows.map((row) => { + const currentPrice = Number(row.price); + const label = vipProduct + ? getVariantLabel( + row, + vipProduct.categoryId, + categoryTree, + ) + : "بدون تنوع"; + + return ( +
+
{label}
+
+ قیمت فعلی +

+ + {Number.isNaN(currentPrice) + ? row.price || "—" + : formatPrice(currentPrice)} + + {!Number.isNaN(currentPrice) && row.price ? ( + تومان + ) : null} +

+
+
+ + + updateVipPrice(row.id, e.target.value) + } + placeholder="3570000" + /> +
+
+ ); + })} +
+ ) : null} +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index a5514fb..558c5e8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} + diff --git a/src/components/dashboard/ChangePasswordModal.tsx b/src/components/dashboard/ChangePasswordModal.tsx new file mode 100644 index 0000000..4a2f111 --- /dev/null +++ b/src/components/dashboard/ChangePasswordModal.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) return; + setPassword(""); + setConfirmPassword(""); + setError(null); + setSaving(false); + }, [open]); + + async function handleSubmit(event: FormEvent) { + 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 ( + + {userName ? ( +

{userName}

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ + setPassword(e.target.value)} + required + minLength={4} + autoFocus + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={4} + /> +
+
+ ); +} diff --git a/src/components/dashboard/DashboardHeader.tsx b/src/components/dashboard/DashboardHeader.tsx index b77bffc..29f9792 100644 --- a/src/components/dashboard/DashboardHeader.tsx +++ b/src/components/dashboard/DashboardHeader.tsx @@ -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(null); useEffect(() => { @@ -87,15 +89,18 @@ export default function DashboardHeader({ پروفایل - setUserOpen(false)} + className="dash-menu-item w-full" + onClick={() => { + setUserOpen(false); + setPasswordOpen(true); + }} > تغییر رمز عبور - +