refactor dashboard feature boundaries and add test setup
This commit is contained in:
Generated
+14
-2
@@ -5,7 +5,10 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="fix(styles): include feature modules in Tailwind content scan">
|
<list default="true" id="bb7b50a5-be9c-4cd7-a05b-979f0e91b173" name="Changes" comment="fix(styles): include feature modules in Tailwind content scan">
|
||||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/app/dashboard/orders/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/dashboard/orders/page.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/app/dashboard/page.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/app/dashboard/page.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/components/dashboard/OrderCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/components/dashboard/OrderCard.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/services/orders.ts" beforeDir="false" afterPath="$PROJECT_DIR$/services/orders.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" />
|
||||||
@@ -225,6 +228,7 @@
|
|||||||
<workItem from="1790569032481" duration="788000" />
|
<workItem from="1790569032481" duration="788000" />
|
||||||
<workItem from="1790573950198" duration="16000" />
|
<workItem from="1790573950198" duration="16000" />
|
||||||
<workItem from="1790660862579" duration="6418000" />
|
<workItem from="1790660862579" duration="6418000" />
|
||||||
|
<workItem from="1790756879141" duration="396000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="update">
|
<task id="LOCAL-00001" summary="update">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
@@ -602,7 +606,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1790663421347</updated>
|
<updated>1790663421347</updated>
|
||||||
</task>
|
</task>
|
||||||
<option name="localTasksCounter" value="48" />
|
<task id="LOCAL-00048" summary="fix(styles): include feature modules in Tailwind content scan">
|
||||||
|
<option name="closed" value="true" />
|
||||||
|
<created>1790757130578</created>
|
||||||
|
<option name="number" value="00048" />
|
||||||
|
<option name="presentableId" value="LOCAL-00048" />
|
||||||
|
<option name="project" value="LOCAL" />
|
||||||
|
<updated>1790757130578</updated>
|
||||||
|
</task>
|
||||||
|
<option name="localTasksCounter" value="49" />
|
||||||
<servers />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Server Components are the default.
|
|||||||
Do not add:
|
Do not add:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
"use client";
|
'use client';
|
||||||
```
|
```
|
||||||
|
|
||||||
unless the component actually requires client-side functionality.
|
unless the component actually requires client-side functionality.
|
||||||
@@ -175,7 +175,7 @@ Avoid putting hundreds of lines of business logic inside `page.tsx`.
|
|||||||
Prefer:
|
Prefer:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { ProductList } from "@/features/products/components/ProductList";
|
import {ProductList} from '@/features/products/components/ProductList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays the product-management page.
|
* Displays the product-management page.
|
||||||
@@ -301,20 +301,20 @@ Functions should be:
|
|||||||
Prefer meaningful names:
|
Prefer meaningful names:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
calculateSaleTotal()
|
calculateSaleTotal();
|
||||||
formatMachineStatus()
|
formatMachineStatus();
|
||||||
canUserEditProduct()
|
canUserEditProduct();
|
||||||
getAvailableProducts()
|
getAvailableProducts();
|
||||||
```
|
```
|
||||||
|
|
||||||
Avoid vague names:
|
Avoid vague names:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
handleData()
|
handleData();
|
||||||
processStuff()
|
processStuff();
|
||||||
doWork()
|
doWork();
|
||||||
helper()
|
helper();
|
||||||
func1()
|
func1();
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -354,12 +354,12 @@ Keep important business rules outside JSX when practical.
|
|||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{machine.enabled &&
|
{
|
||||||
machine.status === "online" &&
|
machine.enabled &&
|
||||||
|
machine.status === 'online' &&
|
||||||
machine.products.length > 0 &&
|
machine.products.length > 0 &&
|
||||||
user.permissions.includes("manage_machine") && (
|
user.permissions.includes('manage_machine') && <ManageButton />;
|
||||||
<ManageButton />
|
}
|
||||||
)}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer:
|
Prefer:
|
||||||
@@ -368,15 +368,12 @@ Prefer:
|
|||||||
/**
|
/**
|
||||||
* Determines whether the current user can manage the given machine.
|
* Determines whether the current user can manage the given machine.
|
||||||
*/
|
*/
|
||||||
function canManageMachine(
|
function canManageMachine(machine: Machine, user: User): boolean {
|
||||||
machine: Machine,
|
|
||||||
user: User
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
machine.enabled &&
|
machine.enabled &&
|
||||||
machine.status === "online" &&
|
machine.status === 'online' &&
|
||||||
machine.products.length > 0 &&
|
machine.products.length > 0 &&
|
||||||
user.permissions.includes("manage_machine")
|
user.permissions.includes('manage_machine')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -384,7 +381,9 @@ function canManageMachine(
|
|||||||
Then:
|
Then:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{canManageMachine(machine, user) && <ManageButton />}
|
{
|
||||||
|
canManageMachine(machine, user) && <ManageButton />;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This improves:
|
This improves:
|
||||||
@@ -510,7 +509,7 @@ export async function getProducts(): Promise<Product[]> {
|
|||||||
const response = await fetch(`${API_URL}/products`);
|
const response = await fetch(`${API_URL}/products`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load products.");
|
throw new Error('Failed to load products.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
@@ -615,9 +614,9 @@ Do not duplicate values that can be calculated.
|
|||||||
Bad:
|
Bad:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState('');
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState('');
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState('');
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer:
|
Prefer:
|
||||||
@@ -721,25 +720,25 @@ Names should explain intent.
|
|||||||
Prefer:
|
Prefer:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
selectedProduct
|
selectedProduct;
|
||||||
currentUser
|
currentUser;
|
||||||
isMachineOnline
|
isMachineOnline;
|
||||||
canEditSale
|
canEditSale;
|
||||||
hasAdminPermission
|
hasAdminPermission;
|
||||||
shouldReloadProducts
|
shouldReloadProducts;
|
||||||
```
|
```
|
||||||
|
|
||||||
Avoid vague names such as:
|
Avoid vague names such as:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
data
|
data;
|
||||||
info
|
info;
|
||||||
item2
|
item2;
|
||||||
temp
|
temp;
|
||||||
res2
|
res2;
|
||||||
obj
|
obj;
|
||||||
value1
|
value1;
|
||||||
stuff
|
stuff;
|
||||||
```
|
```
|
||||||
|
|
||||||
unless the surrounding context makes the meaning completely obvious.
|
unless the surrounding context makes the meaning completely obvious.
|
||||||
|
|||||||
@@ -1,37 +1,5 @@
|
|||||||
'use client';
|
import OrdersList from '@/features/dashboard/components/OrdersList';
|
||||||
|
|
||||||
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() {
|
export default function OrdersPage() {
|
||||||
const auth = useAuth();
|
return <OrdersList />;
|
||||||
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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-81
@@ -1,84 +1,5 @@
|
|||||||
'use client';
|
import DashboardOverview from '@/features/dashboard/components/DashboardOverview';
|
||||||
|
|
||||||
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() {
|
export default function DashboardPage() {
|
||||||
const auth = useAuth();
|
return <DashboardOverview />;
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
import type {CustomerOrder} from '@/types/account';
|
import type {CustomerOrder} from '@/types/account';
|
||||||
import {useLanguage} from '@/app/context/LanguageContext';
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
import {accountCopy, paymentStatusLabels} from '@/data/account';
|
import {accountCopy, paymentStatusLabels} from '@/data/account';
|
||||||
|
import {getOrderItemQuantity} from '@/features/dashboard/lib/orderSummary';
|
||||||
import OrderStatusTag from './OrderStatusTag';
|
import OrderStatusTag from './OrderStatusTag';
|
||||||
|
|
||||||
export default function OrderCard({order}: {order: CustomerOrder}) {
|
export default function OrderCard({order}: {order: CustomerOrder}) {
|
||||||
@@ -32,9 +33,7 @@ export default function OrderCard({order}: {order: CustomerOrder}) {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-secondary block">{copy.orderItems}</span>
|
<span className="text-secondary block">{copy.orderItems}</span>
|
||||||
<span className="text-primary">
|
<span className="text-primary">
|
||||||
{order.items
|
{getOrderItemQuantity(order).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
||||||
.reduce((sum, item) => sum + item.quantity, 0)
|
|
||||||
.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[{"eventName":"NEXT_CLI_SESSION_STOPPED","payload":{"nextVersion":"14.2.5","nodeVersion":"v26.3.0","cliCommand":"dev","durationMilliseconds":263824,"turboFlag":false,"pagesDir":false,"appDir":true}}]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": {}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"polyfillFiles": [
|
|
||||||
"static/chunks/polyfills.js"
|
|
||||||
],
|
|
||||||
"devFiles": [],
|
|
||||||
"ampDevFiles": [],
|
|
||||||
"lowPriorityFiles": [
|
|
||||||
"static/development/_buildManifest.js",
|
|
||||||
"static/development/_ssgManifest.js"
|
|
||||||
],
|
|
||||||
"rootMainFiles": [],
|
|
||||||
"pages": {
|
|
||||||
"/_app": []
|
|
||||||
},
|
|
||||||
"ampFirstPages": []
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
{"type": "commonjs"}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__BUILD_MANIFEST={"polyfillFiles":["static/chunks/polyfills.js"],"devFiles":[],"ampDevFiles":[],"lowPriorityFiles":["static/development/_buildManifest.js","static/development/_ssgManifest.js"],"rootMainFiles":[],"pages":{"/_app":[]},"ampFirstPages":[]}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"middleware": {},
|
|
||||||
"functions": {},
|
|
||||||
"sortedMiddleware": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w=\"\n}"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"node": {},
|
|
||||||
"edge": {},
|
|
||||||
"encryptionKey": "OV7lo+xPvS/sDjLEddseUuhg7MfbZYxNg/IFGVNi66w="
|
|
||||||
}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"type": "module"}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||||
|
import {getActiveOrderCount, getOrderItemQuantity, getProfileCompletion} from '../lib/orderSummary';
|
||||||
|
|
||||||
|
const completeProfile: CustomerProfile = {
|
||||||
|
type: 'individual',
|
||||||
|
phone: '09123456789',
|
||||||
|
firstName: 'Sara',
|
||||||
|
lastName: 'Ahmadi',
|
||||||
|
email: 'sara@example.com',
|
||||||
|
province: 'Razavi Khorasan',
|
||||||
|
city: 'Mashhad',
|
||||||
|
address: 'Main street',
|
||||||
|
postalCode: '1234567890',
|
||||||
|
preferredCallTime: '',
|
||||||
|
companyName: '',
|
||||||
|
nationalId: '',
|
||||||
|
economicCode: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeOrder = (status: CustomerOrder['status'], quantities: number[] = [1]): CustomerOrder => ({
|
||||||
|
id: status,
|
||||||
|
number: `RM-${status}`,
|
||||||
|
createdAt: '1405/06/12',
|
||||||
|
updatedAt: '1405/06/15',
|
||||||
|
status,
|
||||||
|
paymentStatus: 'not_payable',
|
||||||
|
customer: completeProfile,
|
||||||
|
items: quantities.map((quantity, index) => ({
|
||||||
|
id: `item-${index}`,
|
||||||
|
name: 'Robot Market vending machine',
|
||||||
|
model: 'RM-48',
|
||||||
|
image: '/images/machine-48-base.webp',
|
||||||
|
quantity,
|
||||||
|
unitPrice: 100,
|
||||||
|
modules: [],
|
||||||
|
})),
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dashboard order summary helpers', () => {
|
||||||
|
it('calculates required profile completion percentage', () => {
|
||||||
|
expect(getProfileCompletion(completeProfile)).toBe(100);
|
||||||
|
expect(getProfileCompletion({...completeProfile, city: '', postalCode: ''})).toBe(67);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts active orders and excludes terminal statuses', () => {
|
||||||
|
const orders = [makeOrder('under_review'), makeOrder('delivered'), makeOrder('cancelled')];
|
||||||
|
|
||||||
|
expect(getActiveOrderCount(orders)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('totals item quantities for an order card', () => {
|
||||||
|
expect(getOrderItemQuantity(makeOrder('registered', [1, 2, 4]))).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {CheckCircleOutlined, ClockCircleOutlined, FileTextOutlined} from '@ant-design/icons';
|
||||||
|
import {Progress, Skeleton} from 'antd';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import OrderCard from '@/components/dashboard/OrderCard';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
import {useCustomerOrders} from '../hooks/useCustomerOrders';
|
||||||
|
import {getActiveOrderCount, getProfileCompletion} from '../lib/orderSummary';
|
||||||
|
|
||||||
|
export default function DashboardOverview() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const numberLocale = locale === 'fa' ? 'fa-IR' : 'en-US';
|
||||||
|
const profile = auth.session?.profile;
|
||||||
|
const orders = useCustomerOrders(profile);
|
||||||
|
|
||||||
|
if (!profile || !orders) return <Skeleton active />;
|
||||||
|
|
||||||
|
const completion = getProfileCompletion(profile);
|
||||||
|
const activeOrders = getActiveOrderCount(orders);
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: copy.profileCompletion,
|
||||||
|
value: `${completion.toLocaleString(numberLocale)}٪`,
|
||||||
|
icon: <CheckCircleOutlined />,
|
||||||
|
extra: <Progress percent={completion} showInfo={false} size="small" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.activeOrders,
|
||||||
|
value: activeOrders.toLocaleString(numberLocale),
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: copy.totalOrders,
|
||||||
|
value: orders.length.toLocaleString(numberLocale),
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-3xl font-extrabold text-primary">
|
||||||
|
{copy.welcome} {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,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Empty, Skeleton} from 'antd';
|
||||||
|
import {useAuth} from '@/app/context/AuthContext';
|
||||||
|
import {useLanguage} from '@/app/context/LanguageContext';
|
||||||
|
import OrderCard from '@/components/dashboard/OrderCard';
|
||||||
|
import {accountCopy} from '@/data/account';
|
||||||
|
import {useCustomerOrders} from '../hooks/useCustomerOrders';
|
||||||
|
|
||||||
|
export default function OrdersList() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const {locale} = useLanguage();
|
||||||
|
const copy = accountCopy[locale];
|
||||||
|
const profile = auth.session?.profile;
|
||||||
|
const orders = useCustomerOrders(profile);
|
||||||
|
|
||||||
|
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,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {getOrders} from '@/services/orders';
|
||||||
|
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the signed-in customer's orders after the browser auth profile is available.
|
||||||
|
*/
|
||||||
|
export function useCustomerOrders(profile: CustomerProfile | undefined) {
|
||||||
|
const [orders, setOrders] = useState<CustomerOrder[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
getOrders(profile).then(nextOrders => {
|
||||||
|
if (active) setOrders(nextOrders);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
||||||
|
|
||||||
|
const COMPLETE_PROFILE_FIELDS: Array<keyof CustomerProfile> = [
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'province',
|
||||||
|
'city',
|
||||||
|
'address',
|
||||||
|
'postalCode',
|
||||||
|
];
|
||||||
|
|
||||||
|
const INACTIVE_ORDER_STATUSES = new Set<CustomerOrder['status']>(['delivered', 'cancelled']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates how much of the required buyer profile is ready for order processing.
|
||||||
|
*/
|
||||||
|
export function getProfileCompletion(profile: CustomerProfile): number {
|
||||||
|
const filledFields = COMPLETE_PROFILE_FIELDS.filter(field => Boolean(profile[field]));
|
||||||
|
|
||||||
|
return Math.round((filledFields.length / COMPLETE_PROFILE_FIELDS.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts orders that still need operational follow-up from the customer or sales team.
|
||||||
|
*/
|
||||||
|
export function getActiveOrderCount(orders: CustomerOrder[]): number {
|
||||||
|
return orders.filter(order => !INACTIVE_ORDER_STATUSES.has(order.status)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts all units across an order, regardless of how many product lines it contains.
|
||||||
|
*/
|
||||||
|
export function getOrderItemQuantity(order: CustomerOrder): number {
|
||||||
|
return order.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
|
}
|
||||||
Generated
+2939
-4
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -10,7 +10,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "5.3.7",
|
"@ant-design/icons": "5.3.7",
|
||||||
@@ -27,13 +30,20 @@
|
|||||||
"react-redux": "9.1.2"
|
"react-redux": "9.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "20.19.43",
|
"@types/node": "20.19.43",
|
||||||
"@types/react": "18.3.31",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "8.4.39",
|
"postcss": "8.4.39",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"tailwindcss": "3.4.6",
|
"tailwindcss": "3.4.6",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {defineConfig, devices} from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
webServer: {
|
||||||
|
command: 'npx next dev -H 127.0.0.1',
|
||||||
|
url: 'http://127.0.0.1:3000',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:3000',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {...devices['Desktop Chrome']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -2,6 +2,9 @@ import type {CustomerOrder, CustomerProfile} from '@/types/account';
|
|||||||
|
|
||||||
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
|
const wait = () => new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds demo orders with the latest profile fields until the real orders API is connected.
|
||||||
|
*/
|
||||||
function createOrders(customer: CustomerProfile): CustomerOrder[] {
|
function createOrders(customer: CustomerProfile): CustomerOrder[] {
|
||||||
const buyer = {
|
const buyer = {
|
||||||
...customer,
|
...customer,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {expect, test} from '@playwright/test';
|
||||||
|
|
||||||
|
test('home page exposes the primary shopping journey', async ({page}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle(/Robot Market|روبات مارکت/);
|
||||||
|
await expect(page.locator('a[href="#showcase"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('a[href="https://panel.my-rm.com"]').first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -23,10 +23,16 @@
|
|||||||
"app/**/*.tsx",
|
"app/**/*.tsx",
|
||||||
"components/**/*.tsx",
|
"components/**/*.tsx",
|
||||||
"data/**/*.ts",
|
"data/**/*.ts",
|
||||||
|
"features/**/*.ts",
|
||||||
|
"features/**/*.tsx",
|
||||||
"lib/**/*.ts",
|
"lib/**/*.ts",
|
||||||
|
"tests/**/*.ts",
|
||||||
"store/**/*.ts",
|
"store/**/*.ts",
|
||||||
"types/**/*.ts",
|
"types/**/*.ts",
|
||||||
"tailwind.config.ts",
|
"tailwind.config.ts",
|
||||||
|
"vitest.config.ts",
|
||||||
|
"vitest.setup.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "docs", "out"]
|
"exclude": ["node_modules", "docs", "out"]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import {defineConfig} from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
include: ['features/**/*.test.ts', 'features/**/*.test.tsx', 'components/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': new URL('.', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Reference in New Issue
Block a user