From b0ce5ce2f92c60e36f112605962120d5de99679e Mon Sep 17 00:00:00 2001 From: Mohammadali Date: Sun, 30 Aug 2026 13:28:27 +0330 Subject: [PATCH] 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 --- src/app/(customer)/(shop)/profile/layout.tsx | 35 +++++--- src/app/layout.tsx | 4 +- .../profile/profile-exit-account.tsx | 47 +++++------ src/components/profile/profile-menu-item.tsx | 8 +- src/components/profile/profile-menu.tsx | 83 ++++++++++++------ .../profile/profile-person-detail-card.tsx | 84 +++++++++++++++---- src/components/wallet/transaction-history.tsx | 2 +- src/components/wallet/transaction-item.tsx | 67 +++++++-------- src/components/wallet/wallet-blanc-card.tsx | 54 ++++++++---- src/lib/api/account.ts | 8 ++ src/lib/api/gooyal-accounts.ts | 65 ++++++++++++++ src/lib/auth/gooyal-client.ts | 2 +- src/lib/providers/account-provider.tsx | 42 ++++++++++ src/lib/utils/account-utils.ts | 49 +++++++++++ src/types/api.ts | 39 +++++++++ 15 files changed, 452 insertions(+), 137 deletions(-) create mode 100644 src/lib/api/account.ts create mode 100644 src/lib/api/gooyal-accounts.ts create mode 100644 src/lib/providers/account-provider.tsx create mode 100644 src/lib/utils/account-utils.ts diff --git a/src/app/(customer)/(shop)/profile/layout.tsx b/src/app/(customer)/(shop)/profile/layout.tsx index 96b622d..dc8b338 100644 --- a/src/app/(customer)/(shop)/profile/layout.tsx +++ b/src/app/(customer)/(shop)/profile/layout.tsx @@ -1,17 +1,28 @@ -import { requireSession } from '@/lib/auth/session'; -import React from 'react' +import { requireSession } from "@/lib/auth/session"; +import { getAccount } from "@/lib/api/account"; +import { AccountProvider } from "@/lib/providers/account-provider"; -interface ProfileLayoutInterface { - children: React.ReactNode; -} +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + await requireSession("/login"); + let account: Awaited> | null = null; + let error: string | null = null; -const ProfileLayout = async ({ children }: ProfileLayoutInterface) => { - await requireSession("/profile"); - - return
+ try { + account = await getAccount(); + } catch (e: any) { + error = e?.message ?? "خطا در دریافت اطلاعات پروفایل"; + } + + return ( +
+ {children} +
-} - -export default ProfileLayout \ No newline at end of file + ); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 60364b4..217ce00 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,8 +15,8 @@ export const metadata: Metadata = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - + + {children} diff --git a/src/components/profile/profile-exit-account.tsx b/src/components/profile/profile-exit-account.tsx index cb23a24..a5fec14 100644 --- a/src/components/profile/profile-exit-account.tsx +++ b/src/components/profile/profile-exit-account.tsx @@ -1,36 +1,35 @@ 'use client'; -import { ChevronLeft, LogOutIcon } from "lucide-react" +import { ChevronLeft, LogOutIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; const ProfileExitAccount = () => { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); - const router = useRouter(); - const [isPending, startTransition] = useTransition(); + function handleLogout() { + startTransition(async () => { + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/"); + router.refresh(); + }); + } - function handleLogout() { - startTransition(async () => { - await fetch("/api/auth/logout", { method: "POST" }); - router.push("/"); - router.refresh(); - }); - } - - return
-
-
- -
- خروج +
+
+
- + خروج +
+
-} + ); +}; -export default ProfileExitAccount \ No newline at end of file +export default ProfileExitAccount; \ No newline at end of file diff --git a/src/components/profile/profile-menu-item.tsx b/src/components/profile/profile-menu-item.tsx index 885bbc4..9196af6 100644 --- a/src/components/profile/profile-menu-item.tsx +++ b/src/components/profile/profile-menu-item.tsx @@ -21,16 +21,16 @@ const ProfileMenuItem = ({ href={href} className={` flex items-center justify-between w-full py-3 - ${!isLast ? 'border-b border-gray-200' : ''} + ${!isLast ? 'border-b border-border' : ''} `} >
-
- {icon || } +
+ {icon || }
{text}
- + ); }; diff --git a/src/components/profile/profile-menu.tsx b/src/components/profile/profile-menu.tsx index 5e3392e..3f0843b 100644 --- a/src/components/profile/profile-menu.tsx +++ b/src/components/profile/profile-menu.tsx @@ -1,41 +1,70 @@ 'use client'; import { - ShoppingBag, - MapPin, - Wallet, - Bell, - Headset, - CircleQuestionMark + 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: }, - { id: 2, href: '/orders', text: 'سفارش‌های من', icon: }, - { id: 3, href: '/addresses', text: 'آدرس‌های من', icon: }, - { id: 4, href: '/profile/wallet', text: 'کیف پول', icon: }, - { id: 5, href: '/profile/notifications', text: 'اعلان‌ها', icon: }, - { id: 6, href: '/profile/support', text: 'پشتیبانی', icon: }, + { + id: 1, + href: '/addresses', + text: 'سعادت‌آباد', + icon: , + }, + { + id: 2, + href: '/orders', + text: 'سفارش‌های من', + icon: , + }, + { + id: 3, + href: '/addresses', + text: 'آدرس‌های من', + icon: , + }, + { + id: 4, + href: '/profile/wallet', + text: 'کیف پول', + icon: , + }, + { + id: 5, + href: '/profile/notifications', + text: 'اعلان‌ها', + icon: , + }, + { + id: 6, + href: '/profile/support', + text: 'پشتیبانی', + icon: , + }, ]; const ProfileMenu = () => { - return ( -
- {menuItems.map((item, index) => ( - - ))} -
- -
- ); + return ( +
+ {menuItems.map((item, index) => ( + + ))} +
+ +
+ ); }; export default ProfileMenu; \ No newline at end of file diff --git a/src/components/profile/profile-person-detail-card.tsx b/src/components/profile/profile-person-detail-card.tsx index ed99253..57d83d4 100644 --- a/src/components/profile/profile-person-detail-card.tsx +++ b/src/components/profile/profile-person-detail-card.tsx @@ -1,22 +1,70 @@ 'use client'; - -import React from 'react' +import { useAccount } from "@/lib/providers/account-provider"; +import { getDisplayNameHelper, getInitialsHelper } from "@/lib/utils/account-utils"; const ProfilePersonDetailCard = () => { - return ( -
-
-
- م.ر -
-
-

محمد رضایی

-

09121234567

- -
-
-
- ) -} + const { data, error, isLoading } = useAccount(); -export default ProfilePersonDetailCard \ No newline at end of file + if (isLoading) { + return ( +
+

در حال بارگذاری...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (!data) { + return ( +
+

اطلاعات پروفایل یافت نشد

+
+ ); + } + + const displayName = getDisplayNameHelper(data); + const initials = getInitialsHelper(data); + + return ( +
+
+
+ {data.avatar ? ( + {displayName} + ) : ( + {initials} + )} +
+
+

{displayName}

+ + {data.phone_number && ( +

+ {data.phone_number} +

+ )} + + +
+
+
+ ); +}; + +export default ProfilePersonDetailCard; \ No newline at end of file diff --git a/src/components/wallet/transaction-history.tsx b/src/components/wallet/transaction-history.tsx index c168338..38d9721 100644 --- a/src/components/wallet/transaction-history.tsx +++ b/src/components/wallet/transaction-history.tsx @@ -27,7 +27,7 @@ const TransactionHistory = () => { key={item.id} amount={item.amount} description={item.description} - type={item.type} // حالا نوعش دقیقاً 'deposit' | 'withdraw' هست + type={item.type} isLast={index === transactions.length - 1} /> ))} diff --git a/src/components/wallet/transaction-item.tsx b/src/components/wallet/transaction-item.tsx index 45c0f38..6bf2c7c 100644 --- a/src/components/wallet/transaction-item.tsx +++ b/src/components/wallet/transaction-item.tsx @@ -1,48 +1,49 @@ import React from 'react'; -import { ArrowDownCircle, ArrowDownIcon, ArrowUp, ArrowUpCircle } from 'lucide-react'; +import { ArrowDownIcon, ArrowUp } from 'lucide-react'; import { formatPriceWithToman } from '@/lib/utils/priceUtils'; interface TransactionItemProps { - amount: number; - description: string; - type: 'deposit' | 'withdraw'; - isLast?: boolean; + amount: number; + description: string; + type: 'deposit' | 'withdraw'; + isLast?: boolean; } const TransactionItem = ({ - amount, - description, - type, - isLast = false, + amount, + description, + type, + isLast = false, }: TransactionItemProps) => { - const isDeposit = type === 'deposit'; - const Icon = isDeposit ? ArrowDownIcon : ArrowUp; - const amountColor = isDeposit ? 'text-[#2E7D32]' : 'text-[#D32F2F]'; - const bgColor = isDeposit ? 'bg-[#2E7D3210]' : 'bg-[#D32F2F10]'; - const iconColor = isDeposit ? 'text-[#2E7D32]' : 'text-[#D32F2F]'; + 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 ( -
-
-
- -
- {description} -
- -
- - {isDeposit ? '+' : '-'} {formatPriceWithToman(amount)} - - ۱۴۰۳/۰۸/۲۴ -
+ > +
+
+
- ); + {description} +
+ +
+ + {isDeposit ? '+' : '-'} {formatPriceWithToman(amount)} + + ۱۴۰۳/۰۸/۲۴ +
+
+ ); }; export default TransactionItem; \ No newline at end of file diff --git a/src/components/wallet/wallet-blanc-card.tsx b/src/components/wallet/wallet-blanc-card.tsx index b87a119..26b073f 100644 --- a/src/components/wallet/wallet-blanc-card.tsx +++ b/src/components/wallet/wallet-blanc-card.tsx @@ -1,28 +1,52 @@ 'use client'; import { formatPriceWithToman } from '@/lib/utils/priceUtils'; -import { LogOutIcon, PlusIcon } from 'lucide-react' -import React from 'react' +import { LogOutIcon, PlusIcon } from 'lucide-react'; const WalletBlancCard = () => { return ( -
-

موجودی کیف پول

-

{formatPriceWithToman(35000000)}

+
+

+ موجودی کیف پول +

+

+ {formatPriceWithToman(35000000)} +

- -
- ) -} + ); +}; -export default WalletBlancCard \ No newline at end of file +export default WalletBlancCard; \ No newline at end of file diff --git a/src/lib/api/account.ts b/src/lib/api/account.ts new file mode 100644 index 0000000..0ec2102 --- /dev/null +++ b/src/lib/api/account.ts @@ -0,0 +1,8 @@ +"use server"; + +import { gooyalFetch } from "./gooyal-accounts"; +import type { Account } from "@/types/api"; + +export async function getAccount(): Promise { + return gooyalFetch("/users/api/account/"); +} \ No newline at end of file diff --git a/src/lib/api/gooyal-accounts.ts b/src/lib/api/gooyal-accounts.ts new file mode 100644 index 0000000..92a0e16 --- /dev/null +++ b/src/lib/api/gooyal-accounts.ts @@ -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 { + auth?: boolean; + params?: Record; + body?: unknown; +} + +export async function gooyalFetch(path: string, options: GooyalFetchOptions = {}): Promise { + 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; +} \ No newline at end of file diff --git a/src/lib/auth/gooyal-client.ts b/src/lib/auth/gooyal-client.ts index 132c4a2..6b5e404 100644 --- a/src/lib/auth/gooyal-client.ts +++ b/src/lib/auth/gooyal-client.ts @@ -95,4 +95,4 @@ export async function exchangeOtpForToken(phoneNumber: string, otp: string) { throw new GooyalClientError(res.status, data.error ?? "token_exchange_failed", data.error_description); } return data as GooyalTokenResponse; -} +} \ No newline at end of file diff --git a/src/lib/providers/account-provider.tsx b/src/lib/providers/account-provider.tsx new file mode 100644 index 0000000..fe4ba14 --- /dev/null +++ b/src/lib/providers/account-provider.tsx @@ -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(undefined); + +export function AccountProvider({ + children, + account, + error = null, +}: { + children: ReactNode; + account: Account | null; + error?: string | null; +}) { + return ( + + {children} + + ); +} + +export function useAccount() { + const ctx = useContext(AccountContext); + if (ctx === undefined) { + throw new Error("useAccount must be used within AccountProvider"); + } + return ctx; +} \ No newline at end of file diff --git a/src/lib/utils/account-utils.ts b/src/lib/utils/account-utils.ts new file mode 100644 index 0000000..fbd53e9 --- /dev/null +++ b/src/lib/utils/account-utils.ts @@ -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 || 'کاربر'; +} \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index c0c86e3..2945024 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; +} \ No newline at end of file