Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
1fe7c41bdc Merge branch 'develop' into feature/WNPT-752 2026-09-02 12:51:04 +03:30
bae58c8548 Merge pull request 'feature/WNPT-735' (#6) from feature/WNPT-735 into feature/WNPT-752
Reviewed-on: #6
2026-09-02 05:06:11 -04:00
61b82b0fb2 Merge pull request 'feature/WNPT-740' (#3) from feature/WNPT-740 into develop
Reviewed-on: #3
2026-09-02 04:02:09 -04:00
Afra ‌
ef75ed1a0c feat: checkout page implemented 2026-09-02 10:40:48 +03:30
Afra ‌
08e4a1a98a feat: checkout page implemented 2026-09-02 10:40:24 +03:30
Afra ‌
f94ee531a3 feat: checout page implemented 2026-09-02 01:55:25 +03:30
Afra ‌
adf31dc999 perf: cart page modified 2026-09-02 01:30:30 +03:30
Afra ‌
64e321dc3d perf: store and product pages' bottom bar modified 2026-09-02 00:38:22 +03:30
Afra ‌
418a63734f perf: product detail page modified 2026-09-02 00:29:50 +03:30
89d2a090e1 Merge pull request 'perf: logger + store page modification' (#4) from feature/WNPT-734 into develop
Reviewed-on: #4
2026-09-01 02:53:21 -04:00
Afra ‌
cc928871cc perf: logger + store page modification 2026-08-31 16:03:11 +03:30
b0ce5ce2f9 feat(profile): implement account fetching and context provider for user profile
feat(wallet): enhance wallet UI and transaction item display
fix(profile): update profile detail card to handle loading and error states
style: improve UI consistency across profile and wallet components
2026-08-30 13:28:27 +03:30
cbd0b4bebe feat(profile): complete profile and wallet UI with actions
- Add ProfileLayout with session authentication guard
- Create ProfilePage and WalletPage screen components
- Implement reusable profile UI elements:
  * ProfileMenu and ProfileMenuItem for navigation
  * ProfileExitAccount with logout handler
  * ProfilePersonDetailCard displaying user info
  * ProfilePageHeadLine for section titles
- Implement wallet section:
  * WalletBlancCard showing balance with charge/withdraw buttons
  * TransactionHistory listing recent transactions
  * TransactionItem with dynamic deposit/withdraw styling
- Use client components for interactive behaviours (logout, navigation)
- Apply consistent styling using Tailwind CSS and Lucide icons
2026-08-29 14:19:29 +03:30
2cd5a9987d add priceUtils -- formatter price 2026-08-29 08:09:32 +03:30
2c6c8ad6e1 imp profile new design & wallet new design 2026-08-29 07:45:53 +03:30
42 changed files with 1413 additions and 441 deletions

149
package-lock.json generated
View file

@ -81,6 +81,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@ -290,28 +291,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -683,9 +662,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -702,9 +678,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -721,9 +694,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -740,9 +710,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -759,9 +726,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -778,9 +742,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -797,9 +758,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -816,9 +774,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@ -835,9 +790,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -860,9 +812,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -885,9 +834,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -910,9 +856,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -935,9 +878,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -960,9 +900,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -985,9 +922,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1010,9 +944,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@ -1280,9 +1211,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1299,9 +1227,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1318,9 +1243,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1337,9 +1259,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2282,9 +2201,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2302,9 +2218,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2322,9 +2235,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2342,9 +2252,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2506,6 +2413,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2516,6 +2424,7 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -2565,6 +2474,7 @@
"integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.67.0",
"@typescript-eslint/types": "8.67.0",
@ -2921,9 +2831,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2938,9 +2845,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2955,9 +2859,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -2972,9 +2873,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -2989,9 +2887,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3006,9 +2901,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3023,9 +2915,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3040,9 +2929,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3057,9 +2943,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -3074,9 +2957,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -3175,6 +3055,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3530,6 +3411,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@ -4146,6 +4028,7 @@
"integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -4331,6 +4214,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -5818,9 +5702,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5842,9 +5723,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5866,9 +5744,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -5890,9 +5765,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@ -6576,6 +6448,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -6585,6 +6458,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -7361,6 +7235,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7523,6 +7398,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7843,6 +7719,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -1,13 +1,13 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { CartItemRow } from "@/components/customer/cart-item-row";
import { StoreCartCard } from "@/components/customer/store-cart-card";
import { Button } from "@/components/ui/button";
export default async function CartPage() {
await requireSession("/cart");
const cart = await getCart();
const hasBlockedGroup = cart.groups.some((g) => !g.meets_minimum_order);
if (cart.groups.length === 0) {
return (
@ -20,55 +20,64 @@ export default async function CartPage() {
);
}
const totalItemLines = cart.groups.reduce(
(sum, g) => sum + g.items.length,
0,
);
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 py-6">
<h1 className="text-lg font-bold">سبد خرید</h1>
{cart.groups.map((group) => (
<section key={group.store.uuid} className="flex flex-col gap-2 rounded-2xl border border-border p-4">
<Link href={`/stores/${group.store.uuid}`} className="font-medium">
{group.store.name}
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left. */}
<div className="flex items-center justify-start gap-3">
<Link href="/" aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</Link>
<div className="divide-y divide-border">
{group.items.map((item) => (
<CartItemRow key={item.uuid} item={item} />
))}
</div>
<div className="flex flex-col gap-1 border-t border-border pt-2 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>جمع کالاها</span>
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>هزینه ارسال</span>
<span>{group.delivery_fee.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex justify-between font-semibold">
<span>مجموع</span>
<span>{group.total.toLocaleString("fa-IR")} تومان</span>
</div>
<h1 className="text-[18px] font-bold leading-none text-foreground">
سبد خرید
</h1>
</div>
<div className="flex flex-col gap-8">
{cart.groups.map((group) => (
<StoreCartCard key={group.store.uuid} group={group}>
{!group.meets_minimum_order && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
<p className="mt-3 rounded-lg bg-error/10 px-3 py-2 text-xs text-error">
حداقل مبلغ سفارش برای این فروشگاه رعایت نشده است.
</p>
)}
</section>
))}
<div className="sticky bottom-4 flex flex-col gap-2 rounded-2xl border border-border bg-background p-4 shadow-lg">
<div className="flex justify-between font-semibold">
<span>مبلغ قابل پرداخت</span>
<span>{cart.grand_total.toLocaleString("fa-IR")} تومان</span>
</div>
<Link href={hasBlockedGroup ? "#" : "/checkout"} aria-disabled={hasBlockedGroup}>
<Button size="lg" className="w-full" disabled={hasBlockedGroup}>
ادامه و ثبت سفارش
</Button>
<Link
href={group.meets_minimum_order ? `/checkout/${group.store.uuid}` : "#"}
aria-disabled={!group.meets_minimum_order}
className="mt-4 flex justify-center"
>
<button
type="button"
disabled={!group.meets_minimum_order}
className="w-full rounded-xl bg-primary-action px-8 py-3 text-[14px] font-bold text-white disabled:opacity-40"
>
تکمیل سفارش
</button>
</Link>
</StoreCartCard>
))}
</div>
<div className="flex flex-row justify-between border-t border-gray-200 pt-4 px-5">
<div className="flex flex-col items-start justify-start gap-2">
<span className="text-[14px] font-semibold text-foreground">
جمع کل سبد ({cart.groups.length.toLocaleString("fa-IR")} فروشگاه،{" "}
{totalItemLines.toLocaleString("fa-IR")} کالا)
</span>
<p className="text-end text-[12px] leading-normal text-gray-600">
هر فروشگاه جداگانه پرداخت و ارسال می‌شود
</p>
</div>
<span className="text-[16px] font-bold leading-none text-foreground">
{cart.grand_total.toLocaleString("fa-IR")} تومان
</span>
</div>
</main>
);

View file

@ -0,0 +1,35 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { getCart } from "@/lib/api/cart";
import { getAddresses } from "@/lib/api/addresses";
import { requireSession } from "@/lib/auth/session";
import { CheckoutPayment } from "@/components/customer/checkout-payment";
export default async function ConfirmPaymentPage({ params }: PageProps<"/checkout/[store]/confirm">) {
const { store } = await params;
await requireSession(`/checkout/${store}/confirm`);
const [cart, addresses] = await Promise.all([
getCart(),
// Known gap (FRONTEND_GUIDE.md §7): addresses GET can 403 on the current OAuth
// scope — fall back to empty rather than crashing this page over it.
getAddresses().catch(() => []),
]);
const group = cart.groups.find((g) => g.store.uuid === store);
if (!group) notFound();
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
{/* Same header pattern as /cart and /checkout/[store]. */}
<div className="flex items-center justify-start gap-3">
<Link href={`/checkout/${store}`} aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</Link>
<h1 className="text-[18px] font-bold leading-none text-foreground">تأیید و پرداخت</h1>
</div>
<CheckoutPayment group={group} addresses={addresses} />
</main>
);
}

View file

@ -0,0 +1,31 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowRight } from "lucide-react";
import { getCart } from "@/lib/api/cart";
import { requireSession } from "@/lib/auth/session";
import { StoreCartCard } from "@/components/customer/store-cart-card";
import { CheckoutFulfillment } from "@/components/customer/checkout-fulfillment";
export default async function StoreCheckoutPage({ params }: PageProps<"/checkout/[store]">) {
const { store } = await params;
await requireSession(`/checkout/${store}`);
const cart = await getCart();
const group = cart.groups.find((g) => g.store.uuid === store);
if (!group) notFound();
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-6 px-4 pt-4 pb-6">
{/* Same header as /cart, same DOM-order note: first child renders right, last left. */}
<div className="flex items-center justify-start gap-3">
<Link href="/cart" aria-label="بازگشت">
<ArrowRight className="h-5 w-5 text-foreground" />
</Link>
<h1 className="text-[18px] font-bold leading-none text-foreground">سبد خرید</h1>
</div>
<StoreCartCard group={group} />
<CheckoutFulfillment group={group} />
</main>
);
}

View file

@ -14,8 +14,8 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
if (!product) notFound();
return (
<main className="flex min-h-full flex-1 flex-col bg-white pb-[140px]">
<div className="relative h-[320px] w-full shrink-0 overflow-hidden bg-muted">
<main className="flex min-h-full flex-1 flex-col bg-white pb-35">
<div className="relative h-80 w-full shrink-0 overflow-hidden bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
@ -24,9 +24,9 @@ export default async function ProductDetailPage({ params }: PageProps<"/products
</div>
<div className="flex flex-col gap-5 px-5 pt-5">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-6">
<h1 className="text-[24px] font-bold leading-none text-foreground">{product.name}</h1>
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-semibold leading-none text-primary-action">
<Link href={`/stores/${product.store.uuid}`} className="w-fit text-[14px] font-medium leading-none text-gray-600">
{product.store.name}
</Link>
<p className="text-headline-medium font-extrabold leading-none text-primary-action">

View file

@ -0,0 +1,11 @@
import ProfileListMenu from "@/components/profile/profile-menu";
import ProfilePageHeadLine from "@/components/profile/profile-page-head-line";
import ProfilePersonDetailCard from "@/components/profile/profile-person-detail-card";
export default function ProfilePage() {
return <section>
<ProfilePageHeadLine title='پروفایل من' />
<ProfilePersonDetailCard />
<ProfileListMenu />
</section>
}

View file

@ -0,0 +1,16 @@
import ProfilePageHeadLine from '@/components/profile/profile-page-head-line'
import TransactionHistory from '@/components/wallet/transaction-history'
import WalletBlancCard from '@/components/wallet/wallet-blanc-card'
const WalletPage = () => {
return (
<section>
<ProfilePageHeadLine title='کیف پول من' />
<WalletBlancCard />
<ProfilePageHeadLine title='تاریخچه تراکنش‌ها' />
<TransactionHistory />
</section>
)
}
export default WalletPage

View file

@ -0,0 +1,28 @@
import { requireSession } from "@/lib/auth/session";
import { getAccount } from "@/lib/api/account";
import { AccountProvider } from "@/lib/providers/account-provider";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
await requireSession("/login");
let account: Awaited<ReturnType<typeof getAccount>> | null = null;
let error: string | null = null;
try {
account = await getAccount();
} catch (e: any) {
error = e?.message ?? "خطا در دریافت اطلاعات پروفایل";
}
return (
<main className="max-w-5xl w-full p-4 mx-auto">
<AccountProvider account={account} error={error}>
{children}
</AccountProvider>
</main>
);
}

View file

@ -1,23 +0,0 @@
import Link from "next/link";
import { requireSession } from "@/lib/auth/session";
import { LogoutButton } from "@/components/customer/logout-button";
// Minimal placeholder — winofyfrontenddoc.md's bottom-nav references a "پروفایل"
// tab, but no profile screen is documented yet in either doc.
export default async function ProfilePage() {
await requireSession("/profile");
return (
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-3 px-4 py-6">
<h1 className="text-title-large font-extrabold text-foreground">پروفایل</h1>
<Link href="/orders" className="rounded-xl border border-border p-4 text-body-large">
سفارش‌های من
</Link>
<Link href="/addresses" className="rounded-xl border border-border p-4 text-body-large">
آدرس‌های من
</Link>
<div className="rounded-xl border border-border p-4">
<LogoutButton />
</div>
</main>
);
}

View file

@ -25,45 +25,70 @@ export default async function StoreDetailPage({
});
if (!store) notFound();
const [products, session] = await Promise.all([getProducts({ store: uuid, search, limit: 100 }), getValidSession()]);
const [products, session] = await Promise.all([
getProducts({ store: uuid, search, limit: 100 }),
getValidSession(),
]);
const cart = session ? await getCart() : null;
const storeGroup = cart?.groups.find((g) => g.store.uuid === uuid) ?? null;
const cartItemByProduct = new Map<string, CartItem>();
storeGroup?.items.forEach((item) => cartItemByProduct.set(item.product.uuid, item));
storeGroup?.items.forEach((item) =>
cartItemByProduct.set(item.product.uuid, item),
);
return (
<main className="flex min-h-full flex-1 flex-col bg-page-bg">
<div className="relative h-[257px] w-full shrink-0 overflow-hidden rounded-b-3xl bg-muted">
<div className="relative h-64.25 w-full shrink-0 overflow-hidden rounded-b-2xl bg-muted">
{store.cover_image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={store.cover_image_url} alt="" className="h-full w-full object-cover" />
<img
src={store.cover_image_url}
alt=""
className="h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 bg-gradient-to-b from-black/0 to-black/70" />
<HeroBackButton href="/" />
</div>
<div className="w-full h-auto flex flex-col items-center">
<StoreInfoCard store={store} />
</div>
<section className="px-4 pt-2">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">توضیحات</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">{store.description}</p>
<section className="p-6">
<h2 className="text-[18px] font-extrabold leading-none text-primary-action">
توضیحات
</h2>
<p className="mt-3 text-right text-[15px] leading-[1.6] text-gray-700">
{store.description}
</p>
</section>
<div className="pt-4">
<div className="pb-6">
<StoreSearchBar storeUuid={uuid} defaultValue={search} />
</div>
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-[120px]">
<section className="grid grid-cols-2 gap-3 px-8 pt-2 pb-30">
{products.map((product) => (
<ProductGridCard key={product.uuid} product={product} cartItem={cartItemByProduct.get(product.uuid) ?? null} />
<ProductGridCard
key={product.uuid}
product={product}
cartItem={cartItemByProduct.get(product.uuid) ?? null}
/>
))}
{products.length === 0 && (
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">محصولی یافت نشد.</p>
<p className="col-span-2 py-8 text-center text-body-medium text-gray-600">
محصولی یافت نشد.
</p>
)}
</section>
{storeGroup && <StickyCartBar itemCount={storeGroup.items.length} total={storeGroup.total} />}
{storeGroup && (
<StickyCartBar
itemCount={storeGroup.items.length}
total={storeGroup.total}
/>
)}
</main>
);
}

View file

@ -15,8 +15,8 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col bg-background text-foreground">
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} h-full antialiased`} suppressHydrationWarning>
<body className="min-h-full flex flex-col bg-background text-foreground" suppressHydrationWarning>
<QueryProvider>{children}</QueryProvider>
</body>
</html>

View file

@ -31,7 +31,7 @@ export function RoleCard({
className={cn(
"flex h-12 w-full items-center justify-center rounded-full text-body-large font-semibold transition-colors",
ctaVariant === "primary"
? "bg-primary-action text-white hover:bg-primary-action/90"
? "border-0 bg-primary-action text-white hover:bg-primary-action/90"
: "border border-primary-action bg-white text-primary-action hover:bg-primary-action/5",
)}
>

View file

@ -1,54 +0,0 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { addToCart } from "@/lib/api/cart";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils/cn";
export function AddToCartButton({
productUuid,
quantity = 1,
disabled,
outOfStock,
className,
}: {
productUuid: string;
quantity?: number;
disabled?: boolean;
outOfStock?: boolean;
className?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [feedback, setFeedback] = useState<"added" | "error" | null>(null);
function handleClick() {
startTransition(async () => {
const result = await addToCart(productUuid, quantity);
if (result.ok) {
setFeedback("added");
router.refresh();
setTimeout(() => setFeedback(null), 1500);
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setFeedback("error");
}
});
}
return (
<Button
type="button"
size="sm"
variant={feedback === "added" ? "secondary" : "primary"}
disabled={disabled || isPending}
onClick={handleClick}
className={cn("w-full", className)}
>
{outOfStock ? "ناموجود" : isPending ? "..." : feedback === "added" ? "افزوده شد ✓" : feedback === "error" ? "خطا، دوباره تلاش کنید" : "افزودن به سبد"}
</Button>
);
}

View file

@ -1,59 +1,92 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus, Trash2 } from "lucide-react";
import { updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem } from "@/types/api";
export function CartItemRow({ item }: { item: CartItem }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function changeQuantity(nextQuantity: number) {
startTransition(async () => {
if (nextQuantity <= 0) {
await removeCartItem(item.uuid);
} else {
await updateCartItemQuantity(item.uuid, nextQuantity);
}
const result =
nextQuantity <= 0 ? await removeCartItem(item.uuid) : await updateCartItemQuantity(item.uuid, nextQuantity);
if (result.ok) {
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<div className={`flex items-center gap-3 py-3 ${isPending ? "opacity-50" : ""}`}>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-muted">
<div className={`flex flex-col gap-2 py-4 ${isPending ? "opacity-50" : ""}`}>
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left. */}
<div className="flex items-start justify-start gap-3">
<div className="h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-muted">
{item.product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.product.image_url} alt="" className="h-full w-full object-cover" />
)}
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-sm font-medium">{item.product.name}</span>
<span className="text-xs text-muted-foreground">{item.product.price.toLocaleString("fa-IR")} تومان</span>
<div className="flex flex-col items-start gap-3 text-right m-2">
<span className="text-[14px] font-bold leading-none text-foreground">{item.product.name}</span>
<span className="text-[13px] leading-none text-gray-600">
{item.product.price.toLocaleString("fa-IR")} تومان
</span>
</div>
<div className="flex items-center gap-2 rounded-full border border-border px-2 py-1">
</div>
<div className="flex items-center justify-end gap-3">
<div className="flex h-8 items-center gap-3 rounded-lg border border-gray-300 px-2">
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity - 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="کاهش تعداد"
>
−
<Minus className="h-3.5 w-3.5 text-foreground" />
</button>
<span className="w-5 text-center text-sm">{item.quantity}</span>
<span className="min-w-3 text-center text-[13px] font-bold leading-none text-foreground">
{item.quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(item.quantity + 1)}
className="flex h-6 w-6 items-center justify-center text-sm"
aria-label="افزایش تعداد"
>
+
<Plus className="h-3.5 w-3.5 text-foreground" />
</button>
</div>
<span className="w-20 shrink-0 text-end text-sm font-semibold">{item.line_total.toLocaleString("fa-IR")}</span>
<button
type="button"
disabled={isPending}
onClick={() => changeQuantity(0)}
aria-label="حذف از سبد"
className="flex h-8 w-8 items-center justify-center disabled:opacity-40"
>
<Trash2 className="h-4.5 w-4.5 text-error" />
</button>
</div>
{error && <p className="text-end text-xs text-error">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import type { CartGroup } from "@/types/api";
/**
* "دریافت حضوری" vs "ارسال توسط فروشگاه" has no backing field in the Checkout API —
* the OpenAPI spec's Checkout schema is address_uuid/delivery_type/scheduled_at/
* payment_method/notes only, with delivery_type meaning express-vs-scheduled timing,
* not pickup-vs-delivery fulfillment. So this toggle only affects what's *displayed*
* here; the actual order submission (next screen) still uses the real delivery fee.
*/
export function CheckoutFulfillment({ group }: { group: CartGroup }) {
const [pickup, setPickup] = useState(false);
const deliveryFee = pickup ? 0 : group.delivery_fee;
const payable = group.items_subtotal + deliveryFee;
return (
<div className="flex flex-col gap-6 pb-32">
<section className="flex flex-col gap-3">
<h2 className="text-[15px] font-bold leading-none text-foreground">نحوه دریافت</h2>
<label
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${pickup ? "border-primary-action" : "border-gray-300"}`}
>
<input
type="radio"
name="fulfillment"
checked={pickup}
onChange={() => setPickup(true)}
className="sr-only"
/>
{/* DOM order intentionally mirrors the screenshot's left→right layout — see the
inherited dir="rtl" note on the /cart page. */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-1.5">
<span className="text-[14px] font-bold leading-none text-foreground">دریافت حضوری</span>
<span className="text-[12px] leading-none text-gray-500">رایگان</span>
</div>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${pickup ? "border-primary-action" : "border-gray-300"}`}
>
{pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
</span>
</div>
{/* The data for delivery time is mock, it should be provided from the backend*/}
<span className="text-[12px] leading-none text-gray-500">آماده در ۱۵ دقیقه</span>
</label>
<label
className={`flex cursor-pointer flex-col gap-1 rounded-2xl border p-4 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
>
<input
type="radio"
name="fulfillment"
checked={!pickup}
onChange={() => setPickup(false)}
className="sr-only"
/>
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-1.5">
<span className="text-[14px] font-bold leading-none text-foreground">ارسال توسط فروشگاه</span>
<span className="text-[12px] leading-none text-gray-500">
{group.delivery_fee.toLocaleString("fa-IR")} تومان
</span>
</div>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${!pickup ? "border-primary-action" : "border-gray-300"}`}
>
{!pickup && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
</span>
</div>
{/* The data for delivery time is mock, it should be provided from the backend*/}
<span className="text-[12px] leading-none text-gray-500">۲۵-۳۵ دقیقه</span>
</label>
</section>
<section className="flex flex-col gap-3 rounded-2xl bg-[#F4F4F5] px-4 py-5">
<div className="flex items-center justify-between text-[13px] text-gray-600">
<span>جمع کالاها</span>
<span>{group.items_subtotal.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex items-center justify-between text-[13px] text-gray-600">
<span>هزینه ارسال</span>
<span>{deliveryFee.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex items-center justify-between border-t border-gray-300 pt-3">
<span className="text-[14px] font-semibold leading-none text-foreground">مبلغ قابل پرداخت</span>
<span className="text-[16px] font-bold leading-none text-foreground">
{payable.toLocaleString("fa-IR")} تومان
</span>
</div>
</section>
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col items-center bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
{/* Pickup has its own confirm+pay screen (no address needed); delivery still
goes to the general /checkout form until that flow's design is provided. */}
<Link href={pickup ? `/checkout/${group.store.uuid}/confirm` : "/checkout"} className="w-full">
<button
type="button"
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white"
>
پرداخت و ثبت سفارش — {payable.toLocaleString("fa-IR")} تومان
</button>
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { submitCheckout } from "@/lib/api/checkout";
import type { Address, CartGroup, PaymentMethod } from "@/types/api";
/** Exact labels per the OpenAPI PaymentMethodEnum description ("(آنلاین)" added to match the screenshot). */
const PAYMENT_OPTIONS: { value: PaymentMethod; label: string }[] = [
{ value: "wallet", label: "کیف پول وینسو" },
{ value: "online", label: "درگاه بانکی (آنلاین)" },
{ value: "cash_on_delivery", label: "پرداخت در محل" },
];
export function CheckoutPayment({ group, addresses }: { group: CartGroup; addresses: Address[] }) {
const router = useRouter();
const [method, setMethod] = useState<PaymentMethod>("wallet");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
// Pickup flow — no delivery fee, matches the fulfillment screen's "دریافت حضوری" total.
const payable = group.items_subtotal;
function handlePay() {
setError(null);
const addressUuid = addresses.find((a) => a.is_default)?.uuid ?? addresses[0]?.uuid;
if (!addressUuid) {
setError("برای ثبت سفارش ابتدا یک آدرس اضافه کنید.");
return;
}
startTransition(async () => {
// submitCheckout processes the whole cart at once (splits into per-store orders
// server-side), not just this store — see PageProps note on the confirm page.
const result = await submitCheckout({
address_uuid: addressUuid,
delivery_type: "express",
scheduled_at: null,
payment_method: method,
notes: "",
});
if (result.ok) {
router.push(`/order-groups/${result.data.uuid}`);
} else {
setError(result.message);
}
});
}
return (
<div className="flex flex-col gap-6 pb-44">
<section className="flex flex-col gap-3">
<h2 className="text-[15px] font-bold leading-none text-foreground">روش پرداخت</h2>
{PAYMENT_OPTIONS.map((option) => {
const selected = method === option.value;
return (
<label
key={option.value}
className={`flex cursor-pointer items-center justify-between rounded-2xl border p-4 ${selected ? "border-primary-action" : "border-gray-200"}`}
>
<input
type="radio"
name="payment"
checked={selected}
onChange={() => setMethod(option.value)}
className="sr-only"
/>
{/* DOM order intentionally mirrors the screenshot's left→right layout — see
the inherited dir="rtl" note on the /cart page. No wallet balance line:
Winofy's API has no wallet-balance endpoint — that's a Gooyal-side
feature (see the wallet.wallet:get_balance OAuth scope) with no
documented endpoint available here, so it isn't faked. */}
<span className="text-[14px] font-bold leading-none text-foreground">{option.label}</span>
<span
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${selected ? "border-primary-action" : "border-gray-300"}`}
>
{selected && <span className="h-2.5 w-2.5 rounded-full bg-primary-action" />}
</span>
</label>
);
})}
</section>
{error && <p className="text-end text-sm text-error">{error}</p>}
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-md flex-col gap-3 bg-white px-4 pt-4 pb-6 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="flex items-center justify-between text-[13px] text-gray-600">
<span>
{group.items.length.toLocaleString("fa-IR")} کالا از {group.store.name}
</span>
<span>{payable.toLocaleString("fa-IR")} تومان</span>
</div>
<div className="flex items-center justify-between mb-2">
<span className="text-[14px] font-semibold leading-none text-foreground">جمع کل</span>
<span className="text-[16px] font-bold leading-none text-foreground">
{payable.toLocaleString("fa-IR")} تومان
</span>
</div>
<button
type="button"
onClick={handlePay}
disabled={isPending}
className="w-full rounded-2xl bg-primary-action py-4 text-[15px] font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال ثبت سفارش..." : `پرداخت ${payable.toLocaleString("fa-IR")} تومان`}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,48 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Trash2 } from "lucide-react";
import { clearCart } from "@/lib/api/cart";
export function ClearCartButton() {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function handleClear() {
if (!window.confirm("سبد خرید شما به‌طور کامل خالی می‌شود. ادامه می‌دهید؟")) return;
startTransition(async () => {
const result = await clearCart();
if (result.ok) {
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={handleClear}
disabled={isPending}
className="flex items-center gap-1.5 text-xs font-medium text-error disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
خالی کردن سبد
</button>
{error && <p className="text-xs text-error">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,38 @@
import Link from "next/link";
import { ShoppingBasket } from "lucide-react";
import { CartItemRow } from "@/components/customer/cart-item-row";
import type { CartGroup } from "@/types/api";
import type { ReactNode } from "react";
/** Shared between /cart and /checkout/[store] — the pixel-perfect per-store card. */
export function StoreCartCard({ group, children }: { group: CartGroup; children?: ReactNode }) {
return (
<section className="relative rounded-2xl border border-gray-300 bg-white p-6 shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
<span className="absolute -top-3 left-4 rounded-lg bg-blue-bg px-2.5 py-1 text-[12px] font-semibold leading-none text-primary-action">
{group.items.length.toLocaleString("fa-IR")} کالا
</span>
<Link
href={`/stores/${group.store.uuid}`}
className="mb-3 flex items-center justify-start gap-2 border-b border-b-[#E4E4E7] pb-5"
>
<ShoppingBasket className="h-4.5 w-4.5 text-foreground" />
<span className="text-[16px] font-bold leading-none text-foreground">{group.store.name}</span>
</Link>
<div className="divide-y divide-gray-200">
{group.items.map((item) => (
<CartItemRow key={item.uuid} item={item} />
))}
</div>
<div className="flex items-center justify-start border-t border-gray-200 pt-5 pb-1">
<span className="text-[13px] font-semibold leading-none text-foreground">
جمع فروشگاه: {group.items_subtotal.toLocaleString("fa-IR")} تومان
</span>
</div>
{children}
</section>
);
}

View file

@ -2,7 +2,7 @@
import { useEffect, useState, useTransition, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus, X, CheckCircle2 } from "lucide-react";
import { Minus, Plus, X, CheckCircle2, Check } from "lucide-react";
import { addToCart } from "@/lib/api/cart";
import { toPersianDigits } from "@/lib/format/persian-digits";
import type { ProductDetail } from "@/types/api";
@ -41,17 +41,19 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
return (
<>
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left, so "what should appear on the left" must
come last in JSX (stepper group, then its own "+" trailing last so it lands
leftmost within the group). */}
<div className="flex h-10 items-center justify-between">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => q + 1)}
aria-label="افزایش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
<span
className={`inline-flex items-center gap-1 text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
{product.is_out_of_stock ? "ناموجود" : "موجود"}
{!product.is_out_of_stock && <Check className="h-3.5 w-3.5" />}
</span>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
@ -60,10 +62,16 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
>
<Minus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-black">{toPersianDigits(quantity)}</span>
<button
type="button"
onClick={() => setQuantity((q) => q + 1)}
aria-label="افزایش تعداد"
className="flex h-10 w-10 items-center justify-center rounded-[20px] border border-gray-300 text-primary-action"
>
<Plus className="h-4 w-4" />
</button>
</div>
<span className={`text-[14px] font-semibold leading-none ${product.is_out_of_stock ? "text-error" : "text-emerald-500"}`}>
{product.is_out_of_stock ? "ناموجود" : "موجود ●"}
</span>
</div>
<div className="h-px w-full bg-gray-300" />
@ -73,35 +81,35 @@ export function ProductPurchasePanel({ product, children }: { product: ProductDe
{error && <p className="px-5 text-body-medium text-error">{error}</p>}
{showToast && (
<div className="fixed inset-x-0 bottom-[135px] z-20 mx-auto flex w-[358px] max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
<X className="h-4 w-4 text-emerald-500" />
</button>
<div className="fixed inset-x-0 bottom-33.75 z-20 mx-auto flex w-89.5 max-w-[calc(100%-32px)] items-center gap-3 rounded-2xl border border-emerald-border bg-emerald-bg p-4 shadow-[0_4px_12px_0_rgba(16,185,129,0.102)]">
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
<span className="flex-1 text-right text-[14px] font-bold leading-none text-emerald-500">
محصول به سبد خرید اضافه شد
</span>
<CheckCircle2 className="h-6 w-6 shrink-0 fill-emerald-500 text-white" />
<button type="button" onClick={() => setShowToast(false)} aria-label="بستن">
<X className="h-4 w-4 text-emerald-500" />
</button>
</div>
)}
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="flex w-full items-center justify-between gap-4">
<button
type="button"
onClick={handleAddToCart}
disabled={disabled || isPending}
className="flex h-[53px] w-[245px] items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
<div className="flex flex-col items-end gap-0.5">
<div className="flex flex-col items-start gap-2">
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
<span className="text-[18px] font-bold leading-none text-foreground">
{(product.price * quantity).toLocaleString("fa-IR")} تومان
</span>
</div>
<button
type="button"
onClick={handleAddToCart}
disabled={disabled || isPending}
className="flex h-13.25 w-61.25 items-center justify-center rounded-xl bg-primary-action text-body-large font-bold text-white disabled:opacity-40"
>
{isPending ? "در حال افزودن..." : "افزودن به سبد خرید"}
</button>
</div>
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
</div>
</>
);

View file

@ -0,0 +1,35 @@
'use client';
import { ChevronLeft, LogOutIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
const ProfileExitAccount = () => {
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleLogout() {
startTransition(async () => {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/");
router.refresh();
});
}
return (
<div
className="flex items-center justify-between w-full py-3 cursor-pointer"
onClick={handleLogout}
>
<div className="flex items-center gap-3">
<div className="bg-error/10 p-2 rounded-lg">
<LogOutIcon className="w-4 h-4 text-error" />
</div>
<span className="text-sm font-semibold text-error">خروج</span>
</div>
<ChevronLeft className="w-5 h-5 text-error" />
</div>
);
};
export default ProfileExitAccount;

View file

@ -0,0 +1,38 @@
'use client';
import Link from 'next/link';
import { ChevronLeft, User } from 'lucide-react';
interface ProfileMenuItemInterface {
href: string;
text: string;
icon?: React.ReactNode;
isLast?: boolean;
}
const ProfileMenuItem = ({
href,
text,
icon,
isLast = false,
}: ProfileMenuItemInterface) => {
return (
<Link
href={href}
className={`
flex items-center justify-between w-full py-3
${!isLast ? 'border-b border-border' : ''}
`}
>
<div className="flex items-center gap-3">
<div className="bg-gray-50 p-2 rounded-lg">
{icon || <User className="w-4 h-4 text-primary-action" />}
</div>
<span className="text-sm text-gray-700">{text}</span>
</div>
<ChevronLeft className="w-5 h-5 text-gray-500" />
</Link>
);
};
export default ProfileMenuItem;

View file

@ -0,0 +1,70 @@
'use client';
import {
ShoppingBag,
MapPin,
Wallet,
Bell,
CircleQuestionMark,
} from 'lucide-react';
import ProfileMenuItem from './profile-menu-item';
import ProfileExitAccount from './profile-exit-account';
const menuItems = [
{
id: 1,
href: '/addresses',
text: 'سعادت‌آباد',
icon: <MapPin className="size-5 text-primary-action" />,
},
{
id: 2,
href: '/orders',
text: 'سفارش‌های من',
icon: <ShoppingBag className="size-5 text-primary-action" />,
},
{
id: 3,
href: '/addresses',
text: 'آدرس‌های من',
icon: <MapPin className="size-5 text-primary-action" />,
},
{
id: 4,
href: '/profile/wallet',
text: 'کیف پول',
icon: <Wallet className="size-5 text-primary-action" />,
},
{
id: 5,
href: '/profile/notifications',
text: 'اعلان‌ها',
icon: <Bell className="size-5 text-primary-action" />,
},
{
id: 6,
href: '/profile/support',
text: 'پشتیبانی',
icon: <CircleQuestionMark className="size-5 text-primary-action" />,
},
];
const ProfileMenu = () => {
return (
<div className="w-full">
{menuItems.map((item, index) => (
<ProfileMenuItem
key={item.id}
href={item.href}
text={item.text}
icon={item.icon}
isLast={index === menuItems.length - 1}
/>
))}
<div className="w-full h-px bg-border mt-4" />
<ProfileExitAccount />
</div>
);
};
export default ProfileMenu;

View file

@ -0,0 +1,11 @@
import React from 'react'
interface ProfilePageHeadLineInterface {
title: String;
}
const ProfilePageHeadLine = ({ title }: ProfilePageHeadLineInterface) => {
return <h1 className="font-bold text-2xl">{title}</h1>
}
export default ProfilePageHeadLine

View file

@ -0,0 +1,70 @@
'use client';
import { useAccount } from "@/lib/providers/account-provider";
import { getDisplayNameHelper, getInitialsHelper } from "@/lib/utils/account-utils";
const ProfilePersonDetailCard = () => {
const { data, error, isLoading } = useAccount();
if (isLoading) {
return (
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
</div>
);
}
if (error) {
return (
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
</div>
);
}
if (!data) {
return (
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
<p className="text-sm text-gray-500">اطلاعات پروفایل یافت نشد</p>
</div>
);
}
const displayName = getDisplayNameHelper(data);
const initials = getInitialsHelper(data);
return (
<div className="w-full min-h-[128px] rounded-2xl bg-teal-light my-6 py-4 px-10 flex items-center justify-center">
<div className="flex flex-row items-center w-full">
<div className="size-16 bg-primary-action rounded-full flex items-center justify-center text-lg font-bold text-white overflow-hidden shrink-0">
{data.avatar ? (
<img
src={data.avatar}
alt={displayName}
className="size-full object-cover"
/>
) : (
<span>{initials}</span>
)}
</div>
<div className="flex flex-col mr-6 items-start">
<p className="text-lg font-bold">{displayName}</p>
{data.phone_number && (
<p className="text-sm text-gray-700 my-2 font-normal" dir="ltr">
{data.phone_number}
</p>
)}
<button
className="text-sm font-semibold text-primary-action cursor-pointer underline"
onClick={() => alert('latter...')}
>
ویرایش پروفایل
</button>
</div>
</div>
</div>
);
};
export default ProfilePersonDetailCard;

View file

@ -4,11 +4,20 @@ import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus } from "lucide-react";
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import {
addToCart,
updateCartItemQuantity,
removeCartItem,
} from "@/lib/api/cart";
import type { CartItem, ProductListItem } from "@/types/api";
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
export function ProductGridCard({
product,
cartItem,
}: {
product: ProductListItem;
cartItem: CartItem | null;
}) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
@ -43,12 +52,13 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
startTransition(async () => {
if (nextQuantity <= 0) {
const result = await removeCartItem(localItem.uuid);
// Only clear locally once the backend actually confirms removal — otherwise
// the card would show "add" again while the item still exists server-side.
if (result.ok) setLocalItem(null);
else setError(result.message);
} else {
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
const result = await updateCartItemQuantity(
localItem.uuid,
nextQuantity,
);
if (result.ok) setLocalItem(result.data);
else setError(result.message);
}
@ -57,67 +67,83 @@ export function ProductGridCard({ product, cartItem }: { product: ProductListIte
}
return (
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
<article className="relative flex h-63.5 w-39.25 flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
{error && (
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
{error}
</div>
)}
<Link href={`/products/${product.uuid}`} className="block shrink-0">
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
<div className="relative h-32.5 w-full overflow-hidden rounded-t-xl bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
<img
src={product.image_url}
alt={product.name}
className="h-full w-full object-cover"
/>
)}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-t from-black/40 to-transparent" />
</div>
</Link>
<div className="flex flex-1 flex-col justify-between p-3">
<div className="flex flex-col gap-1.5">
<div className="flex flex-1 flex-col gap-1.5 p-3">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">
{product.name}
</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
{disabled && (
<span className="mt-auto text-center text-label text-gray-500">
ناموجود
</span>
)}
</div>
{disabled ? (
<span className="text-center text-label text-gray-500">ناموجود</span>
) : quantity === 0 ? (
{!disabled && (
<div
className={`motion-reduce:transition-none absolute bottom-3 left-3 overflow-hidden rounded-full border transition-[width,background-color,border-color] duration-300 ease-out ${
quantity === 0
? "h-10 w-10 border-transparent bg-primary-action"
: "h-10 w-28 border-gray-300 bg-white"
}`}
dir="ltr"
>
<div className="relative flex h-full w-full items-center justify-between px-1">
<button
type="button"
onClick={handleAdd}
onClick={
quantity === 0 ? handleAdd : () => handleChange(quantity + 1)
}
disabled={isPending}
aria-label="افزودن به سبد"
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
aria-label={quantity === 0 ? "افزودن به سبد" : "افزایش تعداد"}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-primary-action p-0 text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
) : (
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
<button
type="button"
onClick={() => handleChange(quantity + 1)}
disabled={isPending}
aria-label="افزایش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
{quantity > 0 && (
<>
<span className="flex h-full w-8 shrink-0 items-center justify-center text-center text-[18px] font-bold leading-none text-foreground">
{quantity.toLocaleString("fa-IR")}
</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-0 bg-blue-bg p-0 text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
</div>
</>
)}
</div>
</div>
)}
</article>
);
}

View file

@ -1,32 +1,31 @@
import Link from "next/link";
import { ShoppingBag } from "lucide-react";
/** designspec.md §2.6 — only rendered when this store's cart group actually has items. */
export function StickyCartBar({ itemCount, total }: { itemCount: number; total: number }) {
return (
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-[390px] flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
<div className="fixed inset-x-0 bottom-0 z-10 mx-auto flex w-full max-w-97.5 flex-col items-center gap-2 bg-white px-4 pt-4 pb-2 shadow-[0_-4px_15px_0_rgba(0,0,0,0.102)]">
{/* DOM order intentionally mirrors the screenshot's left→right layout, not reading
order — under this app's inherited dir="rtl", the first child renders at the
right edge and the last at the left. */}
<div className="flex w-full items-center justify-between">
<Link
href="/cart"
className="flex h-[50px] items-center gap-2 rounded-full bg-primary-action px-6 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
>
<span className="relative flex h-[18px] w-[18px] items-center justify-center">
<ShoppingBag className="h-[18px] w-[18px]" />
<span className="absolute -top-1.5 -left-1.5 flex h-[14px] min-w-[14px] items-center justify-center rounded-full bg-white text-[10px] font-bold text-primary-action">
{itemCount.toLocaleString("fa-IR")}
</span>
</span>
<span className="text-body-large font-bold">مشاهده سبد</span>
</Link>
<div className="flex flex-col items-end gap-0.5">
<div className="flex flex-col items-start gap-2">
<span className="text-[12px] leading-none text-gray-600">جمع کل</span>
<span className="text-[18px] font-bold leading-none text-foreground">
{total.toLocaleString("fa-IR")} تومان
</span>
</div>
<Link
href="/cart"
className="flex h-12.5 items-center gap-2 rounded-xl bg-primary-action py-1 px-5 text-white shadow-[0_6px_16px_-6px_rgba(0,0,0,0.078),0_2px_8px_-2px_rgba(0,0,0,0.071)]"
>
<span className="text-body-large font-bold">مشاهده سبد</span>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/20 text-[13px] font-bold text-white">
{itemCount.toLocaleString("fa-IR")}
</span>
</Link>
</div>
<div className="mt-2 h-[5px] w-[134px] rounded-full bg-black" />
<div className="mt-2 h-1.5 w-32 rounded-full bg-black" />
</div>
);
}

View file

@ -6,7 +6,9 @@ import type { StoreDetail } from "@/types/api";
function todaysWorkingHours(store: StoreDetail) {
const jsDay = new Date().getDay();
const iranianWeekday = (jsDay + 1) % 7;
return store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null;
return (
store.working_hours.find((wh) => wh.weekday === iranianWeekday) ?? null
);
}
function formatTime(value: string | null) {
@ -20,35 +22,52 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${store.name} ${store.address}`)}`;
return (
<div className="relative z-10 -mt-6 rounded-t-3xl bg-white px-4 pt-5 pb-4">
<h1 className="text-title-large font-extrabold leading-none text-primary-action">{store.name}</h1>
<p className="mt-1 text-[14px] leading-none text-gray-700">{store.category.name}</p>
<div className="w-[90%] relative z-10 -mt-24 rounded-3xl bg-white px-4 pt-5 pb-4 shadow-[0px_4px_20px_0px_rgba(0,0,0,0.102)]">
<h1 className="text-title-large font-extrabold leading-none text-primary-action">
{store.name}
</h1>
<p className="mt-1 text-[14px] leading-none text-gray-700">
{store.category.name}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
{today && (
<span className="rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
{today.is_closed ? "تعطیل" : `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
</span>
)}
<span className="text-[13px] leading-none text-gray-600">|</span>
<span
className={`rounded-full border px-2.5 py-1.5 text-[12px] font-semibold leading-none ${
store.is_open ? "border-emerald-border bg-emerald-bg text-emerald-text-strong" : "border-gray-300 bg-gray-100 text-gray-600"
store.is_open
? "border-emerald-border bg-emerald-bg text-emerald-text-strong"
: "border-gray-300 bg-gray-100 text-gray-600"
}`}
>
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`} />
<span
className={`h-1.5 w-1.5 rounded-full ${store.is_open ? "bg-emerald-500" : "bg-gray-500"}`}
/>
{store.is_open ? "فعال" : "بسته"}
</span>
</span>
<span className="text-[13px] leading-none text-gray-600">|</span>
{today && (
<span className="h-6 flex items-center rounded-full bg-teal-light px-2 py-1 text-[11px] font-semibold leading-none text-primary-action">
{today.is_closed
? "تعطیل"
: `${formatTime(today.opens_at)} تا ${formatTime(today.closes_at)}`}
</span>
)}
</div>
<div className="mt-4 flex items-center gap-2 rounded-[10px] border border-teal-border p-2">
<div className="mt-4 flex items-center gap-2 p-2">
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<MapPin className="h-4 w-4" />
</span>
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">{store.address}</span>
<a href={mapsUrl} target="_blank" rel="noreferrer" className="shrink-0 text-[13px] font-medium text-teal">
<span className="flex-1 truncate text-right text-[14px] leading-normal text-foreground">
{store.address}
</span>
<a
href={mapsUrl}
target="_blank"
rel="noreferrer"
className="shrink-0 text-[13px] font-medium text-teal"
>
مسیریابی
</a>
</div>
@ -56,7 +75,7 @@ export function StoreInfoCard({ store }: { store: StoreDetail }) {
{store.phone_number && (
<a
href={`tel:${store.phone_number}`}
className="mt-2 flex items-center gap-2 rounded-[10px] border border-teal-border p-2"
className="mt-2 flex items-center gap-2 p-2"
>
<span className="flex h-8 w-10 shrink-0 items-center justify-center rounded-[10px] border border-teal-border bg-teal-light text-primary-action">
<Phone className="h-4 w-4" />

View file

@ -8,10 +8,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
// Tailwind's preflight doesn't zero out <button>'s default border/padding (only
// font/color/background/border-radius/opacity) — every variant that doesn't set
// its own border must reset it explicitly, or the browser's default UA button
// border/bevel survives underneath and throws off icon/label centering.
primary: "border-0 bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "border-0 bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-border bg-transparent hover:bg-muted",
ghost: "bg-transparent hover:bg-muted",
ghost: "border-0 bg-transparent hover:bg-muted",
},
size: {
default: "h-12 px-4 text-body-large",

View file

@ -7,7 +7,7 @@ export function HeroBackButton({ href }: { href: string }) {
<Link
href={href}
aria-label="بازگشت"
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full border border-white bg-white/80 backdrop-blur-md"
className="absolute top-4 right-4 flex h-9 w-9 items-center justify-center rounded-full bg-black/35 backdrop-blur-lg"
>
<ArrowRight className="h-[19.8px] w-[19.8px] text-white" />
</Link>

View file

@ -0,0 +1,38 @@
import React from 'react';
import TransactionItem from './transaction-item';
const transactions = [
{ id: 1, amount: 250000, description: 'واریز از کارت', type: 'deposit' },
{ id: 2, amount: 45000, description: 'پرداخت فاکتور', type: 'withdraw' },
{ id: 3, amount: 120000, description: 'واریز نقدی', type: 'deposit' },
{ id: 4, amount: 780000, description: 'حقوق ماهیانه', type: 'deposit' },
{ id: 5, amount: 320000, description: 'خرید از فروشگاه', type: 'withdraw' },
{ id: 6, amount: 560000, description: 'واریز چک', type: 'deposit' },
{ id: 7, amount: 95000, description: 'شارژ اینترنت', type: 'withdraw' },
{ id: 8, amount: 1500000, description: 'پرداخت وام', type: 'withdraw' },
{ id: 9, amount: 430000, description: 'واریز از همراه', type: 'deposit' },
{ id: 10, amount: 210000, description: 'خرید آنلاین', type: 'withdraw' },
{ id: 11, amount: 670000, description: 'پاداش عملکرد', type: 'deposit' },
{ id: 12, amount: 185000, description: 'پرداخت قبوض', type: 'withdraw' },
{ id: 13, amount: 920000, description: 'فروش کالا', type: 'deposit' },
{ id: 14, amount: 380000, description: 'انتقال به دوست', type: 'withdraw' },
{ id: 15, amount: 110000, description: 'بازگشت وجه', type: 'deposit' },
] as const;
const TransactionHistory = () => {
return (
<div className="w-full my-8">
{transactions.map((item, index) => (
<TransactionItem
key={item.id}
amount={item.amount}
description={item.description}
type={item.type}
isLast={index === transactions.length - 1}
/>
))}
</div>
);
};
export default TransactionHistory;

View file

@ -0,0 +1,49 @@
import React from 'react';
import { ArrowDownIcon, ArrowUp } from 'lucide-react';
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
interface TransactionItemProps {
amount: number;
description: string;
type: 'deposit' | 'withdraw';
isLast?: boolean;
}
const TransactionItem = ({
amount,
description,
type,
isLast = false,
}: TransactionItemProps) => {
const isDeposit = type === 'deposit';
const Icon = isDeposit ? ArrowDownIcon : ArrowUp;
const amountColor = isDeposit ? 'text-success' : 'text-error';
const bgColor = isDeposit ? 'bg-success/10' : 'bg-error/10';
const iconColor = isDeposit ? 'text-success' : 'text-error';
return (
<div
className={`
flex items-center justify-between w-full py-3
${!isLast ? 'border-b border-border' : ''}
`}
>
<div className="flex items-center">
<div className={`p-2 rounded-full ${bgColor}`}>
<Icon className={`w-5 h-5 rotate-45 ${iconColor}`} />
</div>
<span className="text-sm font-normal flex mr-4">{description}</span>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`text-sm font-medium ${amountColor}`}>
{isDeposit ? '+' : '-'} {formatPriceWithToman(amount)}
</span>
<span className="text-sm text-gray-500">۱۴۰۳/۰۸/۲۴</span>
</div>
</div>
);
};
export default TransactionItem;

View file

@ -0,0 +1,52 @@
'use client';
import { formatPriceWithToman } from '@/lib/utils/priceUtils';
import { LogOutIcon, PlusIcon } from 'lucide-react';
const WalletBlancCard = () => {
return (
<div
className="
w-full min-h-12 rounded-2xl my-6 py-6 px-6
flex flex-col justify-start
bg-gradient-to-l from-primary to-primary-action
shadow-[0_12px_24px] shadow-primary/25
border border-primary/25
"
>
<h4 className="text-sm font-medium text-gray-300">
موجودی کیف پول
</h4>
<h1 className="text-2xl font-bold my-4 text-primary-foreground">
{formatPriceWithToman(35000000)}
</h1>
<div className="flex w-full gap-4">
<button
className="
w-full bg-white h-12 rounded-xl cursor-pointer
flex items-center justify-center gap-2
"
onClick={() => alert('latter...')}
>
<PlusIcon className="text-primary-action size-4" />
<span className="text-primary-action">شارژ کیف پول</span>
</button>
<button
className="
w-full h-12 rounded-xl cursor-pointer
flex items-center justify-center gap-2
border border-primary-foreground
"
onClick={() => alert('latter...')}
>
<LogOutIcon className="text-primary-foreground size-4 -rotate-90" />
<span className="text-primary-foreground">برداشت</span>
</button>
</div>
</div>
);
};
export default WalletBlancCard;

8
src/lib/api/account.ts Normal file
View file

@ -0,0 +1,8 @@
"use server";
import { gooyalFetch } from "./gooyal-accounts";
import type { Account } from "@/types/api";
export async function getAccount(): Promise<Account> {
return gooyalFetch<Account>("/users/api/account/");
}

View file

@ -46,17 +46,19 @@ function indent(text: string): string {
/**
* Dev-only request/response logging — the terminal-equivalent of Chrome's Network
* tab for server-side calls, since they run on the Next.js server and the browser
* never sees them (no CORS on the Winofy API). Never runs in production.
* tab for every server-side call this app makes (Winofy API, Gooyal accounts),
* since those run on the Next.js server and the browser never sees them. Shared
* across every backend this app talks to, labeled by `service` so interleaved
* calls stay easy to tell apart. Never runs in production.
*/
export function logWinofyRequest(method: string, url: string, body: unknown) {
export function logApiRequest(service: string, method: string, url: string, body: unknown) {
if (!DEBUG) return;
console.log(`\n${CYAN}→ [winofy]${RESET} ${BOLD}${method}${RESET} ${url}`);
console.log(`\n${CYAN}→ [${service}]${RESET} ${BOLD}${method}${RESET} ${url}`);
if (body !== undefined) console.log(`${DIM} body:${RESET}\n${indent(prettyJson(body))}`);
}
export function logWinofyResponse(method: string, url: string, status: number, data: unknown) {
export function logApiResponse(service: string, method: string, url: string, status: number, data: unknown) {
if (!DEBUG) return;
console.log(`${statusColor(status)}← [winofy] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
console.log(`${statusColor(status)}← [${service}] ${status}${RESET} ${BOLD}${method}${RESET} ${url}`);
if (data !== null && data !== undefined) console.log(`${DIM} data:${RESET}\n${indent(prettyJson(data))}`);
}

View file

@ -0,0 +1,65 @@
import "server-only";
import { getValidSession } from "@/lib/auth/session";
import { ApiError, type WinofyErrorDetails } from "./errors";
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
const BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
interface GooyalFetchOptions extends Omit<RequestInit, "body"> {
auth?: boolean;
params?: Record<string, string | number | boolean | undefined>;
body?: unknown;
}
export async function gooyalFetch<T>(path: string, options: GooyalFetchOptions = {}): Promise<T> {
const { auth = true, params, headers, body, ...rest } = options;
const method = rest.method ?? "GET";
const url = new URL(BASE_URL.replace(/\/$/, "") + path);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
}
const finalHeaders = new Headers(headers);
const hasBody = body !== undefined;
if (hasBody && !finalHeaders.has("Content-Type")) {
finalHeaders.set("Content-Type", "application/json");
}
if (auth) {
const session = await getValidSession();
if (session) {
finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
}
}
logWinofyRequest?.(method, url.toString(), body);
const res = await fetch(url.toString(), {
...rest,
headers: finalHeaders,
body: hasBody ? JSON.stringify(body) : undefined,
cache: "no-store",
});
if (res.status === 204) {
logWinofyResponse?.(method, url.toString(), res.status, null);
return undefined as T;
}
const data = await res.json().catch(() => null);
logWinofyResponse?.(method, url.toString(), res.status, data);
if (!res.ok) {
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;
throw new ApiError(
res.status,
envelope?.status_message ?? res.statusText,
envelope?.details ?? null,
);
}
return data as T;
}

View file

@ -1,7 +1,7 @@
import "server-only";
import { getValidSession } from "@/lib/auth/session";
import { ApiError, type WinofyErrorDetails } from "./errors";
import { logWinofyRequest, logWinofyResponse } from "./dev-logger";
import { logApiRequest, logApiResponse } from "./dev-logger";
const BASE_URL = process.env.WINOFY_API_BASE_URL!;
@ -41,7 +41,7 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
if (session) finalHeaders.set("Authorization", `Bearer ${session.accessToken}`);
}
logWinofyRequest(method, url.toString(), body);
logApiRequest("winofy", method, url.toString(), body);
const res = await fetch(url.toString(), {
...rest,
@ -51,12 +51,12 @@ export async function winofyFetch<T>(path: string, options: WinofyFetchOptions =
});
if (res.status === 204) {
logWinofyResponse(method, url.toString(), res.status, null);
logApiResponse("winofy", method, url.toString(), res.status, null);
return undefined as T;
}
const data = await res.json().catch(() => null);
logWinofyResponse(method, url.toString(), res.status, data);
logApiResponse("winofy", method, url.toString(), res.status, data);
if (!res.ok) {
const envelope = data as { status_message?: string; details?: WinofyErrorDetails } | null;

View file

@ -1,4 +1,5 @@
import "server-only";
import { logApiRequest, logApiResponse } from "@/lib/api/dev-logger";
const ACCOUNTS_BASE_URL = process.env.GOOYAL_ACCOUNTS_BASE_URL!;
const CLIENT_ID = process.env.GOOYAL_CLIENT_ID!;
@ -34,11 +35,16 @@ export interface GooyalTokenResponse {
}
export async function refreshAccessToken(refreshToken: string): Promise<GooyalTokenResponse> {
const url = `${ACCOUNTS_BASE_URL}/oauth2/token/`;
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
});
const res = await fetch(`${ACCOUNTS_BASE_URL}/oauth2/token/`, {
// Never print real tokens to the terminal — log the request shape, not the secret values.
logApiRequest("gooyal", "POST", url, { grant_type: "refresh_token", refresh_token: "<redacted>" });
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: basicAuthHeader(),
@ -48,6 +54,12 @@ export async function refreshAccessToken(refreshToken: string): Promise<GooyalTo
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
logApiResponse("gooyal", "POST", url, res.status, {
...data,
access_token: data.access_token ? "<redacted>" : undefined,
refresh_token: data.refresh_token ? "<redacted>" : undefined,
});
if (!res.ok) {
throw new GooyalAuthError(res.status, data.error ?? "refresh_failed", data.error_description);
}

View file

@ -0,0 +1,42 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
import type { Account } from "@/types/api";
type AccountContextValue = {
data: Account | null;
error: string | null;
isLoading: boolean;
};
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
export function AccountProvider({
children,
account,
error = null,
}: {
children: ReactNode;
account: Account | null;
error?: string | null;
}) {
return (
<AccountContext.Provider
value={{
data: account,
error,
isLoading: false,
}}
>
{children}
</AccountContext.Provider>
);
}
export function useAccount() {
const ctx = useContext(AccountContext);
if (ctx === undefined) {
throw new Error("useAccount must be used within AccountProvider");
}
return ctx;
}

View file

@ -0,0 +1,49 @@
'use client';
import { useAccount } from '@/lib/providers/account-provider';
import React from 'react';
export function getInitialsHelper(account: {
name?: string | null;
first_name?: string | null;
last_name?: string | null;
username?: string | null;
}): string {
if (account.name?.trim()) {
const parts = account.name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return parts[0].slice(0, 2).toUpperCase();
}
if (account.first_name || account.last_name) {
const first = account.first_name?.[0] ?? '';
const last = account.last_name?.[0] ?? '';
return (first + last).toUpperCase() || '؟';
}
if (account.username) {
return account.username.slice(0, 2).toUpperCase();
}
return '؟';
}
export function getDisplayNameHelper(account: {
name?: string | null;
first_name?: string | null;
last_name?: string | null;
username?: string | null;
}): string {
if (account.name?.trim()) return account.name.trim();
const fullName = [account.first_name, account.last_name]
.filter(Boolean)
.join(' ')
.trim();
if (fullName) return fullName;
return account.username || 'کاربر';
}

View file

@ -0,0 +1,42 @@
/**
* Converts Rial to Toman (divides by 10)
*/
export const convertRialToToman = (rialAmount: number): number => {
return Math.round(rialAmount / 10);
};
/**
* Formats number with comma separators (e.g., 3,000,000)
*/
export const formatNumberWithCommas = (amount: number): string => {
return amount.toLocaleString('fa-IR'); // for Persian digits
// Or for English digits: return amount.toLocaleString('en-US');
};
/**
* Main function: receives amount in Rial or Toman, returns formatted with comma separators and "Toman" label
*
* @param amount - input amount (in Rial or Toman)
* @param inputIsRial - whether the input amount is in Rial (default: true)
* @returns string - formatted amount with Toman label (e.g., "۳,۰۰۰,۰۰۰ تومان")
*/
export const formatPriceWithToman = (
amount: number,
inputIsRial: boolean = true
): string => {
// If amount is in Rial, convert to Toman first
const tomanAmount = inputIsRial ? convertRialToToman(amount) : amount;
// Add comma separators
const formatted = formatNumberWithCommas(tomanAmount);
// Return with Toman label
return `${formatted} تومان`;
};
/**
* Simpler version: just take an amount in Toman and format it
*/
export const formatTomanPrice = (tomanAmount: number): string => {
return `${formatNumberWithCommas(tomanAmount)} تومان`;
};

View file

@ -310,3 +310,42 @@ export interface SellerAnalytics {
chart: SalesChartPoint[];
top_products: TopProduct[];
}
// ---- Account / Profile ----------------------------------------------------
export type Gender = "m" | "f" | "other" | null;
export type EducationDegree =
| "primary"
| "secondary"
| "diploma"
| "bachelor"
| "master"
| "phd"
| string
| null;
export interface Account {
uuid: string;
username: string;
email: string | null;
phone_number: string | null;
first_name: string | null;
last_name: string | null;
name: string | null;
avatar: string | null;
gender: Gender;
education_degree: EducationDegree;
education_field: string | null;
birthdate: string | null; // "YYYY-MM-DD"
iban: string | null;
iban_verified: boolean;
province: number | null;
province_name: string | null;
city: number | null;
city_name: string | null;
postal_code: string | null;
address: string | null;
date_joined: string;
last_update: string;
}