feat: add customer authentication and order dashboard
This commit is contained in:
Generated
+31
-12
@@ -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
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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}/>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+1171
-2
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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: 'آزمایشی'}};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user