refactor dashboard feature boundaries and add test setup

This commit is contained in:
2026-09-30 12:27:57 +03:30
parent fb2c394433
commit 57b0cfeac8
40 changed files with 7536 additions and 4457 deletions
+14 -2
View File
@@ -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,13 +175,13 @@ 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.
*/ */
export default async function ProductsPage() { export default async function ProductsPage() {
return <ProductList />; return <ProductList />;
} }
``` ```
@@ -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();
``` ```
--- ---
@@ -337,7 +337,7 @@ Example:
* Returns true when a product is enabled and still has stock available. * Returns true when a product is enabled and still has stock available.
*/ */
function canSellProduct(product: Product): boolean { function canSellProduct(product: Product): boolean {
return product.enabled && product.stock > 0; return product.enabled && product.stock > 0;
} }
``` ```
@@ -354,12 +354,12 @@ Keep important business rules outside JSX when practical.
Bad: Bad:
```tsx ```tsx
{machine.enabled && {
machine.status === "online" && machine.enabled &&
machine.products.length > 0 && machine.status === 'online' &&
user.permissions.includes("manage_machine") && ( machine.products.length > 0 &&
<ManageButton /> user.permissions.includes('manage_machine') && <ManageButton />;
)} }
``` ```
Prefer: Prefer:
@@ -368,23 +368,22 @@ 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, return (
user: User machine.enabled &&
): boolean { machine.status === 'online' &&
return ( machine.products.length > 0 &&
machine.enabled && user.permissions.includes('manage_machine')
machine.status === "online" && );
machine.products.length > 0 &&
user.permissions.includes("manage_machine")
);
} }
``` ```
Then: Then:
```tsx ```tsx
{canManageMachine(machine, user) && <ManageButton />} {
canManageMachine(machine, user) && <ManageButton />;
}
``` ```
This improves: This improves:
@@ -478,9 +477,9 @@ Example:
* Loads the available products and renders the product list. * Loads the available products and renders the product list.
*/ */
export default async function ProductsPage() { export default async function ProductsPage() {
const products = await getProducts(); const products = await getProducts();
return <ProductList products={products} />; return <ProductList products={products} />;
} }
``` ```
@@ -507,13 +506,13 @@ Example:
* Loads all products from the backend. * Loads all products from the backend.
*/ */
export async function getProducts(): Promise<Product[]> { 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:
@@ -657,11 +656,11 @@ Bad:
```ts ```ts
if (session) { if (session) {
if (session.user) { if (session.user) {
if (session.user.active) { if (session.user.active) {
// logic // logic
}
} }
}
} }
``` ```
@@ -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.
@@ -948,7 +947,7 @@ Prefer:
const product = await getProduct(productId); const product = await getProduct(productId);
if (!product) { if (!product) {
return null; return null;
} }
return product; return product;
+2 -34
View File
@@ -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
View File
@@ -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>
</>
);
} }
+2 -3
View File
@@ -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
View File
@@ -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}}]
-3
View File
@@ -1,3 +0,0 @@
{
"pages": {}
}
-16
View File
@@ -1,16 +0,0 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"pages": {
"/_app": []
},
"ampFirstPages": []
}
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
{"type": "commonjs"}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
-1
View File
@@ -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":[]}
-6
View File
@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}
@@ -1 +0,0 @@
self.__REACT_LOADABLE_MANIFEST="{}"
-1
View File
@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
-1
View File
@@ -1 +0,0 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -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="
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
self.__BUILD_MANIFEST = {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]};self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
-1
View File
@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
-2
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -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;
}
+35
View File
@@ -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);
}
+7163 -4228
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -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"
} }
} }
+20
View File
@@ -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']},
},
],
});
+3
View File
@@ -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,
+9
View File
@@ -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();
});
+6
View File
@@ -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"]
+17
View File
@@ -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,
},
},
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';