feat: add customer authentication and order dashboard

This commit is contained in:
2026-09-16 09:06:48 +03:30
parent f49ef11c9c
commit 7c55b3916c
31 changed files with 2193 additions and 53 deletions
+31 -12
View File
@@ -4,20 +4,38 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add cart and product pages, quantity controls, and first-visit development notice"> <list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="feat: add customer authentication and order dashboard">
<change afterPath="$PROJECT_DIR$/app/context/AuthContext.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/dashboard/layout.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/dashboard/orders/[id]/page.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/dashboard/orders/page.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/dashboard/page.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/dashboard/profile/page.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/app/login/page.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/auth/AuthModal.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/auth/ProtectedLink.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/auth/ProtectedPage.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/dashboard/DashboardShell.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/dashboard/InvoiceDocument.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/dashboard/OrderCard.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/components/dashboard/OrderStatusTag.tsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/data/account.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/lib/invoice.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/services/auth.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/services/orders.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/services/profile.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/types/account.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/app/cart/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/cart/page.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/globals.css" beforeDir="false" afterPath="$PROJECT_DIR$/app/globals.css" afterDir="false" />
<change beforePath="$PROJECT_DIR$/app/products/[id]/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/products/[id]/page.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/app/products/[id]/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/products/[id]/page.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/CartPage.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/CartPage.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/app/providers.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/providers.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Footer.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Footer.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/components/Header.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/Header.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HorizontalShowcase.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/components/HeroSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/HeroSection.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/MobileShowcase.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/MobileShowcase.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/components/QuantityControl.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/QuantityControl.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/ProductDetails.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ProductDetails.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/components/VendingCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/VendingCard.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/components/ShowcaseSection.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/ShowcaseSection.tsx" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/commerce.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/commerce.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/data/content.ts" beforeDir="false" afterPath="$PROJECT_DIR$/data/content.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/types/domain.ts" beforeDir="false" afterPath="$PROJECT_DIR$/types/domain.ts" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -540,6 +558,7 @@
<MESSAGE value="refactor: remove disabled home sections and their unused assets" /> <MESSAGE value="refactor: remove disabled home sections and their unused assets" />
<MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" /> <MESSAGE value="refactor: migrate project to TypeScript and translate comments to English" />
<MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" /> <MESSAGE value="feat: add cart and product pages, quantity controls, and first-visit development notice" />
<option name="LAST_COMMIT_MESSAGE" value="feat: add cart and product pages, quantity controls, and first-visit development notice" /> <MESSAGE value="feat: add customer authentication and order dashboard" />
<option name="LAST_COMMIT_MESSAGE" value="feat: add customer authentication and order dashboard" />
</component> </component>
</project> </project>
+2 -1
View File
@@ -1,8 +1,9 @@
import type {Metadata} from 'next'; import type {Metadata} from 'next';
import CartPage from '@/components/CartPage'; import CartPage from '@/components/CartPage';
import ProtectedPage from '@/components/auth/ProtectedPage';
export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'}; export const metadata: Metadata = {title: 'سبد خرید | روبات مارکت'};
export default function Page() { export default function Page() {
return <CartPage/>; return <ProtectedPage><CartPage/></ProtectedPage>;
} }
+102
View File
@@ -0,0 +1,102 @@
'use client';
import type {ReactNode} from 'react';
import type {AuthSession, CustomerProfile} from '@/types/account';
import {createContext, useCallback, useContext, useEffect, useRef, useState} from 'react';
import {AUTH_STORAGE_KEY, loginWithOtp, loginWithPassword, requestOtp} from '@/services/auth';
import {saveProfile} from '@/services/profile';
import AuthModal from '@/components/auth/AuthModal';
interface AuthContextValue {
session: AuthSession | null;
isAuthenticated: boolean;
ready: boolean;
loginOpen: boolean;
openLogin: (onSuccess?: () => void) => void;
closeLogin: () => void;
sendOtp: (phone: string) => Promise<void>;
verifyOtp: (phone: string, code: string) => Promise<void>;
loginPassword: (phone: string, password: string) => Promise<void>;
updateProfile: (profile: CustomerProfile) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({children}: {children: ReactNode}) {
const [session, setSession] = useState<AuthSession | null>(null);
const [ready, setReady] = useState(false);
const [loginOpen, setLoginOpen] = useState(false);
const successCallback = useRef<(() => void) | undefined>();
useEffect(() => {
try {
const saved = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (saved) setSession(JSON.parse(saved) as AuthSession);
} catch {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
} finally {
setReady(true);
}
}, []);
const persist = useCallback((next: AuthSession | null) => {
setSession(next);
if (next) {
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(next));
window.localStorage.setItem('rm-auth-token', next.accessToken);
} else {
window.localStorage.removeItem(AUTH_STORAGE_KEY);
window.localStorage.removeItem('rm-auth-token');
}
}, []);
const finishLogin = useCallback((next: AuthSession) => {
persist(next);
setLoginOpen(false);
const callback = successCallback.current;
successCallback.current = undefined;
callback?.();
}, [persist]);
const openLogin = useCallback((onSuccess?: () => void) => {
successCallback.current = onSuccess;
setLoginOpen(true);
}, []);
const closeLogin = useCallback(() => {
successCallback.current = undefined;
setLoginOpen(false);
}, []);
const value: AuthContextValue = {
session,
isAuthenticated: Boolean(session),
ready,
loginOpen,
openLogin,
closeLogin,
sendOtp: async phone => { await requestOtp(phone); },
verifyOtp: async (phone, code) => finishLogin(await loginWithOtp(phone, code)),
loginPassword: async (phone, password) => finishLogin(await loginWithPassword(phone, password)),
updateProfile: async profile => {
if (!session) return;
const saved = await saveProfile(profile);
persist({...session, profile: saved});
},
logout: () => persist(null),
};
return (
<AuthContext.Provider value={value}>
{children}
<AuthModal/>
</AuthContext.Provider>
);
}
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error('useAuth must be used within AuthProvider');
return value;
}
+7
View File
@@ -0,0 +1,7 @@
import type {ReactNode} from 'react';
import ProtectedPage from '@/components/auth/ProtectedPage';
import DashboardShell from '@/components/dashboard/DashboardShell';
export default function Layout({children}: {children: ReactNode}) {
return <ProtectedPage><DashboardShell>{children}</DashboardShell></ProtectedPage>;
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import {useEffect, useRef, useState} from 'react';
import Link from 'next/link';
import {Alert, App, Button, Empty, Skeleton, Steps} from 'antd';
import {DownloadOutlined, FileExcelOutlined, PrinterOutlined} from '@ant-design/icons';
import type {CustomerOrder} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy, orderStatusLabels, paymentStatusLabels} from '@/data/account';
import {getOrder} from '@/services/orders';
import {downloadInvoiceExcel, downloadInvoicePdf} from '@/lib/invoice';
import OrderStatusTag from '@/components/dashboard/OrderStatusTag';
import InvoiceDocument from '@/components/dashboard/InvoiceDocument';
export default function OrderDetailsPage({params}: {params: {id: string}}) {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [order, setOrder] = useState<CustomerOrder | null | undefined>(null);
const [exporting, setExporting] = useState<'pdf' | 'excel' | null>(null);
const invoiceRef = useRef<HTMLDivElement>(null);
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
useEffect(() => { if (auth.session) getOrder(params.id, auth.session.profile).then(setOrder); }, [auth.session, params.id]);
if (order === null) return <Skeleton active/>;
if (!order) return <div><Empty description={copy.notFound}/><Link href="/dashboard/orders" className="text-brand-400 block text-center mt-5">{copy.backToOrders}</Link></div>;
const exportPdf = async () => {
if (!invoiceRef.current) return;
setExporting('pdf');
try { await downloadInvoicePdf(invoiceRef.current, order.invoice!.number); }
catch { await message.error('ساخت فایل PDF انجام نشد. دوباره تلاش کنید.'); }
finally { setExporting(null); }
};
const exportExcel = async () => {
setExporting('excel');
try { await downloadInvoiceExcel(order, order.invoice!.number); }
catch { await message.error('ساخت فایل Excel انجام نشد. دوباره تلاش کنید.'); }
finally { setExporting(null); }
};
return <>
<Link href="/dashboard/orders" className="text-brand-400 hover:underline">{copy.backToOrders}</Link>
<div className="flex flex-wrap justify-between gap-4 items-center mt-5">
<div><p className="text-secondary">{copy.orderNumber}</p><h1 className="text-3xl font-extrabold text-primary" dir="ltr">{order.number}</h1></div>
<OrderStatusTag status={order.status}/>
</div>
<div className="grid sm:grid-cols-3 gap-4 mt-7">
<div className="rounded-xl border border-subtle bg-elevated p-4"><span className="text-sm text-secondary">{copy.orderDate}</span><strong className="block text-primary mt-1">{order.createdAt}</strong></div>
<div className="rounded-xl border border-subtle bg-elevated p-4"><span className="text-sm text-secondary">{copy.lastUpdate}</span><strong className="block text-primary mt-1">{order.updatedAt}</strong></div>
<div className="rounded-xl border border-subtle bg-elevated p-4"><span className="text-sm text-secondary">{copy.paymentStatus}</span><strong className="block text-primary mt-1">{paymentStatusLabels[locale][order.paymentStatus]}</strong></div>
</div>
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
<h2 className="text-xl font-bold text-primary mb-6">{copy.timeline}</h2>
<Steps direction="vertical" current={order.events.length - 1} items={order.events.map(event => ({title: orderStatusLabels[locale][event.status], description: `${event.date} — ${event.note}`, status: event.completed ? 'finish' : 'wait'}))}/>
</section>
<section className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7 mt-7">
<h2 className="text-xl font-bold text-primary mb-5">{copy.orderItems}</h2>
<div className="space-y-5">{order.items.map(item => <article key={item.id} className="flex flex-wrap sm:flex-nowrap gap-5 border-b border-subtle last:border-0 pb-5 last:pb-0">
<img src={item.image} alt={item.name} className="w-24 h-28 object-contain rounded-xl bg-surface"/>
<div className="flex-1"><h3 className="font-bold text-primary">{item.name}</h3><p className="text-sm text-secondary mt-1" dir="ltr">{item.model}</p><p className="text-secondary mt-3">{copy.modules}: {item.modules.join('، ')}</p><p className="text-primary mt-3">{copy.quantity}: {item.quantity.toLocaleString(numberLocale)} · {copy.total}: {money(item.quantity * item.unitPrice)}</p></div>
</article>)}</div>
</section>
<section className="mt-7">
<h2 className="text-xl font-bold text-primary mb-5">{copy.invoice}</h2>
{!order.invoice ? <Alert type="info" showIcon message={copy.invoicePending}/> : <>
<div className="flex flex-wrap gap-3 mb-5 invoice-actions">
<Button type="primary" icon={<DownloadOutlined/>} loading={exporting === 'pdf'} onClick={exportPdf}>{copy.downloadPdf}</Button>
<Button icon={<FileExcelOutlined/>} loading={exporting === 'excel'} onClick={exportExcel}>{copy.downloadExcel}</Button>
<Button icon={<PrinterOutlined/>} onClick={() => window.print()}>{copy.print}</Button>
</div>
<div className="overflow-x-auto rounded-2xl border border-subtle"><InvoiceDocument ref={invoiceRef} order={order}/></div>
</>}
</section>
</>;
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import {useEffect, useState} from 'react';
import {Empty, Skeleton} from 'antd';
import type {CustomerOrder} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
import {getOrders} from '@/services/orders';
import OrderCard from '@/components/dashboard/OrderCard';
export default function OrdersPage() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
useEffect(() => { if (auth.session) getOrders(auth.session.profile).then(setOrders); }, [auth.session]);
return <>
<h1 className="text-3xl font-extrabold text-primary">{copy.orders}</h1>
<p className="text-secondary mt-3 mb-7">{copy.dashboardDescription}</p>
{!orders ? <Skeleton active/> : orders.length ? <div className="space-y-4">{orders.map(order => <OrderCard key={order.id} order={order}/>)}</div> : <Empty description={copy.emptyOrders}/>}
</>;
}
+42
View File
@@ -0,0 +1,42 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import Link from 'next/link';
import {Progress, Skeleton} from 'antd';
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
import type {CustomerOrder} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
import {getOrders} from '@/services/orders';
import OrderCard from '@/components/dashboard/OrderCard';
export default function DashboardPage() {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
useEffect(() => { if (auth.session) getOrders(auth.session.profile).then(setOrders); }, [auth.session]);
const completion = useMemo(() => {
if (!auth.session) return 0;
const profile = auth.session.profile;
const required = [profile.firstName, profile.lastName, profile.province, profile.city, profile.address, profile.postalCode];
return Math.round(required.filter(Boolean).length / required.length * 100);
}, [auth.session]);
if (!orders) return <Skeleton active/>;
const activeOrders = orders.filter(order => !['delivered', 'cancelled'].includes(order.status)).length;
const cards = [
{label: copy.profileCompletion, value: `${completion.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}٪`, icon: <CheckCircleOutlined/>, extra: <Progress percent={completion} showInfo={false} size="small"/>},
{label: copy.activeOrders, value: activeOrders.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'), icon: <ClockCircleOutlined/>},
{label: copy.totalOrders, value: orders.length.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US'), icon: <FileTextOutlined/>},
];
return <>
<h1 className="text-3xl font-extrabold text-primary">{copy.welcome} {auth.session?.profile.firstName}</h1>
<p className="text-secondary mt-3">{copy.dashboardDescription}</p>
<div className="grid sm:grid-cols-3 gap-4 mt-8">{cards.map(card => <div key={card.label} className="rounded-2xl border border-subtle bg-elevated p-5">
<div className="text-brand-400 text-2xl">{card.icon}</div><p className="text-secondary text-sm mt-4">{card.label}</p><strong className="text-2xl text-primary block mt-1">{card.value}</strong>{card.extra && <div className="mt-3">{card.extra}</div>}
</div>)}</div>
<div className="flex items-center justify-between gap-4 mt-10 mb-5"><h2 className="text-xl font-bold text-primary">{copy.recentOrders}</h2><Link href="/dashboard/orders" className="text-brand-400">{copy.viewAll}</Link></div>
<div className="space-y-4">{orders.slice(0, 2).map(order => <OrderCard key={order.id} order={order}/>)}</div>
</>;
}
+48
View File
@@ -0,0 +1,48 @@
'use client';
import {useEffect, useState} from 'react';
import {Alert, App, Button, Form, Input, Radio} from 'antd';
import type {CustomerProfile} from '@/types/account';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function ProfilePage() {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [form] = Form.useForm<CustomerProfile>();
const [saving, setSaving] = useState(false);
const customerType = Form.useWatch('type', form);
useEffect(() => { if (auth.session) form.setFieldsValue(auth.session.profile); }, [auth.session, form]);
const submit = async (values: CustomerProfile) => {
setSaving(true);
try { await auth.updateProfile(values); message.success(copy.saved); } finally { setSaving(false); }
};
return <>
<h1 className="text-3xl font-extrabold text-primary">{copy.profile}</h1>
<p className="text-secondary mt-3 mb-7">{copy.invoicePending}</p>
<Form form={form} layout="vertical" onFinish={submit} className="rounded-2xl border border-subtle bg-elevated p-5 md:p-7">
<Form.Item name="type" label={copy.customerType} rules={[{required: true}]}><Radio.Group optionType="button" buttonStyle="solid" options={[{label: copy.individual, value: 'individual'}, {label: copy.corporate, value: 'corporate'}]}/></Form.Item>
<div className="grid md:grid-cols-2 gap-x-5">
<Form.Item name="firstName" label={copy.firstName} rules={[{required: true}]}><Input size="large"/></Form.Item>
<Form.Item name="lastName" label={copy.lastName} rules={[{required: true}]}><Input size="large"/></Form.Item>
<Form.Item name="phone" label={copy.phone}><Input size="large" dir="ltr" disabled/></Form.Item>
<Form.Item name="email" label={copy.email} rules={[{type: 'email'}]}><Input size="large" dir="ltr"/></Form.Item>
{customerType === 'corporate' && <>
<Form.Item name="companyName" label={copy.companyName} rules={[{required: true}]}><Input size="large"/></Form.Item>
<Form.Item name="nationalId" label={copy.nationalId} rules={[{required: true}]}><Input size="large" dir="ltr"/></Form.Item>
<Form.Item name="economicCode" label={copy.economicCode}><Input size="large" dir="ltr"/></Form.Item>
</>}
<Form.Item name="province" label={copy.province} rules={[{required: true}]}><Input size="large"/></Form.Item>
<Form.Item name="city" label={copy.city} rules={[{required: true}]}><Input size="large"/></Form.Item>
<Form.Item name="postalCode" label={copy.postalCode} rules={[{required: true}]}><Input size="large" dir="ltr" maxLength={10}/></Form.Item>
<Form.Item name="preferredCallTime" label={copy.preferredCallTime}><Input size="large"/></Form.Item>
</div>
<Form.Item name="address" label={copy.address} rules={[{required: true}]}><Input.TextArea rows={4}/></Form.Item>
<Alert type="info" showIcon message={copy.verifiedPhone} className="mb-6"/>
<Button type="primary" htmlType="submit" size="large" loading={saving}>{copy.save}</Button>
</Form>
</>;
}
+43
View File
@@ -211,6 +211,49 @@ html[dir='rtl'] body {
animation: caretBlink 0.9s steps(1) infinite; animation: caretBlink 0.9s steps(1) infinite;
} }
.invoice-document {
width: 900px;
min-height: 1120px;
padding: 48px;
background: #ffffff;
color: #12332e;
font-family: var(--font-vazirmatn), Tahoma, sans-serif;
}
.invoice-head,
.invoice-meta {
display: flex;
justify-content: space-between;
gap: 28px;
}
.invoice-head {
align-items: center;
padding-bottom: 24px;
border-bottom: 2px solid #00a693;
}
.invoice-head img { width: 150px; height: auto; }
.invoice-head h2 { margin: 0 0 8px; font-size: 26px; }
.invoice-head p, .invoice-meta p { margin: 4px 0; }
.invoice-meta { margin: 28px 0; }
.invoice-meta > div { flex: 1; line-height: 1.9; }
.invoice-document table { width: 100%; border-collapse: collapse; font-size: 13px; }
.invoice-document th { background: #e9f8f5; }
.invoice-document th, .invoice-document td { border: 1px solid #b9ded7; padding: 12px 9px; text-align: start; vertical-align: top; }
.invoice-document td small { display: block; color: #55756f; margin-top: 5px; }
.invoice-totals { width: 380px; margin-inline-start: auto; margin-top: 24px; }
.invoice-totals p { display: flex; justify-content: space-between; gap: 20px; margin: 0; padding: 9px 0; border-bottom: 1px solid #dcebe8; }
.invoice-totals .grand { color: #006d61; font-size: 16px; border-bottom: 2px solid #00a693; }
.invoice-note { margin-top: 40px; padding: 16px; background: #f2faf8; border-radius: 10px; line-height: 2; }
@media print {
body * { visibility: hidden !important; }
.invoice-document, .invoice-document * { visibility: visible !important; }
.invoice-document { position: absolute; inset: 0; width: 100%; min-height: auto; padding: 20px; }
.invoice-actions { display: none !important; }
}
@keyframes caretBlink { @keyframes caretBlink {
0%, 49% { 0%, 49% {
opacity: 1; opacity: 1;
+22
View File
@@ -0,0 +1,22 @@
'use client';
import {useEffect} from 'react';
import {useRouter} from 'next/navigation';
import {Button} from 'antd';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function LoginPage() {
const auth = useAuth();
const router = useRouter();
const {locale} = useLanguage();
const copy = accountCopy[locale];
useEffect(() => {
if (auth.isAuthenticated) router.replace('/dashboard');
else if (auth.ready) auth.openLogin(() => router.replace('/dashboard'));
}, [auth.isAuthenticated, auth.ready, auth.openLogin, router]);
return <main className="max-w-2xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
<div className="rounded-3xl border border-subtle bg-elevated p-10"><h1 className="text-3xl font-extrabold text-primary">{copy.login}</h1><p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p><Button type="primary" size="large" onClick={() => auth.openLogin(() => router.replace('/dashboard'))}>{copy.openLogin}</Button></div>
</main>;
}
+2 -1
View File
@@ -2,6 +2,7 @@ import type {Metadata} from 'next';
import {notFound} from 'next/navigation'; import {notFound} from 'next/navigation';
import {content} from '@/data/content'; import {content} from '@/data/content';
import ProductDetails from '@/components/ProductDetails'; import ProductDetails from '@/components/ProductDetails';
import ProtectedPage from '@/components/auth/ProtectedPage';
type Props = { params: { id: string } }; type Props = { params: { id: string } };
@@ -17,5 +18,5 @@ export function generateMetadata({params}: Props): Metadata {
export default function ProductPage({params}: Props) { export default function ProductPage({params}: Props) {
if (!content.fa.machines.some(machine => machine.id === params.id)) notFound(); if (!content.fa.machines.some(machine => machine.id === params.id)) notFound();
return <ProductDetails id={params.id}/>; return <ProtectedPage><ProductDetails id={params.id}/></ProtectedPage>;
} }
+6 -3
View File
@@ -5,13 +5,14 @@ import type {ReactNode} from 'react';
import {Provider as ReduxProvider} from 'react-redux'; import {Provider as ReduxProvider} from 'react-redux';
import type {ThemeConfig} from 'antd'; import type {ThemeConfig} from 'antd';
import {ConfigProvider, theme as antdThemeApi} from 'antd'; import {App as AntdApp, ConfigProvider, theme as antdThemeApi} from 'antd';
import faIR from 'antd/locale/fa_IR'; import faIR from 'antd/locale/fa_IR';
import enUS from 'antd/locale/en_US'; import enUS from 'antd/locale/en_US';
import {store} from '@/store/store'; import {store} from '@/store/store';
import {ThemeProvider, useTheme} from './context/ThemeContext'; import {ThemeProvider, useTheme} from './context/ThemeContext';
import {LanguageProvider, useLanguage} from './context/LanguageContext'; import {LanguageProvider, useLanguage} from './context/LanguageContext';
import CartPersistence from '@/components/CartPersistence'; import CartPersistence from '@/components/CartPersistence';
import {AuthProvider} from './context/AuthContext';
function AntdBridge({children}: {children: ReactNode}) { function AntdBridge({children}: {children: ReactNode}) {
const {theme} = useTheme(); const {theme} = useTheme();
@@ -43,7 +44,7 @@ function AntdBridge({children}: {children: ReactNode}) {
return ( return (
<ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}> <ConfigProvider direction={dir} locale={locale === 'fa' ? faIR : enUS} theme={antdTheme}>
{children} <AntdApp>{children}</AntdApp>
</ConfigProvider> </ConfigProvider>
); );
} }
@@ -54,7 +55,9 @@ export default function Providers({children}: {children: ReactNode}) {
<CartPersistence/> <CartPersistence/>
<ThemeProvider> <ThemeProvider>
<LanguageProvider> <LanguageProvider>
<AntdBridge>{children}</AntdBridge> <AntdBridge>
<AuthProvider>{children}</AuthProvider>
</AntdBridge>
</LanguageProvider> </LanguageProvider>
</ThemeProvider> </ThemeProvider>
</ReduxProvider> </ReduxProvider>
+20 -21
View File
@@ -12,6 +12,9 @@ import {useAppSelector} from '@/store/hooks';
import {selectCartCount} from '@/store/cartSlice'; import {selectCartCount} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce'; import {commerceCopy} from '@/data/commerce';
import {accountCopy} from '@/data/account';
import {useAuth} from '@/app/context/AuthContext';
import ProtectedLink from './auth/ProtectedLink';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
import LanguageToggle from './LanguageToggle'; import LanguageToggle from './LanguageToggle';
@@ -19,6 +22,8 @@ export default function Header() {
const pathname = usePathname(); const pathname = usePathname();
const cartCount = useAppSelector(selectCartCount); const cartCount = useAppSelector(selectCartCount);
const {t, locale} = useLanguage(); const {t, locale} = useLanguage();
const auth = useAuth();
const account = accountCopy[locale];
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
@@ -28,6 +33,9 @@ export default function Header() {
{href: '/#about', label: t.nav.about}, {href: '/#about', label: t.nav.about},
{href: '/#locations', label: t.nav.locations}, {href: '/#locations', label: t.nav.locations},
]; ];
const accountLabel = auth.session?.profile.firstName
? `${auth.session.profile.firstName} ${auth.session.profile.lastName}`.trim()
: auth.session?.profile.phone || account.login;
useEffect(() => { useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 24); const onScroll = () => setScrolled(window.scrollY > 24);
@@ -70,21 +78,17 @@ export default function Header() {
<ThemeToggle/> <ThemeToggle/>
</div> </div>
<Button {auth.isAuthenticated ? (
className="hidden sm:inline-flex" <Button className="hidden sm:inline-flex" icon={<UserOutlined/>} href="/dashboard">{accountLabel}</Button>
icon={<UserOutlined/>} ) : (
href="https://panel.my-rm.com" <Button className="hidden sm:inline-flex" icon={<UserOutlined/>} onClick={() => auth.openLogin()}>{account.login}</Button>
target="_blank" )}
rel="noopener noreferrer"
>
{t.nav.login}
</Button>
<Badge count={cartCount} size="small" offset={[-2, 2]}> <Badge count={cartCount} size="small" offset={[-2, 2]}>
<Link href="/cart" onClick={() => setMobileOpen(false)} aria-label={t.cart.title} <ProtectedLink href="/cart" onClick={() => setMobileOpen(false)}
className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400"> className="w-11 h-11 rounded-full border border-subtle bg-elevated inline-flex items-center justify-center text-xl text-primary hover:text-brand-400">
<ShoppingCartOutlined/> <ShoppingCartOutlined/>
</Link> </ProtectedLink>
</Badge> </Badge>
<button <button
@@ -113,16 +117,11 @@ export default function Header() {
<div className="flex items-center gap-3 pt-2 border-t border-subtle"> <div className="flex items-center gap-3 pt-2 border-t border-subtle">
<LanguageToggle/> <LanguageToggle/>
<ThemeToggle/> <ThemeToggle/>
<Button {auth.isAuthenticated ? (
icon={<UserOutlined/>} <Button icon={<UserOutlined/>} className="flex-1" href="/dashboard" onClick={() => setMobileOpen(false)}>{accountLabel}</Button>
className="flex-1" ) : (
href="https://panel.my-rm.com" <Button icon={<UserOutlined/>} className="flex-1" onClick={() => {setMobileOpen(false); auth.openLogin();}}>{account.login}</Button>
target="_blank" )}
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
>
{t.nav.login}
</Button>
</div> </div>
</nav> </nav>
)} )}
+9 -5
View File
@@ -5,11 +5,12 @@
import {useCallback, useEffect, useRef, useState} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react';
import {LeftOutlined, RightOutlined} from '@ant-design/icons'; import {LeftOutlined, RightOutlined} from '@ant-design/icons';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
const AUTOPLAY_DELAY = 5000; // Advance every five seconds. const AUTOPLAY_DELAY = 5000; // Advance every five seconds.
export default function HeroSection() { export default function HeroSection() {
const {t, dir} = useLanguage(); const {t, dir, locale} = useLanguage();
const slides = t.hero.slides; const slides = t.hero.slides;
const [activeIndex, setActiveIndex] = useState(0); const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -107,12 +108,15 @@ export default function HeroSection() {
))} ))}
</h1> </h1>
<p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p> <p className="mt-5 text-white/90 text-base md:text-lg max-w-xl drop-shadow-lg">{slide.sub}</p>
<a <div className="mt-8 flex flex-wrap gap-3">
href="#showcase" <a href="#showcase" className="inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow">
className="mt-8 inline-flex items-center gap-2 bg-brand hover:bg-brand-400 text-ink-950 font-bold px-8 py-3.5 rounded-full transition-colors shadow-brandGlow"
>
{t.hero.cta} {t.hero.cta}
</a> </a>
<a href="https://panel.my-rm.com" target="_blank" rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white border border-white/30 font-bold px-8 py-3.5 rounded-full transition-colors backdrop-blur-sm">
{accountCopy[locale].robotMasterPanel}
</a>
</div>
</div> </div>
</div> </div>
+8 -3
View File
@@ -7,11 +7,13 @@ import {useAppDispatch, useAppSelector} from '@/store/hooks';
import {addToCart, decrementQty} from '@/store/cartSlice'; import {addToCart, decrementQty} from '@/store/cartSlice';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import {commerceCopy} from '@/data/commerce'; import {commerceCopy} from '@/data/commerce';
import {useAuth} from '@/app/context/AuthContext';
export default function QuantityControl({product}: {product: CartProduct}) { export default function QuantityControl({product}: {product: CartProduct}) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const {locale} = useLanguage(); const {locale} = useLanguage();
const copy = commerceCopy[locale]; const copy = commerceCopy[locale];
const auth = useAuth();
const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0); const quantity = useAppSelector(state => state.cart.items.find(item => item.id === product.id)?.qty ?? 0);
return ( return (
@@ -19,14 +21,17 @@ export default function QuantityControl({product}: {product: CartProduct}) {
className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1" dir="ltr"> className="inline-flex items-center gap-3 rounded-xl border border-subtle bg-page p-1" dir="ltr">
<Button icon={<MinusOutlined/>} disabled={quantity === 0} <Button icon={<MinusOutlined/>} disabled={quantity === 0}
aria-label={`${copy.decrease}: ${product.name}`} aria-label={`${copy.decrease}: ${product.name}`}
onClick={() => dispatch(decrementQty(product.id))}/> onClick={() => auth.isAuthenticated ? dispatch(decrementQty(product.id)) : auth.openLogin()}/>
<span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary"> <span aria-live="polite" aria-atomic="true" className="min-w-8 text-center font-bold text-primary">
{quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')} {quantity.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
</span> </span>
<Button type="primary" icon={<PlusOutlined/>} <Button type="primary" icon={<PlusOutlined/>}
aria-label={`${copy.increase}: ${product.name}`} aria-label={`${copy.increase}: ${product.name}`}
onClick={() => dispatch(addToCart({id: product.id, name: product.name, image: product.image, onClick={() => {
priceLabel: product.priceLabel, priceNumeric: product.priceNumeric}))}/> const add = () => dispatch(addToCart({id: product.id, name: product.name, image: product.image,
priceLabel: product.priceLabel, priceNumeric: product.priceNumeric}));
if (auth.isAuthenticated) add(); else auth.openLogin(add);
}}/>
</div> </div>
); );
} }
+3 -3
View File
@@ -4,8 +4,8 @@
import type {Machine} from '@/types/domain'; import type {Machine} from '@/types/domain';
import type {MouseEvent} from 'react'; import type {MouseEvent} from 'react';
import {useRef} from 'react'; import {useRef} from 'react';
import Link from 'next/link';
import QuantityControl from './QuantityControl'; import QuantityControl from './QuantityControl';
import ProtectedLink from './auth/ProtectedLink';
import {commerceCopy} from '@/data/commerce'; import {commerceCopy} from '@/data/commerce';
import {useLanguage} from '@/app/context/LanguageContext'; import {useLanguage} from '@/app/context/LanguageContext';
import FloatingItems from './FloatingItems'; import FloatingItems from './FloatingItems';
@@ -89,9 +89,9 @@ export default function VendingCard({machine}: {machine: Machine}) {
<div className="flex flex-wrap items-center gap-2 mt-2 "> <div className="flex flex-wrap items-center gap-2 mt-2 ">
<QuantityControl product={machine}/> <QuantityControl product={machine}/>
<Link href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400"> <ProtectedLink href={`/products/${machine.id}`} className="px-5 py-3 rounded-xl border border-subtle text-primary hover:text-brand-400">
{commerceCopy[locale].details} {commerceCopy[locale].details}
</Link> </ProtectedLink>
</div> </div>
</div> </div>
</div> </div>
+85
View File
@@ -0,0 +1,85 @@
'use client';
import {useEffect, useState} from 'react';
import {Alert, App, Button, Form, Input, Modal, Segmented, Typography} from 'antd';
import {LockOutlined, MobileOutlined, SafetyCertificateOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
type LoginMode = 'otp' | 'password';
export default function AuthModal() {
const auth = useAuth();
const {message} = App.useApp();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const [mode, setMode] = useState<LoginMode>('otp');
const [step, setStep] = useState<'phone' | 'code'>('phone');
const [phone, setPhone] = useState('09123456789');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (auth.loginOpen) {
setMode('otp');
setStep('phone');
setError('');
}
}, [auth.loginOpen]);
const submit = async (values: {phone?: string; code?: string; password?: string}) => {
setLoading(true);
setError('');
try {
const currentPhone = (values.phone || phone).replace(/\s/g, '');
if (mode === 'otp' && step === 'phone') {
await auth.sendOtp(currentPhone);
setPhone(currentPhone);
setStep('code');
message.success(copy.otpSent);
} else if (mode === 'otp') {
await auth.verifyOtp(currentPhone, values.code || '');
} else {
await auth.loginPassword(currentPhone, values.password || '');
}
} catch (caught) {
setError(caught instanceof Error && caught.message === 'INVALID_PHONE' ? copy.invalidPhone : copy.invalidCredentials);
} finally {
setLoading(false);
}
};
return (
<Modal open={auth.loginOpen} onCancel={auth.closeLogin} footer={null} centered title={copy.login} destroyOnClose>
<Segmented<LoginMode> block value={mode} onChange={value => {setMode(value); setStep('phone'); setError('');}}
options={[{label: copy.otpLogin, value: 'otp'}, {label: copy.passwordLogin, value: 'password'}]}/>
<Alert className="mt-5" type="info" showIcon message={copy.demoTitle}
description={<div><div>{copy.demoOtp}</div><div>{copy.demoPassword}</div></div>}/>
{error && <Alert className="mt-4" type="error" showIcon message={error}/>}
<Form layout="vertical" className="mt-5" onFinish={submit} initialValues={{phone: '09123456789', code: '12345', password: '123456'}}>
{(mode === 'password' || step === 'phone') && (
<Form.Item name="phone" label={copy.phone} rules={[{required: true, pattern: /^09\d{9}$/, message: copy.invalidPhone}]}>
<Input dir="ltr" size="large" maxLength={11} prefix={<MobileOutlined/>}/>
</Form.Item>
)}
{mode === 'password' && (
<Form.Item name="password" label={copy.password} rules={[{required: true}]}>
<Input.Password dir="ltr" size="large" prefix={<LockOutlined/>}/>
</Form.Item>
)}
{mode === 'otp' && step === 'code' && (
<>
<Typography.Paragraph type="secondary">{copy.phone}: <span dir="ltr">{phone}</span></Typography.Paragraph>
<Form.Item name="code" label={copy.otp} rules={[{required: true}]}>
<Input dir="ltr" size="large" maxLength={5} prefix={<SafetyCertificateOutlined/>}/>
</Form.Item>
</>
)}
<Button htmlType="submit" type="primary" size="large" block loading={loading}>
{mode === 'otp' ? (step === 'phone' ? copy.sendOtp : copy.verifyAndLogin) : copy.loginButton}
</Button>
</Form>
</Modal>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import type {MouseEvent, ReactNode} from 'react';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useAuth} from '@/app/context/AuthContext';
export default function ProtectedLink({href, children, className, onClick}: {href: string; children: ReactNode; className?: string; onClick?: () => void}) {
const auth = useAuth();
const router = useRouter();
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
onClick?.();
if (!auth.isAuthenticated) {
event.preventDefault();
auth.openLogin(() => router.push(href));
}
};
return <Link href={href} className={className} onClick={handleClick}>{children}</Link>;
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import type {ReactNode} from 'react';
import {useEffect, useRef} from 'react';
import {Button, Skeleton} from 'antd';
import {LockOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function ProtectedPage({children}: {children: ReactNode}) {
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const prompted = useRef(false);
useEffect(() => {
if (auth.ready && !auth.isAuthenticated && !prompted.current) {
prompted.current = true;
auth.openLogin();
}
}, [auth]);
if (!auth.ready) return <main className="max-w-5xl mx-auto min-h-[70vh] px-5 pt-32"><Skeleton active/></main>;
if (!auth.isAuthenticated) return (
<main className="max-w-3xl mx-auto min-h-[70vh] px-5 pt-32 pb-20 text-center">
<div className="rounded-3xl border border-subtle bg-surface p-10">
<LockOutlined className="text-4xl text-brand-400"/>
<h1 className="text-2xl font-extrabold text-primary mt-5">{copy.protectedTitle}</h1>
<p className="text-secondary mt-3 mb-7">{copy.protectedDescription}</p>
<Button type="primary" size="large" onClick={() => auth.openLogin()}>{copy.openLogin}</Button>
</div>
</main>
);
return children;
}
+44
View File
@@ -0,0 +1,44 @@
'use client';
import type {ReactNode} from 'react';
import Link from 'next/link';
import {usePathname, useRouter} from 'next/navigation';
import {Button} from 'antd';
import {DashboardOutlined, FileTextOutlined, LogoutOutlined, UserOutlined} from '@ant-design/icons';
import {useAuth} from '@/app/context/AuthContext';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
export default function DashboardShell({children}: {children: ReactNode}) {
const pathname = usePathname();
const router = useRouter();
const auth = useAuth();
const {locale} = useLanguage();
const copy = accountCopy[locale];
const links = [
{href: '/dashboard', label: copy.dashboard, icon: <DashboardOutlined/>},
{href: '/dashboard/orders', label: copy.orders, icon: <FileTextOutlined/>},
{href: '/dashboard/profile', label: copy.profile, icon: <UserOutlined/>},
];
const active = (href: string) => href === '/dashboard' ? pathname === href : pathname.startsWith(href);
return (
<main className="max-w-7xl mx-auto min-h-[75vh] px-5 md:px-8 pt-28 pb-20">
<div className="grid lg:grid-cols-[240px,minmax(0,1fr)] gap-7 items-start">
<aside className="lg:sticky lg:top-28 rounded-2xl border border-subtle bg-elevated p-4">
<div className="px-3 py-3 border-b border-subtle mb-3">
<p className="font-bold text-primary">{auth.session?.profile.firstName || copy.account}</p>
<p className="text-sm text-secondary mt-1" dir="ltr">{auth.session?.profile.phone}</p>
</div>
<nav className="flex lg:flex-col gap-2 overflow-x-auto no-scrollbar">
{links.map(link => <Link key={link.href} href={link.href}
className={`flex items-center gap-2 whitespace-nowrap px-4 py-3 rounded-xl transition-colors ${active(link.href) ? 'bg-brand text-ink-950 font-bold' : 'text-secondary hover:bg-surface-strong hover:text-primary'}`}>
{link.icon}{link.label}
</Link>)}
</nav>
<Button className="mt-3" type="text" danger icon={<LogoutOutlined/>} onClick={() => {auth.logout(); router.push('/');}} block>{copy.logout}</Button>
</aside>
<section className="min-w-0">{children}</section>
</div>
</main>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import {forwardRef} from 'react';
import type {CustomerOrder} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy} from '@/data/account';
const InvoiceDocument = forwardRef<HTMLDivElement, {order: CustomerOrder}>(({order}, ref) => {
const {locale} = useLanguage();
const copy = accountCopy[locale];
const invoice = order.invoice!;
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
const money = (value: number) => `${value.toLocaleString(numberLocale)} ${locale === 'fa' ? 'ریال' : 'Rials'}`;
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const total = subtotal - invoice.discount + invoice.shipping + invoice.tax;
const buyerName = order.customer.type === 'corporate' ? order.customer.companyName : `${order.customer.firstName} ${order.customer.lastName}`;
return (
<div ref={ref} className="invoice-document" dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<div className="invoice-head">
<img src="/images/icons/logo.png" alt="Robot Market"/>
<div><h2>{copy.invoice}</h2><p>{copy.invoiceNumber}: <span dir="ltr">{invoice.number}</span></p></div>
</div>
<div className="invoice-meta">
<div><strong>{copy.seller}</strong><p>{copy.sellerName}</p><p>{copy.sellerPhone}</p></div>
<div><strong>{copy.buyer}</strong><p>{buyerName}</p><p dir="ltr">{order.customer.phone}</p><p>{order.customer.city}، {order.customer.address}</p></div>
<div><p>{copy.issueDate}: {invoice.issueDate}</p><p>{copy.validUntil}: {invoice.validUntil}</p></div>
</div>
<table><thead><tr><th>#</th><th>{copy.orderItems}</th><th>{copy.modules}</th><th>{copy.quantity}</th><th>{copy.unitPrice}</th><th>{copy.total}</th></tr></thead>
<tbody>{order.items.map((item, index) => <tr key={item.id}><td>{index + 1}</td><td>{item.name}<small>{item.model}</small></td><td>{item.modules.join('، ')}</td><td>{item.quantity}</td><td>{money(item.unitPrice)}</td><td>{money(item.unitPrice * item.quantity)}</td></tr>)}</tbody>
</table>
<div className="invoice-totals">
<p><span>{copy.subtotal}</span><strong>{money(subtotal)}</strong></p>
<p><span>{copy.discount}</span><strong>{money(invoice.discount)}</strong></p>
<p><span>{copy.shipping}</span><strong>{money(invoice.shipping)}</strong></p>
<p><span>{copy.tax}</span><strong>{money(invoice.tax)}</strong></p>
<p className="grand"><span>{copy.finalTotal}</span><strong>{money(total)}</strong></p>
</div>
<p className="invoice-note">این پیش‌فاکتور پس از هماهنگی تلفنی و تأیید ادمین روبات مارکت صادر شده است.</p>
</div>
);
});
InvoiceDocument.displayName = 'InvoiceDocument';
export default InvoiceDocument;
+26
View File
@@ -0,0 +1,26 @@
'use client';
import Link from 'next/link';
import type {CustomerOrder} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {accountCopy, paymentStatusLabels} from '@/data/account';
import OrderStatusTag from './OrderStatusTag';
export default function OrderCard({order}: {order: CustomerOrder}) {
const {locale} = useLanguage();
const copy = accountCopy[locale];
return (
<article className="rounded-2xl border border-subtle bg-elevated p-5">
<div className="flex flex-wrap justify-between gap-4">
<div><p className="text-sm text-secondary">{copy.orderNumber}</p><strong className="text-primary" dir="ltr">{order.number}</strong></div>
<OrderStatusTag status={order.status}/>
</div>
<div className="grid sm:grid-cols-3 gap-4 mt-5 pt-5 border-t border-subtle text-sm">
<div><span className="text-secondary block">{copy.orderDate}</span><span className="text-primary">{order.createdAt}</span></div>
<div><span className="text-secondary block">{copy.paymentStatus}</span><span className="text-primary">{paymentStatusLabels[locale][order.paymentStatus]}</span></div>
<div><span className="text-secondary block">{copy.orderItems}</span><span className="text-primary">{order.items.reduce((sum, item) => sum + item.quantity, 0).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}</span></div>
</div>
<Link href={`/dashboard/orders/${order.id}`} className="inline-block text-brand-400 font-semibold mt-5 hover:underline">{copy.details}</Link>
</article>
);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
import {Tag} from 'antd';
import type {OrderStatus} from '@/types/account';
import {useLanguage} from '@/app/context/LanguageContext';
import {orderStatusLabels} from '@/data/account';
export default function OrderStatusTag({status}: {status: OrderStatus}) {
const {locale} = useLanguage();
const color = status === 'cancelled' ? 'red' : status === 'delivered' ? 'green' : status === 'proforma_issued' ? 'cyan' : 'gold';
return <Tag color={color}>{orderStatusLabels[locale][status]}</Tag>;
}
+62
View File
@@ -0,0 +1,62 @@
import type {Locale} from '@/types/domain';
import type {OrderStatus, PaymentStatus} from '@/types/account';
export const orderStatusLabels: Record<Locale, Record<OrderStatus, string>> = {
fa: {
registered: 'ثبت‌شده', under_review: 'در حال بررسی', needs_information: 'نیازمند تکمیل اطلاعات',
approved: 'تأییدشده', proforma_issued: 'پیش‌فاکتور صادرشده', awaiting_payment: 'در انتظار پرداخت',
preparing: 'در حال آماده‌سازی', ready_to_ship: 'آماده ارسال', shipped: 'ارسال‌شده',
delivered: 'تحویل‌شده', cancelled: 'لغوشده',
},
en: {
registered: 'Registered', under_review: 'Under review', needs_information: 'More information required',
approved: 'Approved', proforma_issued: 'Proforma issued', awaiting_payment: 'Awaiting payment',
preparing: 'Preparing', ready_to_ship: 'Ready to ship', shipped: 'Shipped', delivered: 'Delivered', cancelled: 'Cancelled',
},
};
export const paymentStatusLabels: Record<Locale, Record<PaymentStatus, string>> = {
fa: {not_payable: 'در انتظار تأیید', awaiting_payment: 'در انتظار پرداخت', paid: 'پرداخت‌شده', failed: 'ناموفق'},
en: {not_payable: 'Pending approval', awaiting_payment: 'Awaiting payment', paid: 'Paid', failed: 'Failed'},
};
const fa = {
login: 'ورود', account: 'حساب کاربری', dashboard: 'داشبورد', profile: 'اطلاعات حساب', orders: 'سفارش‌ها', logout: 'خروج',
robotMasterPanel: 'پنل روبات مستر', otpLogin: 'ورود با کد یک‌بارمصرف', passwordLogin: 'ورود با رمز عبور',
phone: 'شماره موبایل', password: 'رمز عبور', otp: 'کد تأیید', sendOtp: 'دریافت کد', verifyAndLogin: 'تأیید و ورود',
loginButton: 'ورود به حساب', demoTitle: 'اطلاعات ورود آزمایشی', demoOtp: 'کد نمونه: ۱۲۳۴۵', demoPassword: 'شماره ۰۹۱۲۳۴۵۶۷۸۹ و رمز ۱۲۳۴۵۶',
otpSent: 'کد آزمایشی برای این شماره آماده است.', invalidPhone: 'شماره موبایل معتبر وارد کنید.', invalidCredentials: 'شماره یا رمز عبور صحیح نیست.',
protectedTitle: 'برای ادامه وارد حساب شوید', protectedDescription: 'مشاهده جزئیات دستگاه، سبد خرید و داشبورد فقط پس از ورود امکان‌پذیر است.',
openLogin: 'ورود به حساب', welcome: 'خوش آمدید', dashboardDescription: 'اطلاعات حساب و روند سفارش‌های خود را از اینجا مدیریت کنید.',
profileCompletion: 'تکمیل اطلاعات', activeOrders: 'سفارش فعال', totalOrders: 'همه سفارش‌ها', recentOrders: 'آخرین سفارش‌ها', viewAll: 'مشاهده همه',
save: 'ذخیره اطلاعات', saved: 'اطلاعات با موفقیت ذخیره شد.', individual: 'شخص حقیقی', corporate: 'شخص حقوقی', customerType: 'نوع خریدار',
firstName: 'نام', lastName: 'نام خانوادگی', email: 'ایمیل', province: 'استان', city: 'شهر', address: 'نشانی کامل', postalCode: 'کد پستی', preferredCallTime: 'زمان مناسب تماس',
companyName: 'نام شرکت', nationalId: 'شناسه ملی', economicCode: 'کد اقتصادی', verifiedPhone: 'شماره تأییدشده و قابل ویرایش نیست.',
orderNumber: 'شماره سفارش', orderDate: 'تاریخ ثبت', lastUpdate: 'آخرین به‌روزرسانی', orderStatus: 'وضعیت سفارش', paymentStatus: 'وضعیت پرداخت', details: 'مشاهده جزئیات',
orderItems: 'اقلام سفارش', quantity: 'تعداد', unitPrice: 'قیمت واحد', total: 'جمع کل', modules: 'ماژول‌ها و امکانات', timeline: 'روند سفارش',
invoice: 'پیش‌فاکتور', invoicePending: 'پیش‌فاکتور پس از بررسی تلفنی و تأیید ادمین در این بخش قرار می‌گیرد.', downloadPdf: 'دانلود PDF', downloadExcel: 'دانلود Excel', print: 'چاپ',
invoiceNumber: 'شماره پیش‌فاکتور', issueDate: 'تاریخ صدور', validUntil: 'اعتبار تا', buyer: 'خریدار', seller: 'فروشنده', subtotal: 'جمع اقلام', discount: 'تخفیف', shipping: 'هزینه ارسال', tax: 'مالیات', finalTotal: 'مبلغ نهایی',
sellerName: 'شرکت نگین سازان پیشگام رادمهر آتوسا (روبات مارکت)', sellerPhone: 'تلفن سفارش: ۰۹۱۵۱۶۰۱۸۴۷', emptyOrders: 'هنوز سفارشی ثبت نشده است.', backToOrders: 'بازگشت به سفارش‌ها', notFound: 'سفارش پیدا نشد.',
};
export const accountCopy: Record<Locale, typeof fa> = {
fa,
en: {
login: 'Login', account: 'Account', dashboard: 'Dashboard', profile: 'Profile', orders: 'Orders', logout: 'Log out',
robotMasterPanel: 'Robot Master Panel', otpLogin: 'Login with OTP', passwordLogin: 'Login with password',
phone: 'Mobile number', password: 'Password', otp: 'Verification code', sendOtp: 'Send code', verifyAndLogin: 'Verify and login',
loginButton: 'Login', demoTitle: 'Demo credentials', demoOtp: 'Demo code: 12345', demoPassword: 'Phone 09123456789 and password 123456',
otpSent: 'The demo code is ready for this number.', invalidPhone: 'Enter a valid Iranian mobile number.', invalidCredentials: 'Incorrect phone number or password.',
protectedTitle: 'Log in to continue', protectedDescription: 'Product details, cart, and dashboard are available after login.',
openLogin: 'Login', welcome: 'Welcome', dashboardDescription: 'Manage your account details and follow your orders.',
profileCompletion: 'Profile completion', activeOrders: 'Active orders', totalOrders: 'All orders', recentOrders: 'Recent orders', viewAll: 'View all',
save: 'Save profile', saved: 'Your profile was saved.', individual: 'Individual', corporate: 'Company', customerType: 'Customer type',
firstName: 'First name', lastName: 'Last name', email: 'Email', province: 'Province', city: 'City', address: 'Full address', postalCode: 'Postal code', preferredCallTime: 'Preferred call time',
companyName: 'Company name', nationalId: 'National ID', economicCode: 'Economic code', verifiedPhone: 'This verified number cannot be edited.',
orderNumber: 'Order number', orderDate: 'Created', lastUpdate: 'Last update', orderStatus: 'Order status', paymentStatus: 'Payment status', details: 'View details',
orderItems: 'Order items', quantity: 'Quantity', unitPrice: 'Unit price', total: 'Total', modules: 'Modules and options', timeline: 'Order progress',
invoice: 'Proforma invoice', invoicePending: 'The invoice appears here after phone review and admin approval.', downloadPdf: 'Download PDF', downloadExcel: 'Download Excel', print: 'Print',
invoiceNumber: 'Invoice number', issueDate: 'Issue date', validUntil: 'Valid until', buyer: 'Buyer', seller: 'Seller', subtotal: 'Subtotal', discount: 'Discount', shipping: 'Shipping', tax: 'Tax', finalTotal: 'Grand total',
sellerName: 'Negin Sazan Pishgam Radmehr Atousa (Robot Market)', sellerPhone: 'Sales: 09151601847', emptyOrders: 'No orders have been registered.', backToOrders: 'Back to orders', notFound: 'Order not found.',
},
};
+56
View File
@@ -0,0 +1,56 @@
import type {CustomerOrder} from '@/types/account';
export async function downloadInvoicePdf(element: HTMLElement, filename: string) {
const [{default: html2canvas}, {jsPDF}] = await Promise.all([import('html2canvas'), import('jspdf')]);
const canvas = await html2canvas(element, {scale: 2, backgroundColor: '#ffffff', useCORS: true});
const image = canvas.toDataURL('image/jpeg', 0.95);
const pdf = new jsPDF({orientation: 'portrait', unit: 'mm', format: 'a4'});
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const imageHeight = (canvas.height * pageWidth) / canvas.width;
let remaining = imageHeight;
let position = 0;
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
remaining -= pageHeight;
while (remaining > 0) {
position = remaining - imageHeight;
pdf.addPage();
pdf.addImage(image, 'JPEG', 0, position, pageWidth, imageHeight);
remaining -= pageHeight;
}
pdf.save(`${filename}.pdf`);
}
export async function downloadInvoiceExcel(order: CustomerOrder, filename: string) {
const ExcelJS = await import('exceljs');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('پیش فاکتور', {views: [{rightToLeft: true}]});
sheet.columns = [
{header: 'نام دستگاه', key: 'name', width: 34},
{header: 'مدل', key: 'model', width: 14},
{header: 'امکانات', key: 'modules', width: 44},
{header: 'تعداد', key: 'quantity', width: 10},
{header: 'قیمت واحد (ریال)', key: 'unitPrice', width: 22},
{header: 'جمع (ریال)', key: 'total', width: 22},
];
order.items.forEach(item => sheet.addRow({
name: item.name, model: item.model, modules: item.modules.join('، '), quantity: item.quantity,
unitPrice: item.unitPrice, total: item.unitPrice * item.quantity,
}));
const subtotal = order.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
const total = subtotal - (order.invoice?.discount || 0) + (order.invoice?.shipping || 0) + (order.invoice?.tax || 0);
sheet.addRow({name: 'مبلغ نهایی', total});
sheet.getRow(1).font = {bold: true, color: {argb: 'FFFFFFFF'}};
sheet.getRow(1).fill = {type: 'pattern', pattern: 'solid', fgColor: {argb: 'FF00A693'}};
sheet.getRow(sheet.rowCount).font = {bold: true, color: {argb: 'FF006D61'}};
sheet.getColumn('unitPrice').numFmt = '#,##0';
sheet.getColumn('total').numFmt = '#,##0';
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${filename}.xlsx`;
link.click();
URL.revokeObjectURL(url);
}
+1171 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,7 +14,10 @@
"@reduxjs/toolkit": "2.2.7", "@reduxjs/toolkit": "2.2.7",
"antd": "5.19.1", "antd": "5.19.1",
"axios": "1.7.2", "axios": "1.7.2",
"exceljs": "^4.4.0",
"gsap": "3.12.5", "gsap": "3.12.5",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"next": "14.2.5", "next": "14.2.5",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
+50
View File
@@ -0,0 +1,50 @@
import type {AuthSession, CustomerProfile} from '@/types/account';
export const AUTH_STORAGE_KEY = 'rm-auth-session';
const DEMO_PHONE = '09123456789';
const DEMO_PASSWORD = '123456';
const DEMO_OTP = '12345';
const wait = (milliseconds = 450) => new Promise(resolve => setTimeout(resolve, milliseconds));
const emptyProfile = (phone: string): CustomerProfile => ({
type: 'individual', phone, firstName: '', lastName: '', email: '', province: '', city: '', address: '',
postalCode: '', preferredCallTime: '', companyName: '', nationalId: '', economicCode: '',
});
export function isIranianMobile(phone: string) {
return /^09\d{9}$/.test(phone.replace(/\s/g, ''));
}
export async function requestOtp(phone: string): Promise<{expiresIn: number}> {
await wait();
if (!isIranianMobile(phone)) throw new Error('INVALID_PHONE');
/*
POST /auth/otp/request
Request: { "phone": "09123456789" }
Response: { "success": true, "expiresIn": 120 }
*/
return {expiresIn: 120};
}
export async function loginWithOtp(phone: string, code: string): Promise<AuthSession> {
await wait();
if (!isIranianMobile(phone) || code !== DEMO_OTP) throw new Error('INVALID_CREDENTIALS');
/*
POST /auth/otp/verify
Request: { "phone": "09123456789", "code": "12345" }
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "", "lastName": "" } }
In production, prefer a Secure, HttpOnly session cookie instead of localStorage.
*/
return {accessToken: `mock-${phone}`, profile: emptyProfile(phone)};
}
export async function loginWithPassword(phone: string, password: string): Promise<AuthSession> {
await wait();
if (phone !== DEMO_PHONE || password !== DEMO_PASSWORD) throw new Error('INVALID_CREDENTIALS');
/*
POST /auth/password/login
Request: { "phone": "09123456789", "password": "123456" }
Response: { "accessToken": "...", "user": { "phone": "09123456789", "firstName": "حامد", "lastName": "عبدالله‌زاده" } }
*/
return {accessToken: `mock-${phone}`, profile: {...emptyProfile(phone), firstName: 'کاربر', lastName: 'آزمایشی'}};
}
+49
View File
@@ -0,0 +1,49 @@
import type {CustomerOrder, CustomerProfile} from '@/types/account';
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
function createOrders(customer: CustomerProfile): CustomerOrder[] {
const buyer = {...customer, firstName: customer.firstName || 'کاربر', lastName: customer.lastName || 'روبات مارکت', city: customer.city || 'مشهد', address: customer.address || 'نشانی خریدار پس از تکمیل پروفایل ثبت می‌شود'};
return [
{
id: 'order-128', number: 'RM-1405-00128', createdAt: '1405/06/12', updatedAt: '1405/06/15',
status: 'proforma_issued', paymentStatus: 'awaiting_payment', customer: buyer,
items: [{id: 'rm-48', name: 'دستگاه وندینگ روبات مارکت ۴۸', model: 'RM-48', image: '/images/machine-48-base.webp', quantity: 1, unitPrice: 4850000000, modules: ['کارت‌خوان بانکی', 'سیستم سرمایش', 'برچسب اختصاصی برند']}],
events: [
{status: 'registered', date: '1405/06/12', note: 'درخواست شما ثبت شد.', completed: true},
{status: 'under_review', date: '1405/06/13', note: 'کارشناس فروش درخواست را بررسی کرد.', completed: true},
{status: 'approved', date: '1405/06/14', note: 'جزئیات سفارش تلفنی تأیید شد.', completed: true},
{status: 'proforma_issued', date: '1405/06/15', note: 'پیش‌فاکتور قابل دریافت است.', completed: true},
],
invoice: {number: 'PF-1405-0081', issueDate: '1405/06/15', validUntil: '1405/06/22', approvedAt: '1405/06/15', discount: 50000000, shipping: 25000000, tax: 480000000},
},
{
id: 'order-127', number: 'RM-1405-00127', createdAt: '1405/06/08', updatedAt: '1405/06/10',
status: 'under_review', paymentStatus: 'not_payable', customer: buyer,
items: [{id: 'rm-35', name: 'دستگاه وندینگ روبات مارکت ۳۵', model: 'RM-35', image: '/images/machine-35-base.webp', quantity: 2, unitPrice: 3950000000, modules: ['کارت‌خوان بانکی']}],
events: [
{status: 'registered', date: '1405/06/08', note: 'درخواست شما ثبت شد.', completed: true},
{status: 'under_review', date: '1405/06/10', note: 'کارشناس برای هماهنگی با شما تماس می‌گیرد.', completed: true},
],
},
];
}
export async function getOrders(customer: CustomerProfile): Promise<CustomerOrder[]> {
await wait();
/*
GET /orders
Response: { "orders": [{ "id": "order-128", "number": "RM-1405-00128", "status": "proforma_issued", "paymentStatus": "awaiting_payment" }] }
*/
return createOrders(customer);
}
export async function getOrder(id: string, customer: CustomerProfile): Promise<CustomerOrder | undefined> {
await wait();
/*
GET /orders/:id
Response: { "order": { "id": "order-128", "items": [], "events": [], "invoice": { "approvedAt": "1405/06/15" } } }
The backend must verify that the signed-in customer owns this order.
*/
return createOrders(customer).find(order => order.id === id);
}
+13
View File
@@ -0,0 +1,13 @@
import type {CustomerProfile} from '@/types/account';
const wait = () => new Promise(resolve => setTimeout(resolve, 350));
export async function saveProfile(profile: CustomerProfile): Promise<CustomerProfile> {
await wait();
/*
PATCH /me
Request: { "type": "corporate", "firstName": "...", "lastName": "...", "companyName": "...", "address": "..." }
Response: { "user": { "phone": "09123456789", "type": "corporate", "firstName": "...", "lastName": "..." } }
*/
return {...profile};
}
+75
View File
@@ -0,0 +1,75 @@
export type CustomerType = 'individual' | 'corporate';
export type OrderStatus =
| 'registered'
| 'under_review'
| 'needs_information'
| 'approved'
| 'proforma_issued'
| 'awaiting_payment'
| 'preparing'
| 'ready_to_ship'
| 'shipped'
| 'delivered'
| 'cancelled';
export type PaymentStatus = 'not_payable' | 'awaiting_payment' | 'paid' | 'failed';
export interface CustomerProfile {
type: CustomerType;
phone: string;
firstName: string;
lastName: string;
email: string;
province: string;
city: string;
address: string;
postalCode: string;
preferredCallTime: string;
companyName: string;
nationalId: string;
economicCode: string;
}
export interface AuthSession {
accessToken: string;
profile: CustomerProfile;
}
export interface OrderItem {
id: string;
name: string;
model: string;
image: string;
quantity: number;
unitPrice: number;
modules: string[];
}
export interface OrderEvent {
status: OrderStatus;
date: string;
note: string;
completed: boolean;
}
export interface ProformaInvoice {
number: string;
issueDate: string;
validUntil: string;
approvedAt: string;
discount: number;
shipping: number;
tax: number;
}
export interface CustomerOrder {
id: string;
number: string;
createdAt: string;
updatedAt: string;
status: OrderStatus;
paymentStatus: PaymentStatus;
items: OrderItem[];
customer: CustomerProfile;
events: OrderEvent[];
invoice?: ProformaInvoice;
}