mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# Backend API (NestJS)
|
||||
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||
|
||||
# Optional localhost-only fallback when not using customer.{domain} in /etc/hosts.
|
||||
# Leave unset for multi-tenant dev — tenant is resolved from hostname
|
||||
# (e.g. customer.sanihome.ir → sanihome.ir, customer.safeteb.com → safeteb.com).
|
||||
# VITE_BUSINESS_DOMAIN=sanihome.ir
|
||||
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Customer Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@meshkee/customer-dashboard",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@meshkee/dashboard-core": "*",
|
||||
"@meshkee/dashboard-ui": "*",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,71 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { CustomerThemeProvider } from './context/CustomerThemeContext'
|
||||
import { TenantBrandingProvider } from './context/TenantBrandingContext'
|
||||
import { ToastProvider } from '@meshkee/dashboard-ui'
|
||||
import { CustomerDomainGuard } from './components/CustomerDomainGuard'
|
||||
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||
import { GuestRoute } from './components/GuestRoute'
|
||||
import { PageLayout } from './components/PageLayout'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { OrdersPage } from './pages/OrdersPage'
|
||||
import { AddressesPage } from './pages/AddressesPage'
|
||||
import { FavoritesPage } from './pages/FavoritesPage'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { CheckoutLayout } from './components/checkout/CheckoutLayout'
|
||||
import { CheckoutFlow } from './pages/checkout/CheckoutFlow'
|
||||
import { CheckoutLoginStep } from './pages/checkout/CheckoutLoginStep'
|
||||
import { CheckoutCartStep } from './pages/checkout/CheckoutCartStep'
|
||||
import { CheckoutDeliveryStep } from './pages/checkout/CheckoutDeliveryStep'
|
||||
import { CheckoutPaymentStep } from './pages/checkout/CheckoutPaymentStep'
|
||||
import { CheckoutSuccessStep } from './pages/checkout/CheckoutSuccessStep'
|
||||
import { CheckoutFailedStep } from './pages/checkout/CheckoutFailedStep'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<CustomerDomainGuard>
|
||||
<CustomerThemeProvider>
|
||||
<BrowserRouter>
|
||||
<TenantBrandingProvider>
|
||||
<DashboardDocumentTitle />
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Routes>
|
||||
<Route path="checkout" element={<CheckoutLayout />}>
|
||||
<Route element={<CheckoutFlow />}>
|
||||
<Route path="login" element={<CheckoutLoginStep />} />
|
||||
<Route path="cart" element={<CheckoutCartStep />} />
|
||||
<Route path="delivery" element={<CheckoutDeliveryStep />} />
|
||||
<Route path="payment" element={<CheckoutPaymentStep />} />
|
||||
<Route path="success" element={<CheckoutSuccessStep />} />
|
||||
<Route path="failed" element={<CheckoutFailedStep />} />
|
||||
<Route index element={<CheckoutCartStep />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route element={<GuestRoute />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<PageLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="addresses" element={<AddressesPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="favorites" element={<FavoritesPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</TenantBrandingProvider>
|
||||
</BrowserRouter>
|
||||
</CustomerThemeProvider>
|
||||
</CustomerDomainGuard>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
@@ -0,0 +1,8 @@
|
||||
import { createDomainGuard } from '@meshkee/dashboard-ui'
|
||||
import { getCustomerDashboardHostForApp, isAllowedCustomerHost } from '../lib/config'
|
||||
|
||||
export const CustomerDomainGuard = createDomainGuard({
|
||||
isAllowedHost: isAllowedCustomerHost,
|
||||
getExpectedHost: getCustomerDashboardHostForApp,
|
||||
dashboardLabel: 'customer dashboard',
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { CUSTOMER_DASHBOARD_NAME, customerRouteTitleRules } from '../lib/routeTitles'
|
||||
|
||||
export function DashboardDocumentTitle() {
|
||||
const { pathname } = useLocation()
|
||||
const { businessName } = useTenantBranding()
|
||||
|
||||
useDashboardDocumentTitle({
|
||||
businessName,
|
||||
dashboardName: CUSTOMER_DASHBOARD_NAME,
|
||||
pathname,
|
||||
routeRules: customerRouteTitleRules,
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.1);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.festivalBadge,
|
||||
.stockBadge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.festivalBadge {
|
||||
left: 8px;
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stockBadge {
|
||||
right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nameEn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 3px;
|
||||
text-align: left;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.variantLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 6px 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.controlsLeft button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.controlsLeft button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controlsLeft button.danger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.addToCartIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addToCartBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.addToCartBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.addToCartBtn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useId } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import type { FavoriteListing } from '../services/favoritesService'
|
||||
import { formatVariantCount } from '../utils/storeProductGroups'
|
||||
import { StoreItemPrice } from './StoreItemPrice'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './FavoriteStoreItemCard.module.css'
|
||||
|
||||
interface FavoriteStoreItemCardProps {
|
||||
listing: FavoriteListing
|
||||
onRemove: (listing: FavoriteListing) => void
|
||||
onAddToCart: (listing: FavoriteListing) => void
|
||||
removing?: boolean
|
||||
}
|
||||
|
||||
function GradientPlusIcon({ gradientId }: { gradientId: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
className={styles.addToCartIcon}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="4"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#22c55e" />
|
||||
<stop offset="0.5" stopColor="var(--primary)" />
|
||||
<stop offset="1" stopColor="#a855f7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FavoriteStoreItemCard({
|
||||
listing,
|
||||
onRemove,
|
||||
onAddToCart,
|
||||
removing = false,
|
||||
}: FavoriteStoreItemCardProps) {
|
||||
const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.imageWrap}>
|
||||
{listing.productImage ? (
|
||||
<img
|
||||
src={listing.productImage}
|
||||
alt={listing.productTitle}
|
||||
className={styles.image}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.imagePlaceholder} />
|
||||
)}
|
||||
{listing.showFestival && <span className={styles.festivalBadge}>Festival</span>}
|
||||
{listing.productTotalStock > 0 && (
|
||||
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<h3 className={styles.nameEn}>{listing.productTitle}</h3>
|
||||
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>}
|
||||
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p>
|
||||
<StoreItemPrice
|
||||
price={listing.displayPrice}
|
||||
discountedPrice={listing.displayDiscountedPrice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.controlsLeft}>
|
||||
<Tooltip label="Remove from favorites">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(listing)}
|
||||
disabled={removing}
|
||||
aria-label="Remove from favorites"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip label="Add to shopping cart">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addToCartBtn}
|
||||
onClick={() => onAddToCart(listing)}
|
||||
aria-label="Add to shopping cart"
|
||||
>
|
||||
<GradientPlusIcon gradientId={plusGradientId} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { RouteLoader } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function safeRedirectPath(value: string | null) {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function GuestRoute() {
|
||||
const { user, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
const redirectTo = safeRedirectPath(new URLSearchParams(location.search).get('redirect'))
|
||||
|
||||
if (isLoading) {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to={redirectTo} replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: none;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.menuBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.notificationBtn,
|
||||
.iconBtn {
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notificationBtn:hover,
|
||||
.iconBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.profileWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 6px 6px;
|
||||
border-radius: 50px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--glass-border);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.profile:hover,
|
||||
.profileOpen {
|
||||
box-shadow: var(--glass-shadow);
|
||||
border-color: rgba(var(--primary-rgb) / 0.25);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profileInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
padding: 6px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.12);
|
||||
z-index: 60;
|
||||
animation: dropdownIn 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dropdownItem:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dropdownItem:last-child:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menuBtn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.profileInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Menu, Bell, MessageSquare, ChevronDown, User, KeyRound, LogOut } from 'lucide-react'
|
||||
import { PasswordResetModal } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { changePassword } from '../services/authService'
|
||||
import styles from './Header.module.css'
|
||||
|
||||
export function Header() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const displayName =
|
||||
[user?.firstName, user?.lastName].filter(Boolean).join(' ') || user?.cellNumber || 'Customer'
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
setMenuOpen(false)
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
setMenuOpen(false)
|
||||
setPasswordModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.left}>
|
||||
<button className={styles.menuBtn} aria-label="Toggle menu">
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<h1 className={styles.title}>Customer Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
<button className={styles.iconBtn} aria-label="Messages">
|
||||
<MessageSquare size={20} />
|
||||
</button>
|
||||
|
||||
<button className={styles.iconBtn} aria-label="Notifications">
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
|
||||
<div className={styles.profileWrap} ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.profile} ${menuOpen ? styles.profileOpen : ''}`}
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<img
|
||||
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(displayName)}`}
|
||||
alt={displayName}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.profileInfo}>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
<span className={styles.role}>Customer</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${menuOpen ? styles.chevronOpen : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div className={styles.dropdown} role="menu">
|
||||
<Link
|
||||
to="/profile"
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<User size={16} />
|
||||
<span>My Profile</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={openPasswordModal}
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
<span>Change password</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownItem}
|
||||
role="menuitem"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<PasswordResetModal
|
||||
open={passwordModalOpen}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
onChangePassword={changePassword}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
.modalWide {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metaItem strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tableBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.itemList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.itemThumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.itemThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.itemThumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(148, 163, 184, 0.12), rgba(148, 163, 184, 0.22));
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.itemVariant {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.itemSku {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.qtyCell,
|
||||
.unitCell,
|
||||
.totalCell {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableHead {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 72px 120px 120px;
|
||||
gap: 10px;
|
||||
padding: 0 12px 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tableHead span:not(:first-child):not(:nth-child(2)) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.summaryLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summaryValue {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.itemRow,
|
||||
.tableHead {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.qtyCell,
|
||||
.unitCell,
|
||||
.totalCell {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
import styles from './OrderItemsModal.module.css'
|
||||
|
||||
interface OrderItemsModalProps {
|
||||
open: boolean
|
||||
order: Order | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
function displayName(order: Order) {
|
||||
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
function formatVariantLabel(selections: Order['items'][number]['selections']) {
|
||||
if (!selections.length) return '—'
|
||||
return selections.map((s) => `${s.variationName}: ${s.value}`).join(' · ')
|
||||
}
|
||||
|
||||
function totalQuantity(order: Order) {
|
||||
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
}
|
||||
|
||||
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
if (!mounted || !order) return null
|
||||
|
||||
const itemCount = totalQuantity(order)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-items-title"
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="order-items-title" className={modalStyles.title}>
|
||||
Order items
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
||||
</div>
|
||||
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.body} ${styles.body}`}>
|
||||
<div className={styles.metaRow}>
|
||||
<span className={styles.metaItem}>
|
||||
Customer: <strong>{displayName(order)}</strong>
|
||||
</span>
|
||||
<span className={styles.metaItem}>
|
||||
Phone: <strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
|
||||
</span>
|
||||
<span className={styles.metaItem}>
|
||||
Items: <strong>{itemCount}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{order.items.length === 0 ? (
|
||||
<p className={styles.empty}>No items in this order.</p>
|
||||
) : (
|
||||
<div className={styles.tableBlock}>
|
||||
<div className={styles.tableHead}>
|
||||
<span aria-hidden="true" />
|
||||
<span>Product</span>
|
||||
<span>Qty</span>
|
||||
<span>Unit price</span>
|
||||
<span>Line total</span>
|
||||
</div>
|
||||
<ul className={styles.itemList}>
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id} className={styles.itemRow}>
|
||||
<div className={styles.itemThumb}>
|
||||
{item.productImage ? (
|
||||
<img src={item.productImage} alt="" />
|
||||
) : (
|
||||
<div className={styles.itemThumbPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.itemTitle}>{item.productTitle}</div>
|
||||
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
||||
{item.variantSku && (
|
||||
<div className={styles.itemSku}>SKU: {item.variantSku}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.qtyCell}>{item.quantity}</div>
|
||||
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
|
||||
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.summary}>
|
||||
<span className={styles.summaryLabel}>
|
||||
Order total · {itemCount} {itemCount === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orderNumber {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dateCell {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dateTime {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dateTimeSub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceOperator {
|
||||
color: var(--primary-dark, var(--primary));
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.sourceWebsite {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.sourceApplication {
|
||||
color: #7c3aed;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
}
|
||||
|
||||
.stepBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tdActions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.actionBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Eye } from 'lucide-react'
|
||||
import type { Order, OrderSource } from '../services/orderService'
|
||||
import type { OrderProcessStep } from '../utils/orderSteps'
|
||||
import { stepLabel, stepColor } from '../utils/orderSteps'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import { stepBadgeStyle } from '../utils/stepColors'
|
||||
import styles from './OrderRow.module.css'
|
||||
|
||||
interface OrderRowProps {
|
||||
order: Order
|
||||
processSteps: OrderProcessStep[]
|
||||
onViewItems: (order: Order) => void
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
function totalItemQuantity(order: Order) {
|
||||
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
||||
}
|
||||
|
||||
function sourceLabel(source: OrderSource) {
|
||||
switch (source) {
|
||||
case 'admin':
|
||||
return 'Operator'
|
||||
case 'app':
|
||||
return 'Application'
|
||||
case 'website':
|
||||
default:
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceClass(source: OrderSource) {
|
||||
switch (source) {
|
||||
case 'admin':
|
||||
return styles.sourceOperator
|
||||
case 'app':
|
||||
return styles.sourceApplication
|
||||
case 'website':
|
||||
default:
|
||||
return styles.sourceWebsite
|
||||
}
|
||||
}
|
||||
|
||||
export function OrderRow({ order, processSteps, onViewItems }: OrderRowProps) {
|
||||
const { date, time } = formatDateTime(order.createdAt)
|
||||
const itemQty = totalItemQuantity(order)
|
||||
const processStepId = order.processStepId ?? processSteps[0]?.id ?? 'processing'
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.orderNumber}>{order.orderNumber}</div>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
{itemQty > 0 ? itemQty : <span className={styles.subText}>0</span>}
|
||||
</td>
|
||||
<td className={styles.td}>{formatIrtPrice(order.total)}</td>
|
||||
<td className={`${styles.td} ${styles.dateCell}`}>
|
||||
<div className={styles.dateTime}>{date}</div>
|
||||
{time && <div className={styles.dateTimeSub}>{time}</div>}
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<span
|
||||
className={styles.stepBadge}
|
||||
style={stepBadgeStyle(
|
||||
stepColor(processSteps, processStepId, order.processStepColor),
|
||||
)}
|
||||
>
|
||||
{stepLabel(processSteps, processStepId, order.processStepLabel)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
<span className={`${styles.sourceBadge} ${sourceClass(order.source)}`}>
|
||||
{sourceLabel(order.source)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => onViewItems(order)}
|
||||
aria-label="View items"
|
||||
title="View items"
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.content {
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pageSubtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dateBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 50px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.gridHome {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.gridFour {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.grid12 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.col6 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.gridHome {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.gridHome,
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dateBadge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.gridHome,
|
||||
.gridFour {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.col6 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
export function PageLayout() {
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<Sidebar />
|
||||
|
||||
<div className={styles.main}>
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { RouteLoader } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
padding: 4px 8px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.brandFallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brandLogoUploaded {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandDomain {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navItem.active {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navGroupBtn {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.navGroupLabel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.subNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 2px 0 4px 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.subNavItem {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.subNavItem:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.subNavActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Home, User, MapPin, ShoppingBag, ShoppingCart, Heart, HelpCircle, LogOut } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getActiveBusinessDomain } from '../lib/businessContext'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: 'Home', to: '/' },
|
||||
{ icon: ShoppingCart, label: 'Shopping Cart', to: '/checkout' },
|
||||
{ icon: User, label: 'My Profile', to: '/profile' },
|
||||
{ icon: MapPin, label: 'My Addresses', to: '/addresses' },
|
||||
{ icon: ShoppingBag, label: 'My Orders', to: '/orders' },
|
||||
{ icon: Heart, label: 'My Favorites', to: '/favorites' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||
|
||||
const businessDomain = getActiveBusinessDomain()
|
||||
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? 'Store'
|
||||
const displayName = brandName || fallbackBusinessName
|
||||
const initial = displayName.trim().charAt(0) || businessDomain.charAt(0) || 'S'
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
const info = await getWebsiteBusinessInfo(businessDomain, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setBrandName(info.nameFa?.trim() || info.name.trim() || fallbackBusinessName)
|
||||
setLogoUrl(info.logoUrl)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setBrandName(fallbackBusinessName)
|
||||
setLogoUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadBranding()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [businessDomain, fallbackBusinessName])
|
||||
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={displayName}
|
||||
className={styles.brandLogo}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.brandFallback} aria-hidden>
|
||||
{initial.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||
<span className={styles.brandName}>{displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button type="button" className={styles.navItem}>
|
||||
<HelpCircle size={20} />
|
||||
<span>Help Center</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navItem}
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.priceChip {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.discountWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.originalPrice {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.saleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.salePrice {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.percent {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
calcDiscountPercent,
|
||||
formatIrtPrice,
|
||||
hasStoreItemDiscount,
|
||||
} from '../utils/irtPrice'
|
||||
import styles from './StoreItemPrice.module.css'
|
||||
|
||||
interface StoreItemPriceProps {
|
||||
price: number | null
|
||||
discountedPrice?: number | null
|
||||
}
|
||||
|
||||
export function StoreItemPrice({ price, discountedPrice = null }: StoreItemPriceProps) {
|
||||
if (hasStoreItemDiscount(price, discountedPrice)) {
|
||||
const percent = calcDiscountPercent(price!, discountedPrice!)
|
||||
return (
|
||||
<div className={styles.discountWrap}>
|
||||
<span className={styles.originalPrice}>{formatIrtPrice(price)}</span>
|
||||
<div className={styles.saleRow}>
|
||||
<span className={styles.salePrice}>{formatIrtPrice(discountedPrice)}</span>
|
||||
<span className={styles.percent}>-{percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={styles.priceChip}>{formatIrtPrice(price)}</span>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.toastIn {
|
||||
animation: toastIn 0.22s ease forwards;
|
||||
}
|
||||
|
||||
.toastOut {
|
||||
animation: toastOut 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.success {
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s;
|
||||
z-index: 50;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(31, 38, 135, 0.12);
|
||||
}
|
||||
|
||||
.tip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.wrap:hover .tip,
|
||||
.wrap:focus-within .tip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import styles from './Tooltip.module.css'
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
export function Tooltip({ label, children }: TooltipProps) {
|
||||
return (
|
||||
<span className={styles.wrap}>
|
||||
{children}
|
||||
<span className={styles.tip} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.overlayNested {
|
||||
z-index: 210;
|
||||
}
|
||||
|
||||
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
|
||||
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
|
||||
}
|
||||
|
||||
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
|
||||
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modalHint {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.headerWithHint {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.bodyCompactTop {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.listItemInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listItemName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.listItemValues {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.listItemEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.listItemType {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
padding: 3px 8px;
|
||||
border-radius: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px dashed rgba(var(--primary-rgb) / 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.addBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
min-height: var(--field-height);
|
||||
}
|
||||
|
||||
.field select {
|
||||
padding-right: var(--select-padding-end);
|
||||
}
|
||||
|
||||
.field select:focus,
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chipGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 50px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chipSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.customValues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.customRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.customRow input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.removeValueBtn {
|
||||
width: var(--field-height);
|
||||
height: var(--field-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.removeValueBtn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.addValueBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.addValueBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 16px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.attrHint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.attrPill {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
padding: 4px 10px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.variationFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.removeRowBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.removeRowBtn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.preview {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes overlayFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes modalFadeIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes modalFadeOut {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.websiteBtn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
line-height: 1.5;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.websiteBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 22px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.primaryBtn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.primaryBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bar {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.websiteBtn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getWebsiteUrl } from '../../services/websiteService'
|
||||
import styles from './CheckoutActionBar.module.css'
|
||||
|
||||
interface CheckoutActionBarProps {
|
||||
primaryLabel: string
|
||||
onPrimary: () => void
|
||||
primaryDisabled?: boolean
|
||||
primaryType?: 'button' | 'submit'
|
||||
}
|
||||
|
||||
export function CheckoutActionBar({
|
||||
primaryLabel,
|
||||
onPrimary,
|
||||
primaryDisabled = false,
|
||||
primaryType = 'button',
|
||||
}: CheckoutActionBarProps) {
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<a href={websiteUrl} className={styles.websiteBtn}>
|
||||
بازگشت به وبسایت و ادامهٔ خرید
|
||||
</a>
|
||||
<button
|
||||
type={primaryType}
|
||||
className={styles.primaryBtn}
|
||||
disabled={primaryDisabled}
|
||||
onClick={primaryType === 'button' ? onPrimary : undefined}
|
||||
>
|
||||
{primaryLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Link variant for success / navigation-only rows. */
|
||||
export function CheckoutWebsiteLink() {
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<a href={websiteUrl} className={styles.websiteBtn}>
|
||||
بازگشت به وبسایت و ادامهٔ خرید
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** For Link-based primary actions (success page). */
|
||||
export function CheckoutActionBarLinks({
|
||||
primaryTo,
|
||||
primaryLabel,
|
||||
onPrimaryClick,
|
||||
}: {
|
||||
primaryTo: string
|
||||
primaryLabel: string
|
||||
onPrimaryClick?: () => void
|
||||
}) {
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<a href={websiteUrl} className={styles.websiteBtn}>
|
||||
بازگشت به وبسایت و ادامهٔ خرید
|
||||
</a>
|
||||
<Link to={primaryTo} className={styles.primaryBtn} onClick={onPrimaryClick}>
|
||||
{primaryLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.modal {
|
||||
max-width: 720px;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
z-index: 1000;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { CheckoutAddAddressPanel } from './CheckoutAddAddressPanel'
|
||||
import modalStyles from '../VariationsModal.module.css'
|
||||
import styles from './CheckoutAddAddressModal.module.css'
|
||||
|
||||
interface CheckoutAddAddressModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export function CheckoutAddAddressModal({ open, onClose, onSaved }: CheckoutAddAddressModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [formKey, setFormKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setFormKey((key) => key + 1)
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
function handleSaved() {
|
||||
onSaved()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${styles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="checkout-add-address-title"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="checkout-add-address-title" className={modalStyles.title}>
|
||||
افزودن آدرس جدید
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>آدرس ارسال سفارش را وارد کنید.</p>
|
||||
</div>
|
||||
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="بستن">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.body}>
|
||||
<CheckoutAddAddressPanel
|
||||
key={formKey}
|
||||
inModal
|
||||
onSaved={handleSaved}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 2px solid rgba(var(--primary-rgb) / 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.formInModal {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fieldRowTriple {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.optionalMark {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-fa);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
font-family: var(--font-fa);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.field select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-inline-start: var(--field-padding-x);
|
||||
padding-inline-end: var(--select-padding-end);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: left var(--select-arrow-offset) center;
|
||||
background-size: var(--select-arrow-size);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cancelBtn:hover:not(:disabled) {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.3);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.saveBtn:disabled,
|
||||
.cancelBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 2px dashed rgba(148, 163, 184, 0.45);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.addRow:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fieldRow,
|
||||
.fieldRowTriple {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../../lib/api'
|
||||
import { createAddress } from '../../services/addressService'
|
||||
import {
|
||||
listCitiesByProvinceSlug,
|
||||
listIranProvinces,
|
||||
type CityOption,
|
||||
} from '../../services/citiesService'
|
||||
import styles from './CheckoutAddAddressPanel.module.css'
|
||||
|
||||
function FieldLabel({
|
||||
htmlFor,
|
||||
optional,
|
||||
children,
|
||||
}: {
|
||||
htmlFor: string
|
||||
optional?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label htmlFor={htmlFor}>
|
||||
{children}
|
||||
{optional ? <span className={styles.optionalMark}> (اختیاری)</span> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckoutAddAddressPanelProps {
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
inModal?: boolean
|
||||
}
|
||||
|
||||
export function CheckoutAddAddressPanel({
|
||||
onSaved,
|
||||
onCancel,
|
||||
inModal = false,
|
||||
}: CheckoutAddAddressPanelProps) {
|
||||
const { showToast } = useToast()
|
||||
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||
const [cities, setCities] = useState<CityOption[]>([])
|
||||
const [loadingLocations, setLoadingLocations] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
const [provinceSlug, setProvinceSlug] = useState('')
|
||||
const [city, setCity] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [postalCode, setPostalCode] = useState('')
|
||||
const [landline, setLandline] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadProvinces() {
|
||||
setLoadingLocations(true)
|
||||
try {
|
||||
const items = await listIranProvinces(controller.signal)
|
||||
if (!controller.signal.aborted) setProvinces(items)
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError('بارگذاری استانها ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoadingLocations(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadProvinces()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
async function handleProvinceChange(slug: string) {
|
||||
setProvinceSlug(slug)
|
||||
setCity('')
|
||||
setCities([])
|
||||
|
||||
if (!slug) return
|
||||
|
||||
try {
|
||||
const items = await listCitiesByProvinceSlug(slug)
|
||||
setCities(items)
|
||||
} catch {
|
||||
setError('بارگذاری شهرها ممکن نشد.')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const province = provinces.find((item) => item.slug === provinceSlug)
|
||||
if (!label.trim() || !province || !city.trim() || !address.trim()) {
|
||||
setError('لطفاً همه فیلدهای الزامی را تکمیل کنید.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await createAddress({
|
||||
label: label.trim(),
|
||||
province: province.nameFa || province.nameEn,
|
||||
city: city.trim(),
|
||||
address: address.trim(),
|
||||
postalCode: postalCode.trim() || undefined,
|
||||
landline: landline.trim() || undefined,
|
||||
})
|
||||
showToast('آدرس ذخیره شد.', 'success')
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ذخیره آدرس ممکن نشد.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className={[styles.form, inModal ? styles.formInModal : ''].filter(Boolean).join(' ')}
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.fieldRowTriple}>
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-label">عنوان آدرس</FieldLabel>
|
||||
<input
|
||||
id="checkout-label"
|
||||
type="text"
|
||||
value={label}
|
||||
disabled={saving}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="مثلاً خانه، محل کار"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-province">استان</FieldLabel>
|
||||
<select
|
||||
id="checkout-province"
|
||||
value={provinceSlug}
|
||||
disabled={loadingLocations || saving}
|
||||
onChange={(e) => void handleProvinceChange(e.target.value)}
|
||||
>
|
||||
<option value="">انتخاب استان</option>
|
||||
{provinces.map((province) => (
|
||||
<option key={province.id} value={province.slug}>
|
||||
{province.nameFa}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-city">شهر</FieldLabel>
|
||||
<select
|
||||
id="checkout-city"
|
||||
value={city}
|
||||
disabled={!provinceSlug || saving}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
<option value="">انتخاب شهر</option>
|
||||
{cities.map((item) => (
|
||||
<option key={item.id} value={item.nameFa}>
|
||||
{item.nameFa}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-address">آدرس</FieldLabel>
|
||||
<input
|
||||
id="checkout-address"
|
||||
type="text"
|
||||
value={address}
|
||||
disabled={saving}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="خیابان، پلاک، واحد"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-postal" optional>
|
||||
کد پستی
|
||||
</FieldLabel>
|
||||
<input
|
||||
id="checkout-postal"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={postalCode}
|
||||
disabled={saving}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
placeholder="کد پستی"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<FieldLabel htmlFor="checkout-landline" optional>
|
||||
تلفن ثابت
|
||||
</FieldLabel>
|
||||
<input
|
||||
id="checkout-landline"
|
||||
type="tel"
|
||||
value={landline}
|
||||
disabled={saving}
|
||||
onChange={(e) => setLandline(e.target.value)}
|
||||
placeholder="021..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onCancel} disabled={saving}>
|
||||
انصراف
|
||||
</button>
|
||||
<button type="submit" className={styles.saveBtn} disabled={saving}>
|
||||
{saving ? 'در حال ذخیره...' : 'ذخیره آدرس'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
interface CheckoutAddAddressRowProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function CheckoutAddAddressRow({ onClick }: CheckoutAddAddressRowProps) {
|
||||
return (
|
||||
<button type="button" className={styles.addRow} onClick={onClick}>
|
||||
<Plus size={18} />
|
||||
<span>افزودن آدرس جدید</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: -8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.backBtn span {
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import styles from './CheckoutBackButton.module.css'
|
||||
|
||||
interface CheckoutBackButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function CheckoutBackButton({ label, onClick }: CheckoutBackButtonProps) {
|
||||
return (
|
||||
<button type="button" className={styles.backBtn} onClick={onClick}>
|
||||
<ArrowRight size={16} aria-hidden />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
.checkoutPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
direction: rtl;
|
||||
font-family: var(--font-fa);
|
||||
/* Soften brand color in page wash — keep primary accents, less saturated bg */
|
||||
background-color: #f8f6f6;
|
||||
background-image:
|
||||
radial-gradient(ellipse 520px 520px at calc(100% - 40px) -60px, rgba(var(--primary-rgb) / 0.1), transparent 72%),
|
||||
radial-gradient(ellipse 420px 420px at 18% calc(100% + 20px), rgba(var(--primary-rgb) / 0.07), transparent 72%),
|
||||
radial-gradient(ellipse 320px 320px at -40px 42%, rgba(var(--primary-rgb) / 0.05), transparent 72%),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--primary-light) 22%, #ffffff) 0%,
|
||||
color-mix(in srgb, var(--primary-light) 10%, #ffffff) 50%,
|
||||
#fafafa 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.brandLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.brandLink:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.brandTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.logoFallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 60%;
|
||||
max-width: none;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 22px 18px 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { isAbortError } from '../../lib/api'
|
||||
import { getTenantDomain } from '../../lib/config'
|
||||
import { CheckoutProvider } from '../../context/CheckoutContext'
|
||||
import { getWebsiteBusinessInfo, getWebsiteUrl } from '../../services/websiteService'
|
||||
import styles from './CheckoutLayout.module.css'
|
||||
|
||||
export function CheckoutLayout() {
|
||||
const tenantDomain = getTenantDomain()
|
||||
const websiteUrl = getWebsiteUrl(tenantDomain)
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
const info = await getWebsiteBusinessInfo(tenantDomain, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setBrandName(info.nameFa?.trim() || info.name.trim() || tenantDomain)
|
||||
setLogoUrl(info.logoUrl)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setBrandName(tenantDomain)
|
||||
setLogoUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadBranding()
|
||||
return () => controller.abort()
|
||||
}, [tenantDomain])
|
||||
|
||||
const displayName = brandName || tenantDomain
|
||||
const initial = displayName.trim().charAt(0) || 'S'
|
||||
|
||||
return (
|
||||
<CheckoutProvider>
|
||||
<div className={styles.checkoutPage} lang="fa" dir="rtl">
|
||||
<header className={styles.header}>
|
||||
<a href={websiteUrl} className={styles.brandLink}>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={displayName}
|
||||
className={styles.logo}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.logoFallback} aria-hidden>
|
||||
{initial.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandTitle}>{displayName}</span>
|
||||
<span className={styles.subtitle}>سبد خرید</span>
|
||||
</div>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</CheckoutProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stepGroup {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepGroup:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.stepUnit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-muted);
|
||||
border: 2px solid transparent;
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.stepLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.stepActive .stepDot {
|
||||
background: rgba(var(--primary-rgb) / 0.15);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stepActive .stepLabel {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stepDone .stepDot {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stepDone .stepLabel {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.connector {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
min-width: 24px;
|
||||
margin: 0 8px 13px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
border-radius: 1px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.connectorDone {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stepLabel {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.connector {
|
||||
min-width: 12px;
|
||||
margin: 0 4px 13px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import styles from './CheckoutStepper.module.css'
|
||||
|
||||
export type CheckoutStepId = 'cart' | 'login' | 'delivery' | 'payment'
|
||||
|
||||
const STEPS: { id: CheckoutStepId; label: string }[] = [
|
||||
{ id: 'cart', label: 'سبد خرید' },
|
||||
{ id: 'login', label: 'ورود' },
|
||||
{ id: 'delivery', label: 'ارسال' },
|
||||
{ id: 'payment', label: 'پرداخت' },
|
||||
]
|
||||
|
||||
interface CheckoutStepperProps {
|
||||
current: CheckoutStepId
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
export function CheckoutStepper({ current, isAuthenticated }: CheckoutStepperProps) {
|
||||
const visibleSteps = isAuthenticated
|
||||
? STEPS.filter((step) => step.id !== 'login')
|
||||
: STEPS
|
||||
|
||||
const currentVisibleIdx = visibleSteps.findIndex((step) => step.id === current)
|
||||
|
||||
return (
|
||||
<nav className={styles.stepper} aria-label="مراحل تکمیل خرید">
|
||||
{visibleSteps.map((step, index) => {
|
||||
const isDone = currentVisibleIdx >= 0 && index < currentVisibleIdx
|
||||
const isActive = step.id === current
|
||||
const connectorDone = currentVisibleIdx >= 0 && index < currentVisibleIdx
|
||||
|
||||
return (
|
||||
<div key={step.id} className={styles.stepGroup}>
|
||||
<div
|
||||
className={[
|
||||
styles.stepUnit,
|
||||
isActive ? styles.stepActive : '',
|
||||
isDone ? styles.stepDone : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className={styles.stepLabel}>{step.label}</span>
|
||||
<span className={styles.stepDot} aria-hidden>
|
||||
{isDone ? <Check size={14} /> : index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{index < visibleSteps.length - 1 && (
|
||||
<div
|
||||
className={[styles.connector, connectorDone ? styles.connectorDone : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { ApiError, getAccessToken, isAbortError } from '../lib/api'
|
||||
import { setActiveBusinessById } from '../lib/businessContext'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
} from '../services/authService'
|
||||
import { resolveTenantByDomain } from '../services/tenantService'
|
||||
import type { AuthUser } from '../types/auth'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
isLoading: boolean
|
||||
login: (cellNumber: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
setUser: (user: AuthUser) => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export const CUSTOMER_ACCESS_MESSAGE =
|
||||
'This account cannot access the customer dashboard. Please sign in with a verified customer account.'
|
||||
|
||||
export const VERIFICATION_REQUIRED_MESSAGE =
|
||||
'Your mobile number is not verified yet. Please complete SMS verification before accessing the dashboard.'
|
||||
|
||||
function isVerifiedUser(user: AuthUser) {
|
||||
return user.cellVerifiedAt !== null && user.cellVerifiedAt !== undefined
|
||||
}
|
||||
|
||||
function canAccessCustomerDashboard(user: AuthUser) {
|
||||
return user.customerBusinesses.length > 0
|
||||
}
|
||||
|
||||
async function prepareCustomerContext(user: AuthUser) {
|
||||
const domain = getTenantDomain()
|
||||
const tenant = await resolveTenantByDomain(domain)
|
||||
|
||||
const membership =
|
||||
user.customerBusinesses.find((business) => business.id === tenant.id) ??
|
||||
user.customerBusinesses.find((business) => business.slug === tenant.slug) ??
|
||||
user.customerBusinesses[0]
|
||||
|
||||
if (!membership) {
|
||||
throw new ApiError('You are not a customer of this business.', 403)
|
||||
}
|
||||
|
||||
setActiveBusinessById(String(membership.id), domain)
|
||||
}
|
||||
|
||||
async function assertCustomerAccess(user: AuthUser) {
|
||||
if (!canAccessCustomerDashboard(user)) {
|
||||
logoutRequest()
|
||||
throw new ApiError(CUSTOMER_ACCESS_MESSAGE, 403)
|
||||
}
|
||||
|
||||
if (!isVerifiedUser(user)) {
|
||||
logoutRequest()
|
||||
throw new ApiError(VERIFICATION_REQUIRED_MESSAGE, 403)
|
||||
}
|
||||
|
||||
await prepareCustomerContext(user)
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutRequest()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function init() {
|
||||
if (!getAccessToken()) {
|
||||
if (!controller.signal.aborted) setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { user: currentUser } = await fetchCurrentUser(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (!canAccessCustomerDashboard(currentUser) || !isVerifiedUser(currentUser)) {
|
||||
logout()
|
||||
return
|
||||
}
|
||||
|
||||
await prepareCustomerContext(currentUser)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setUser(currentUser)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
logout()
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void init()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [logout])
|
||||
|
||||
const login = useCallback(async (cellNumber: string, password: string) => {
|
||||
const data = await loginRequest(cellNumber, password)
|
||||
await assertCustomerAccess(data.user)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, isLoading, login, logout, setUser }),
|
||||
[user, isLoading, login, logout],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { Order } from '../services/orderService'
|
||||
|
||||
export type DeliveryMode = 'delivery' | 'pickup'
|
||||
|
||||
interface CheckoutState {
|
||||
deliveryMode: DeliveryMode
|
||||
selectedAddressId: string | null
|
||||
discountCode: string
|
||||
placedOrder: Order | null
|
||||
}
|
||||
|
||||
interface CheckoutContextValue extends CheckoutState {
|
||||
setDeliveryMode: (mode: DeliveryMode) => void
|
||||
setSelectedAddressId: (id: string | null) => void
|
||||
setDiscountCode: (code: string) => void
|
||||
setPlacedOrder: (order: Order | null) => void
|
||||
resetCheckout: () => void
|
||||
}
|
||||
|
||||
const defaultState: CheckoutState = {
|
||||
deliveryMode: 'delivery',
|
||||
selectedAddressId: null,
|
||||
discountCode: '',
|
||||
placedOrder: null,
|
||||
}
|
||||
|
||||
const CheckoutContext = createContext<CheckoutContextValue | null>(null)
|
||||
|
||||
export function CheckoutProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<CheckoutState>(defaultState)
|
||||
|
||||
const setDeliveryMode = useCallback((deliveryMode: DeliveryMode) => {
|
||||
setState((prev) => ({ ...prev, deliveryMode }))
|
||||
}, [])
|
||||
|
||||
const setSelectedAddressId = useCallback((selectedAddressId: string | null) => {
|
||||
setState((prev) =>
|
||||
prev.selectedAddressId === selectedAddressId ? prev : { ...prev, selectedAddressId },
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setDiscountCode = useCallback((discountCode: string) => {
|
||||
setState((prev) => ({ ...prev, discountCode }))
|
||||
}, [])
|
||||
|
||||
const setPlacedOrder = useCallback((placedOrder: Order | null) => {
|
||||
setState((prev) => ({ ...prev, placedOrder }))
|
||||
}, [])
|
||||
|
||||
const resetCheckout = useCallback(() => {
|
||||
setState(defaultState)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<CheckoutContextValue>(
|
||||
() => ({
|
||||
...state,
|
||||
setDeliveryMode,
|
||||
setSelectedAddressId,
|
||||
setDiscountCode,
|
||||
setPlacedOrder,
|
||||
resetCheckout,
|
||||
}),
|
||||
[state, setDeliveryMode, setSelectedAddressId, setDiscountCode, setPlacedOrder, resetCheckout],
|
||||
)
|
||||
|
||||
return <CheckoutContext.Provider value={value}>{children}</CheckoutContext.Provider>
|
||||
}
|
||||
|
||||
export function useCheckout() {
|
||||
const ctx = useContext(CheckoutContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useCheckout must be used within CheckoutProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import { resolveTenantByDomain } from '../services/tenantService'
|
||||
import { applyBusinessPrimaryColor, resetBusinessPrimaryColor } from '../utils/applyBusinessTheme'
|
||||
import { normalizeBusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||
|
||||
export function CustomerThemeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const domain = getTenantDomain()
|
||||
|
||||
async function loadTheme() {
|
||||
try {
|
||||
const tenant = await resolveTenantByDomain(domain, controller.signal)
|
||||
applyBusinessPrimaryColor(
|
||||
normalizeBusinessPrimaryColorId(tenant.primaryColor),
|
||||
)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
applyBusinessPrimaryColor(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
void loadTheme()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
resetBusinessPrimaryColor()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { applyDocumentFavicon } from '@meshkee/dashboard-core'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import { getWebsiteBusinessInfo } from '../services/websiteService'
|
||||
import { resolveTenantByDomain } from '../services/tenantService'
|
||||
|
||||
interface TenantBrandingContextValue {
|
||||
businessName: string
|
||||
faviconUrl: string | null
|
||||
}
|
||||
|
||||
const TenantBrandingContext = createContext<TenantBrandingContextValue | null>(null)
|
||||
|
||||
function pickBusinessName(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string {
|
||||
for (const value of candidates) {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function TenantBrandingProvider({ children }: { children: ReactNode }) {
|
||||
const [businessName, setBusinessName] = useState('')
|
||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null)
|
||||
const domain = getTenantDomain()
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
const [tenant, info] = await Promise.all([
|
||||
resolveTenantByDomain(domain, controller.signal),
|
||||
getWebsiteBusinessInfo(domain, controller.signal).catch(() => null),
|
||||
])
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const name = pickBusinessName(
|
||||
info?.nameFa,
|
||||
info?.name,
|
||||
tenant.nameFa,
|
||||
tenant.name,
|
||||
domain,
|
||||
)
|
||||
|
||||
const nextFavicon =
|
||||
info?.faviconUrl?.trim() ||
|
||||
tenant.faviconUrl?.trim() ||
|
||||
info?.logoUrl?.trim() ||
|
||||
tenant.logoUrl?.trim() ||
|
||||
null
|
||||
|
||||
setBusinessName(name || domain)
|
||||
setFaviconUrl(nextFavicon)
|
||||
applyDocumentFavicon(nextFavicon)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setBusinessName(domain)
|
||||
setFaviconUrl(null)
|
||||
applyDocumentFavicon(null)
|
||||
}
|
||||
}
|
||||
|
||||
void loadBranding()
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [domain])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ businessName, faviconUrl }),
|
||||
[businessName, faviconUrl],
|
||||
)
|
||||
|
||||
return (
|
||||
<TenantBrandingContext.Provider value={value}>{children}</TenantBrandingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTenantBranding() {
|
||||
const context = useContext(TenantBrandingContext)
|
||||
if (!context) {
|
||||
throw new Error('useTenantBranding must be used within TenantBrandingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@font-face {
|
||||
font-family: 'YekanBakh';
|
||||
src: url('/fonts/yekanbakh/YekanBakh-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'YekanBakh';
|
||||
src: url('/fonts/yekanbakh/YekanBakh-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'YekanBakh';
|
||||
src: url('/fonts/yekanbakh/YekanBakh-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import '@meshkee/dashboard-core/styles/tokens.css';
|
||||
|
||||
/* Customer app Farsi typography — Yekan Bakh for body, inputs, and placeholders */
|
||||
:root {
|
||||
--font-fa: 'YekanBakh', Tahoma, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApiClient } from '@meshkee/dashboard-core'
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
|
||||
|
||||
export const api = createApiClient({
|
||||
baseUrl: API_BASE_URL,
|
||||
accessTokenKey: 'meshkee_customer_access_token',
|
||||
refreshTokenKey: 'meshkee_customer_refresh_token',
|
||||
})
|
||||
|
||||
export const {
|
||||
apiRequest,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearTokens,
|
||||
} = api
|
||||
|
||||
export { ApiError, isAbortError } from '@meshkee/dashboard-core'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getTenantDomain } from './config'
|
||||
|
||||
const ACTIVE_BUSINESS_ID_KEY = 'meshkee_customer_active_business_id'
|
||||
const ACTIVE_BUSINESS_DOMAIN_KEY = 'meshkee_customer_active_business_domain'
|
||||
|
||||
export function setActiveBusinessById(businessId: string, domain?: string) {
|
||||
localStorage.setItem(ACTIVE_BUSINESS_ID_KEY, businessId)
|
||||
localStorage.setItem(ACTIVE_BUSINESS_DOMAIN_KEY, domain ?? getTenantDomain())
|
||||
}
|
||||
|
||||
export function getActiveBusinessId(): string | null {
|
||||
return localStorage.getItem(ACTIVE_BUSINESS_ID_KEY)
|
||||
}
|
||||
|
||||
export function getActiveBusinessDomain(): string {
|
||||
return localStorage.getItem(ACTIVE_BUSINESS_DOMAIN_KEY) ?? getTenantDomain()
|
||||
}
|
||||
|
||||
export function clearActiveBusiness() {
|
||||
localStorage.removeItem(ACTIVE_BUSINESS_ID_KEY)
|
||||
localStorage.removeItem(ACTIVE_BUSINESS_DOMAIN_KEY)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { toE164CellNumber, formatCellForDisplay } from '@meshkee/dashboard-core'
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
CUSTOMER_SUBDOMAIN_PREFIX,
|
||||
getBaseDomainFromHost,
|
||||
getCustomerDashboardHost,
|
||||
isCustomerDashboardHost,
|
||||
} from '@meshkee/dashboard-core'
|
||||
|
||||
export { CUSTOMER_SUBDOMAIN_PREFIX }
|
||||
|
||||
/** Base tenant domain without customer. prefix (e.g. sanihome.ir). */
|
||||
export function getBaseBusinessDomain(hostname = window.location.hostname): string {
|
||||
return getBaseDomainFromHost(hostname, import.meta.env.VITE_BUSINESS_DOMAIN)
|
||||
}
|
||||
|
||||
/** Expected customer dashboard host (e.g. customer.sanihome.ir). */
|
||||
export function getCustomerDashboardHostForApp(baseDomain?: string): string {
|
||||
return getCustomerDashboardHost(baseDomain ?? getBaseBusinessDomain())
|
||||
}
|
||||
|
||||
export function isAllowedCustomerHost(hostname = window.location.hostname): boolean {
|
||||
const host = hostname.toLowerCase().trim()
|
||||
|
||||
if (host === 'localhost' || host === '127.0.0.1') {
|
||||
return true
|
||||
}
|
||||
|
||||
return isCustomerDashboardHost(host)
|
||||
}
|
||||
|
||||
/** Domain sent to tenant resolution and customer registration APIs. */
|
||||
export function getTenantDomain(): string {
|
||||
return getBaseBusinessDomain()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/** Shared guest cart key used by the storefront and customer dashboard. */
|
||||
export const GUEST_CART_STORAGE_KEY = 'meshkee-guest-cart'
|
||||
export const GUEST_CART_URL_PARAM = 'guestCart'
|
||||
|
||||
export interface GuestCartItem {
|
||||
id: string
|
||||
name: string
|
||||
slug?: string
|
||||
price: number
|
||||
originalPrice?: number
|
||||
image: string | null
|
||||
quantity: number
|
||||
}
|
||||
|
||||
function cookieDomain(): string | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const host = window.location.hostname.toLowerCase()
|
||||
if (host === 'localhost' || host === '127.0.0.1') return undefined
|
||||
const parts = host.split('.').filter(Boolean)
|
||||
if (parts.length < 2) return undefined
|
||||
return `.${parts.slice(-2).join('.')}`
|
||||
}
|
||||
|
||||
function toCookiePayload(items: GuestCartItem[]): string {
|
||||
return JSON.stringify(
|
||||
items.map(({ id, name, slug, price, originalPrice, quantity }) => ({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
price,
|
||||
originalPrice,
|
||||
quantity,
|
||||
image: null as string | null,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
function writeCookie(value: string) {
|
||||
const domain = cookieDomain()
|
||||
const maxAge = 60 * 60 * 24 * 30
|
||||
const encoded = encodeURIComponent(value)
|
||||
if (encoded.length > 3500) return
|
||||
let cookie = `${GUEST_CART_STORAGE_KEY}=${encoded}; path=/; max-age=${maxAge}; SameSite=Lax`
|
||||
if (domain) cookie += `; domain=${domain}`
|
||||
document.cookie = cookie
|
||||
}
|
||||
|
||||
function readCookie(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const prefix = `${GUEST_CART_STORAGE_KEY}=`
|
||||
const match = document.cookie.split('; ').find((row) => row.startsWith(prefix))
|
||||
if (!match) return null
|
||||
try {
|
||||
return decodeURIComponent(match.slice(prefix.length))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseItems(raw: string | null): GuestCartItem[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as GuestCartItem[]
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeGuestCartForUrl(items: GuestCartItem[]): string {
|
||||
const json = JSON.stringify(items)
|
||||
return btoa(unescape(encodeURIComponent(json)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function decodeGuestCartFromUrl(encoded: string): GuestCartItem[] {
|
||||
try {
|
||||
const padded = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4))
|
||||
const json = decodeURIComponent(escape(atob(padded + pad)))
|
||||
return parseItems(json)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer cart payload passed from the storefront URL (cross-origin),
|
||||
* then localStorage, then shared cookie.
|
||||
*/
|
||||
export function loadGuestCart(): GuestCartItem[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
|
||||
const fromUrl = ingestGuestCartFromLocation()
|
||||
if (fromUrl.length > 0) return fromUrl
|
||||
|
||||
const fromLocal = parseItems(localStorage.getItem(GUEST_CART_STORAGE_KEY))
|
||||
if (fromLocal.length > 0) return fromLocal
|
||||
|
||||
return parseItems(readCookie())
|
||||
}
|
||||
|
||||
export function ingestGuestCartFromLocation(): GuestCartItem[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
|
||||
const hash = window.location.hash.replace(/^#/, '')
|
||||
const search = window.location.search.replace(/^\?/, '')
|
||||
const params = new URLSearchParams(hash.includes('=') ? hash : search)
|
||||
const encoded = params.get(GUEST_CART_URL_PARAM)
|
||||
if (!encoded) return []
|
||||
|
||||
const items = decodeGuestCartFromUrl(encoded)
|
||||
if (items.length === 0) return []
|
||||
|
||||
saveGuestCart(items)
|
||||
|
||||
// Clean the payload from the address bar
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}`
|
||||
window.history.replaceState(null, '', nextUrl)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function saveGuestCart(items: GuestCartItem[]) {
|
||||
if (typeof window === 'undefined') return
|
||||
const raw = JSON.stringify(items)
|
||||
localStorage.setItem(GUEST_CART_STORAGE_KEY, raw)
|
||||
try {
|
||||
writeCookie(toCookiePayload(items))
|
||||
} catch {
|
||||
// ignore cookie failures
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGuestCart() {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(GUEST_CART_STORAGE_KEY)
|
||||
const domain = cookieDomain()
|
||||
let cookie = `${GUEST_CART_STORAGE_KEY}=; path=/; max-age=0; SameSite=Lax`
|
||||
if (domain) cookie += `; domain=${domain}`
|
||||
document.cookie = cookie
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteTitleRule } from '@meshkee/dashboard-core'
|
||||
|
||||
export const CUSTOMER_DASHBOARD_NAME = 'Customer Dashboard'
|
||||
|
||||
export const customerRouteTitleRules: RouteTitleRule[] = [
|
||||
{ match: '/login', labels: ['Sign in'] },
|
||||
{ match: '/checkout/login', labels: ['Checkout', 'Sign in'] },
|
||||
{ match: '/checkout/cart', labels: ['Checkout', 'Shopping Cart'] },
|
||||
{ match: '/checkout/delivery', labels: ['Checkout', 'Delivery'] },
|
||||
{ match: '/checkout/payment', labels: ['Checkout', 'Payment'] },
|
||||
{ match: '/checkout/success', labels: ['Checkout', 'Success'] },
|
||||
{ match: '/checkout/failed', labels: ['Checkout', 'Failed'] },
|
||||
{ match: '/checkout', labels: ['Checkout'] },
|
||||
{ match: '/profile', labels: ['My Profile'] },
|
||||
{ match: '/addresses', labels: ['My Addresses'] },
|
||||
{ match: '/orders', labels: ['My Orders'] },
|
||||
{ match: '/favorites', labels: ['My Favorites'] },
|
||||
{ match: '/', labels: ['Home'] },
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './fonts/iranyekan.css'
|
||||
import './fonts/yekanbakh.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
width: 100%;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AddressListEditor,
|
||||
Breadcrumbs,
|
||||
createEmptyAddressItem,
|
||||
matchCityByName,
|
||||
matchProvinceByName,
|
||||
useToast,
|
||||
type AddressListItem,
|
||||
type CityOption,
|
||||
} from '@meshkee/dashboard-ui'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
createAddress,
|
||||
listAddresses,
|
||||
removeAddress,
|
||||
updateAddress,
|
||||
type UserAddress,
|
||||
type UserAddressInput,
|
||||
} from '../services/addressService'
|
||||
import {
|
||||
listCitiesByProvinceSlug,
|
||||
listIranProvinces,
|
||||
} from '../services/citiesService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddressesPage.module.css'
|
||||
|
||||
type AddressDraft = AddressListItem
|
||||
|
||||
function toDraft(
|
||||
item: UserAddress,
|
||||
provinces: CityOption[],
|
||||
citiesByProvince: Record<string, CityOption[]>,
|
||||
): AddressDraft {
|
||||
const province = matchProvinceByName(item.province, provinces)
|
||||
const cities = province ? (citiesByProvince[province.slug] ?? []) : []
|
||||
const city = matchCityByName(item.city, cities)
|
||||
return {
|
||||
id: item.id,
|
||||
provinceSlug: province?.slug ?? '',
|
||||
province: province?.nameEn ?? item.province,
|
||||
city: city?.nameEn ?? item.city,
|
||||
address: item.address,
|
||||
postalCode: item.postalCode ?? '',
|
||||
landline: item.landline ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function isCompleteAddress(item: AddressDraft) {
|
||||
return item.province.trim() && item.city.trim() && item.address.trim()
|
||||
}
|
||||
|
||||
export function AddressesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [provinces, setProvinces] = useState<CityOption[]>([])
|
||||
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
|
||||
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [provinceItems, data] = await Promise.all([
|
||||
listIranProvinces(controller.signal),
|
||||
listAddresses(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setProvinces(provinceItems)
|
||||
|
||||
const draftItems =
|
||||
data.items.length > 0 ? data.items : []
|
||||
|
||||
const slugs = [
|
||||
...new Set(
|
||||
draftItems
|
||||
.map((item) => matchProvinceByName(item.province, provinceItems)?.slug)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
]
|
||||
const cityGroups = await Promise.all(
|
||||
slugs.map(async (slug) => ({
|
||||
slug,
|
||||
cities: await listCitiesByProvinceSlug(slug, controller.signal),
|
||||
})),
|
||||
)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
|
||||
setCitiesByProvince(citiesMap)
|
||||
setAddresses(
|
||||
draftItems.length > 0
|
||||
? draftItems.map((item) => toDraft(item, provinceItems, citiesMap))
|
||||
: [createEmptyAddressItem()],
|
||||
)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load addresses.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
function updateAddressDraft(index: number, patch: Partial<AddressDraft>) {
|
||||
setAddresses((prev) =>
|
||||
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleProvinceChange(index: number, provinceSlug: string) {
|
||||
const province = provinces.find((item) => item.slug === provinceSlug)
|
||||
updateAddressDraft(index, {
|
||||
provinceSlug,
|
||||
province: province?.nameEn ?? '',
|
||||
city: '',
|
||||
})
|
||||
|
||||
if (provinceSlug && !citiesByProvince[provinceSlug]) {
|
||||
const cities = await listCitiesByProvinceSlug(provinceSlug)
|
||||
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
|
||||
}
|
||||
}
|
||||
|
||||
function addAddressRow() {
|
||||
setAddresses((prev) => [...prev, createEmptyAddressItem()])
|
||||
}
|
||||
|
||||
async function handleRemove(index: number) {
|
||||
const target = addresses[index]
|
||||
if (!target) return
|
||||
|
||||
if (!target.id) {
|
||||
setAddresses((prev) =>
|
||||
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await removeAddress(target.id)
|
||||
setAddresses((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index)
|
||||
return next.length > 0 ? next : [createEmptyAddressItem()]
|
||||
})
|
||||
showToast('Address removed.', 'success')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove address.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
const payload = addresses.filter(isCompleteAddress)
|
||||
if (payload.length === 0) {
|
||||
setError('Add at least one complete address.')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
const saved: AddressDraft[] = []
|
||||
|
||||
for (const item of payload) {
|
||||
const input: UserAddressInput = {
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
postalCode: item.postalCode.trim() || undefined,
|
||||
landline: item.landline?.trim() || undefined,
|
||||
}
|
||||
|
||||
if (item.id) {
|
||||
const result = await updateAddress(item.id, input)
|
||||
saved.push(toDraft(result.address, provinces, citiesByProvince))
|
||||
} else {
|
||||
const result = await createAddress(input)
|
||||
saved.push(toDraft(result.address, provinces, citiesByProvince))
|
||||
}
|
||||
}
|
||||
|
||||
setAddresses(saved.length > 0 ? saved : [createEmptyAddressItem()])
|
||||
showToast('Addresses saved.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to save addresses. Please try again.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasAddresses = useMemo(
|
||||
() => addresses.some((item) => isCompleteAddress(item)),
|
||||
[addresses],
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Addresses' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Addresses</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage your shipping addresses for orders at this store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.section}>
|
||||
<AddressListEditor
|
||||
addresses={addresses}
|
||||
provinces={provinces}
|
||||
citiesByProvince={citiesByProvince}
|
||||
onAddressChange={updateAddressDraft}
|
||||
onProvinceChange={handleProvinceChange}
|
||||
onAdd={addAddressRow}
|
||||
onRemove={(index) => void handleRemove(index)}
|
||||
disabled={saving}
|
||||
loading={loading}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className={styles.saveBtn} disabled={saving || loading || !hasAddresses}>
|
||||
{saving ? 'Saving...' : 'Save addresses'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
height: var(--field-height);
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Heart } from 'lucide-react'
|
||||
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui'
|
||||
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listFavorites,
|
||||
removeFavorite,
|
||||
type FavoriteListing,
|
||||
type FavoritesListResponse,
|
||||
} from '../services/favoritesService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './FavoritesPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export function FavoritesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<FavoritesListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await listFavorites({ page, pageSize: PAGE_SIZE }, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load favorites.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [page])
|
||||
|
||||
async function handleRemove(listing: FavoriteListing) {
|
||||
setRemovingId(listing.favoriteId)
|
||||
try {
|
||||
await removeFavorite(listing.productId)
|
||||
setData((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
items: prev.items.filter((item) => item.favoriteId !== listing.favoriteId),
|
||||
total: Math.max(0, prev.total - 1),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
showToast('Removed from favorites.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to remove favorite.'
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddToCart() {
|
||||
showToast('Shopping cart is coming soon.', 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Favorites' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Favorites</h2>
|
||||
<p className={pageStyles.pageSubtitle}>Products you have saved for later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className={styles.status}>Loading favorites...</p>}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<Heart size={32} />
|
||||
<p>No favorites yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!data?.items.length && (
|
||||
<div className={styles.grid}>
|
||||
{data.items.map((listing) => (
|
||||
<FavoriteStoreItemCard
|
||||
key={listing.favoriteId}
|
||||
listing={listing}
|
||||
onRemove={handleRemove}
|
||||
onAddToCart={handleAddToCart}
|
||||
removing={removingId === listing.favoriteId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.total > PAGE_SIZE && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {Math.max(1, Math.ceil(data.total / data.pageSize))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= Math.ceil(data.total / data.pageSize)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { CalendarDays, User, MapPin, ShoppingBag, Heart } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SectionCard } from '@meshkee/dashboard-ui'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const sections = [
|
||||
{
|
||||
icon: User,
|
||||
title: 'My Profile',
|
||||
description: 'View and update your personal information and contact details.',
|
||||
linkText: 'View profile',
|
||||
href: '/profile',
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'My Addresses',
|
||||
description: 'Manage your shipping addresses for checkout and deliveries.',
|
||||
linkText: 'View addresses',
|
||||
href: '/addresses',
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'My Orders',
|
||||
description: 'Track your orders, view order history and order details.',
|
||||
linkText: 'View orders',
|
||||
href: '/orders',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: 'My Favorites',
|
||||
description: 'Browse and manage your saved favorite products.',
|
||||
linkText: 'View favorites',
|
||||
href: '/favorites',
|
||||
},
|
||||
]
|
||||
|
||||
function getFormattedDate() {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date())
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { user } = useAuth()
|
||||
const firstName = user?.firstName || 'there'
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>
|
||||
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
|
||||
</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Manage your profile, addresses, orders, and favorites in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.dateBadge}>
|
||||
<CalendarDays size={16} />
|
||||
<span>{getFormattedDate()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{sections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px 32px 32px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.domain {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) 40px var(--field-padding-y) 38px;
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.togglePassword:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.formActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.linkBtn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.submitBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
.footerText {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.codeHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.codeHint strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.resendRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.domainHint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: -12px 0 20px;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card {
|
||||
padding: 28px 20px 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import {
|
||||
useAuth,
|
||||
CUSTOMER_ACCESS_MESSAGE,
|
||||
VERIFICATION_REQUIRED_MESSAGE,
|
||||
} from '../context/AuthContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
import {
|
||||
logout as logoutRequest,
|
||||
register,
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
} from '../services/authService'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './LoginPage.module.css'
|
||||
|
||||
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
|
||||
type SmsStep = 'phone' | 'code'
|
||||
|
||||
function safeRedirectPath(value: string | null) {
|
||||
if (!value || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const redirectTo = safeRedirectPath(searchParams.get('redirect'))
|
||||
const { login } = useAuth()
|
||||
const tenantDomain = getTenantDomain()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [smsCode, setSmsCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function clearMessages() {
|
||||
setError('')
|
||||
setInfo('')
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setPhone('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setSmsCode('')
|
||||
setNewPassword('')
|
||||
setSmsStep('phone')
|
||||
setCodeSent(false)
|
||||
setShowPassword(false)
|
||||
clearMessages()
|
||||
}
|
||||
|
||||
function switchView(next: AuthView) {
|
||||
resetForm()
|
||||
setView(next)
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
setCountdown(60)
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function handleApiError(err: unknown, fallback: string) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendCode() {
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
const result = await sendOtp(cellNumber)
|
||||
|
||||
if (!result.enabled) {
|
||||
setInfo(result.message)
|
||||
}
|
||||
|
||||
setCodeSent(true)
|
||||
setSmsStep('code')
|
||||
startCountdown()
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to send verification code.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignup(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
const data = await register({
|
||||
cellNumber,
|
||||
password,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
domain: tenantDomain,
|
||||
})
|
||||
|
||||
if (data.user.customerBusinesses.length === 0) {
|
||||
logoutRequest()
|
||||
setError(CUSTOMER_ACCESS_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.user.cellVerifiedAt) {
|
||||
setInfo(
|
||||
`${VERIFICATION_REQUIRED_MESSAGE} Use one-time login with SMS to verify your number.`,
|
||||
)
|
||||
switchView('otp')
|
||||
setPhone(phone)
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to create account.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('Password must be at least 8 characters.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
setInfo(
|
||||
'Phone number verified. Full password reset via SMS is not available yet — please contact support or sign in if you remember your password.',
|
||||
)
|
||||
setTimeout(() => switchView('login'), 2500)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to verify code.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOtpLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
clearMessages()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await verifyOtp(cellNumber, smsCode)
|
||||
|
||||
if (!password) {
|
||||
setError('Enter your account password to complete sign-in after SMS verification.')
|
||||
return
|
||||
}
|
||||
|
||||
await login(cellNumber, password)
|
||||
navigate(redirectTo)
|
||||
} catch (err) {
|
||||
handleApiError(err, 'Unable to sign in with SMS verification.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain}>{tenantDomain}</span>
|
||||
<span className={styles.appName}>Customer Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.domainHint}>Store domain: {tenantDomain}</p>
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Welcome back</h1>
|
||||
<p className={styles.subtitle}>Sign in with your mobile number</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="login-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('forgot')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={() => switchView('otp')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<KeyRound size={18} />
|
||||
One-time login with SMS
|
||||
</button>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('signup')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'signup' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Create account</h1>
|
||||
<p className={styles.subtitle}>Register as a customer of {tenantDomain}</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-first">First name</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-first"
|
||||
type="text"
|
||||
placeholder="First name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-last">Last name</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<User size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-last"
|
||||
type="text"
|
||||
placeholder="Last name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Choose a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="signup-confirm">Confirm password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="signup-confirm"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Repeat your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerText}>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'forgot' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>Forgot password</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'We will send a verification code via SMS'
|
||||
: 'Enter the code and your new password'}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{info && <div className={styles.info}>{info}</div>}
|
||||
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-code">SMS verification code</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="forgot-new-password">New password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="forgot-new-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter new password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Verifying...' : 'Reset password'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'otp' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backBtn}
|
||||
onClick={() => switchView('login')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
Back to sign in
|
||||
</button>
|
||||
|
||||
<h1 className={styles.title}>One-time login</h1>
|
||||
<p className={styles.subtitle}>
|
||||
{smsStep === 'phone'
|
||||
? 'Verify your mobile number with a one-time SMS code'
|
||||
: 'Enter the SMS code and your password'}
|
||||
</p>
|
||||
|
||||
<form className={styles.form} onSubmit={handleOtpLogin}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smsStep === 'phone' ? (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-phone">Mobile number</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send SMS code'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{codeSent && (
|
||||
<p className={styles.codeHint}>
|
||||
Verification code sent to <strong>{phone}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-code">SMS verification code</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<KeyRound size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
value={smsCode}
|
||||
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="otp-password">Password</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="otp-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Your account password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.resendRow}>
|
||||
{countdown > 0 ? (
|
||||
<span className={styles.countdown}>Resend code in {countdown}s</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => void handleSendCode()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Resend SMS code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
.tablePanel {
|
||||
margin-top: 12px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.tableHeaderTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.colOrderId {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.colItems {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.colTotal {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colDate {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
.colStep {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.colSource {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.th,
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.th {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.td {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.orderNumber {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerCell {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.subText {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dateCell {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dateTime {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dateTimeSub {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sourceOperator {
|
||||
color: var(--primary-dark, var(--primary));
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.sourceWebsite {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.sourceApplication {
|
||||
color: #7c3aed;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
}
|
||||
|
||||
.stepBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.stepBadge:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.ordersFiltersRow {
|
||||
grid-column: span 10;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filterOrderId {
|
||||
flex: 0 1 156px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filterCustomer {
|
||||
flex: 0 1 168px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.filterDate {
|
||||
flex: 0 1 148px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.filterCost {
|
||||
flex: 0 1 124px;
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.ordersFiltersRow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filterOrderId,
|
||||
.filterCustomer,
|
||||
.filterDate,
|
||||
.filterCost {
|
||||
flex: 1 1 140px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.thActions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdActions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.actionBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.actionBtnDanger:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.actionBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pageBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageBtnActive {
|
||||
border-color: rgba(var(--primary-dark-rgb) / 0.35);
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-dark-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.errorBanner {
|
||||
padding: 16px;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Breadcrumbs } from '@meshkee/dashboard-ui'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderRow } from '../components/OrderRow'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
listOrders,
|
||||
type Order,
|
||||
type OrdersListResponse,
|
||||
} from '../services/orderService'
|
||||
import { DEFAULT_ORDER_PROCESS_STEPS } from '../utils/orderSteps'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './OrdersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const COLUMN_COUNT = 7
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
export function OrdersPage() {
|
||||
const [data, setData] = useState<OrdersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [viewOrder, setViewOrder] = useState<Order | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await listOrders({ page, pageSize: PAGE_SIZE }, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load orders.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [page])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
}, [data, page])
|
||||
|
||||
const showingTo = useMemo(() => {
|
||||
if (!data) return 0
|
||||
return Math.min(data.total, page * PAGE_SIZE)
|
||||
}, [data, page])
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Orders' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Orders</h2>
|
||||
<p className={pageStyles.pageSubtitle}>View your order history and details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Order list</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
) : (
|
||||
'No orders'
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<colgroup>
|
||||
<col className={styles.colOrderId} />
|
||||
<col className={styles.colItems} />
|
||||
<col className={styles.colTotal} />
|
||||
<col className={styles.colDate} />
|
||||
<col className={styles.colStep} />
|
||||
<col className={styles.colSource} />
|
||||
<col className={styles.colActions} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Order ID</th>
|
||||
<th className={styles.th}>Items</th>
|
||||
<th className={styles.th}>Total cost</th>
|
||||
<th className={styles.th}>Date & time</th>
|
||||
<th className={styles.th}>Step</th>
|
||||
<th className={styles.th}>Registered by</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading orders...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
You have no orders yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items.map((order) => (
|
||||
<OrderRow
|
||||
key={order.id}
|
||||
order={order}
|
||||
processSteps={DEFAULT_ORDER_PROCESS_STEPS}
|
||||
onViewItems={setViewOrder}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{data && data.total > PAGE_SIZE && (
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrderItemsModal
|
||||
open={viewOrder !== null}
|
||||
order={viewOrder}
|
||||
onClose={() => setViewOrder(null)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.col4 {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.col12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
height: var(--field-height);
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
font-size: var(--field-font-size);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
height: auto;
|
||||
padding: 0.65rem 0.75rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
height: var(--field-height);
|
||||
padding: 0 1.25rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.col3 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.col4 {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.col3,
|
||||
.col4,
|
||||
.col12 {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { updateProfile } from '../services/authService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProfilePage.module.css'
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, setUser } = useAuth()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [landline, setLandline] = useState('')
|
||||
const [backupPhone, setBackupPhone] = useState('')
|
||||
const [about, setAbout] = useState('')
|
||||
const [instagram, setInstagram] = useState('')
|
||||
const [telegramId, setTelegramId] = useState('')
|
||||
const [linkedin, setLinkedin] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
setFirstName(user.firstName ?? '')
|
||||
setLastName(user.lastName ?? '')
|
||||
setEmail(user.email ?? '')
|
||||
setLandline(user.profile.landline ?? '')
|
||||
setBackupPhone(user.profile.backupPhone ?? '')
|
||||
setAbout(user.profile.about ?? '')
|
||||
setInstagram(user.profile.instagram ?? '')
|
||||
setTelegramId(user.profile.telegramId ?? '')
|
||||
setLinkedin(user.profile.linkedin ?? '')
|
||||
}, [user])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const result = await updateProfile({
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
email: email.trim() || undefined,
|
||||
landline: landline.trim(),
|
||||
backupPhone: backupPhone.trim(),
|
||||
about: about.trim(),
|
||||
instagram: instagram.trim(),
|
||||
telegramId: telegramId.trim(),
|
||||
linkedin: linkedin.trim(),
|
||||
})
|
||||
|
||||
setUser(result.user)
|
||||
showToast('Profile updated successfully.', 'success')
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : 'Unable to update profile. Please try again.'
|
||||
setError(message)
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'My Profile' }]} />
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Profile</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Update your personal information and contact details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Account</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-cell">Mobile number</label>
|
||||
<input
|
||||
id="profile-cell"
|
||||
type="text"
|
||||
value={user ? formatCellForDisplay(user.cellNumber) : ''}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
id="profile-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-first">First name</label>
|
||||
<input
|
||||
id="profile-first"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-last">Last name</label>
|
||||
<input
|
||||
id="profile-last"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-landline">Landline</label>
|
||||
<input
|
||||
id="profile-landline"
|
||||
type="text"
|
||||
value={landline}
|
||||
onChange={(e) => setLandline(e.target.value)}
|
||||
placeholder="02112345678"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="profile-backup-phone">Backup phone number</label>
|
||||
<input
|
||||
id="profile-backup-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={backupPhone}
|
||||
onChange={(e) => setBackupPhone(e.target.value)}
|
||||
placeholder="09123456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>About</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label htmlFor="profile-about">About</label>
|
||||
<textarea
|
||||
id="profile-about"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Social</h3>
|
||||
<div className={styles.grid}>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-instagram">Instagram</label>
|
||||
<input
|
||||
id="profile-instagram"
|
||||
type="text"
|
||||
value={instagram}
|
||||
onChange={(e) => setInstagram(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-telegram">Telegram</label>
|
||||
<input
|
||||
id="profile-telegram"
|
||||
type="text"
|
||||
value={telegramId}
|
||||
onChange={(e) => setTelegramId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.field} ${styles.col4}`}>
|
||||
<label htmlFor="profile-linkedin">LinkedIn</label>
|
||||
<input
|
||||
id="profile-linkedin"
|
||||
type="text"
|
||||
value={linkedin}
|
||||
onChange={(e) => setLinkedin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Minus, Package, Plus, ShoppingBag, Trash2 } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
loadGuestCart,
|
||||
saveGuestCart,
|
||||
type GuestCartItem,
|
||||
} from '../../lib/guestCart'
|
||||
import {
|
||||
getCart,
|
||||
removeCartItem,
|
||||
updateCartItem,
|
||||
type Cart,
|
||||
} from '../../services/cartService'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
type DisplayItem = {
|
||||
id: string
|
||||
title: string
|
||||
variant?: string
|
||||
image: string | null
|
||||
quantity: number
|
||||
lineTotal: number
|
||||
stockQuantity: number | null
|
||||
}
|
||||
|
||||
function guestToDisplay(items: GuestCartItem[]): DisplayItem[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.name,
|
||||
image: item.image,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.price * item.quantity,
|
||||
stockQuantity: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function formatVariantLabel(item: Cart['items'][number]): string | undefined {
|
||||
if (item.selections.length > 0) {
|
||||
return item.selections.map((selection) => selection.value).join(' · ')
|
||||
}
|
||||
|
||||
const title = item.productNameFa?.trim() || item.productTitle
|
||||
if (item.label && item.label !== title && item.label !== item.productTitle) {
|
||||
return item.label
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function apiToDisplay(cart: Cart): DisplayItem[] {
|
||||
return cart.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.productNameFa?.trim() || item.productTitle,
|
||||
variant: formatVariantLabel(item),
|
||||
image: item.productImage,
|
||||
quantity: item.quantity,
|
||||
lineTotal: item.lineTotal,
|
||||
stockQuantity: item.stockQuantity,
|
||||
}))
|
||||
}
|
||||
|
||||
export function CheckoutCartStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { user } = useAuth()
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
const [cart, setCart] = useState<Cart | null>(null)
|
||||
const [guestItems, setGuestItems] = useState<GuestCartItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [busyItemId, setBusyItemId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
const guest = loadGuestCart()
|
||||
setGuestItems(guest)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setCart(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let data = await getCart(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (data.cart.items.length === 0 && guest.length > 0) {
|
||||
const synced = await syncGuestCartToServer(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
data = { cart: synced }
|
||||
}
|
||||
|
||||
setCart(data.cart)
|
||||
if ((data.cart?.items?.length ?? 0) > 0) {
|
||||
setGuestItems([])
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سبد خرید ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [isAuthenticated])
|
||||
|
||||
const apiHasItems = (cart?.items.length ?? 0) > 0
|
||||
const usingGuest = !isAuthenticated || !apiHasItems
|
||||
|
||||
const items = useMemo(
|
||||
() => (usingGuest ? guestToDisplay(guestItems) : cart ? apiToDisplay(cart) : []),
|
||||
[usingGuest, guestItems, cart],
|
||||
)
|
||||
|
||||
const subtotal = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.lineTotal, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
const totalQuantity = useMemo(
|
||||
() => items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
[items],
|
||||
)
|
||||
|
||||
function persistGuest(next: GuestCartItem[]) {
|
||||
setGuestItems(next)
|
||||
saveGuestCart(next)
|
||||
}
|
||||
|
||||
async function handleQuantityChange(itemId: string, nextQuantity: number) {
|
||||
if (usingGuest) {
|
||||
if (nextQuantity <= 0) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
return
|
||||
}
|
||||
persistGuest(
|
||||
guestItems.map((item) =>
|
||||
item.id === itemId ? { ...item, quantity: nextQuantity } : item,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await updateCartItem(itemId, nextQuantity)
|
||||
setCart(data.cart)
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'بهروزرسانی تعداد ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
if (usingGuest) {
|
||||
persistGuest(guestItems.filter((item) => item.id !== itemId))
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
return
|
||||
}
|
||||
|
||||
setBusyItemId(itemId)
|
||||
try {
|
||||
const data = await removeCartItem(itemId)
|
||||
setCart(data.cart)
|
||||
showToast('محصول از سبد حذف شد.', 'success')
|
||||
} catch (err) {
|
||||
showToast(err instanceof ApiError ? err.message : 'حذف محصول ممکن نشد.', 'error')
|
||||
} finally {
|
||||
setBusyItemId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinue() {
|
||||
if (!isAuthenticated) {
|
||||
navigate(`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`, {
|
||||
state: { from: '/checkout/delivery' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (usingGuest && guestItems.length > 0) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const cart = await syncGuestCartToServer()
|
||||
setCart(cart)
|
||||
setGuestItems([])
|
||||
if (cart.items.length === 0) {
|
||||
showToast('انتقال سبد خرید ممکن نشد. دوباره تلاش کنید.', 'error')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(
|
||||
err instanceof ApiError ? err.message : 'انتقال سبد خرید ممکن نشد.',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
navigate('/checkout/delivery')
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری سبد خرید...</div>
|
||||
}
|
||||
|
||||
if (error && items.length === 0) {
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<button type="button" className={styles.secondaryBtn} onClick={() => window.location.reload()}>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>سبد خرید شما</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
{items.length === 0
|
||||
? 'سبد خرید شما خالی است.'
|
||||
: 'آماده تکمیل سفارش هستید.'}
|
||||
{!isAuthenticated && items.length > 0
|
||||
? ' برای ادامه، در مرحله بعد وارد شوید.'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<div className={styles.emptyIcon}>
|
||||
<ShoppingBag size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<p>برای شروع خرید، از فروشگاه محصول اضافه کنید.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.cartList}>
|
||||
{items.map((item) => {
|
||||
const busy = busyItemId === item.id
|
||||
const atMax =
|
||||
item.stockQuantity !== null && item.quantity >= item.stockQuantity
|
||||
|
||||
return (
|
||||
<article key={item.id} className={styles.cartItem}>
|
||||
{item.image ? (
|
||||
<img src={item.image} alt="" className={styles.cartItemImage} />
|
||||
) : (
|
||||
<div className={`${styles.cartItemImage} ${styles.cartItemImagePlaceholder}`}>
|
||||
<Package size={22} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.cartItemInfo}>
|
||||
<div className={styles.cartItemTitle}>{item.title}</div>
|
||||
{item.variant ? (
|
||||
<div className={styles.cartItemVariant}>{item.variant}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.cartItemFooter}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => void handleRemove(item.id)}
|
||||
disabled={busy}
|
||||
aria-label="حذف محصول"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<div className={styles.qtyControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity - 1)}
|
||||
disabled={busy || item.quantity <= 1}
|
||||
aria-label="کاهش تعداد"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<span className={styles.qtyValue}>{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() => void handleQuantityChange(item.id, item.quantity + 1)}
|
||||
disabled={busy || atMax}
|
||||
aria-label="افزایش تعداد"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<span className={styles.cartItemPrice}>{formatIrtPrice(item.lineTotal)}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تعداد کالا</span>
|
||||
<span>{totalQuantity.toLocaleString('fa-IR')} عدد</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>جمع کل</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={isAuthenticated ? 'ادامه به ارسال' : 'ادامه به ورود'}
|
||||
onPrimary={handleContinue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, useNavigate } from 'react-router-dom'
|
||||
import { MapPin, Store } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import { listAddresses, type UserAddress } from '../../services/addressService'
|
||||
import {
|
||||
CheckoutAddAddressRow,
|
||||
} from '../../components/checkout/CheckoutAddAddressPanel'
|
||||
import { CheckoutAddAddressModal } from '../../components/checkout/CheckoutAddAddressModal'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutDeliveryStep() {
|
||||
const navigate = useNavigate()
|
||||
const { user, isLoading: authLoading } = useAuth()
|
||||
const { deliveryMode, selectedAddressId, setDeliveryMode, setSelectedAddressId } =
|
||||
useCheckout()
|
||||
const [addresses, setAddresses] = useState<UserAddress[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const selectedAddressIdRef = useRef(selectedAddressId)
|
||||
selectedAddressIdRef.current = selectedAddressId
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await listAddresses(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
setAddresses(data.items)
|
||||
|
||||
const current = selectedAddressIdRef.current
|
||||
if (data.items.length === 0) {
|
||||
if (current !== null) setSelectedAddressId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stillValid = current !== null && data.items.some((item) => item.id === current)
|
||||
if (!stillValid) {
|
||||
setSelectedAddressId(data.items[0].id)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [user, setSelectedAddressId])
|
||||
|
||||
async function handleAddressSaved() {
|
||||
try {
|
||||
const data = await listAddresses()
|
||||
setAddresses(data.items)
|
||||
const newest = data.items.at(-1)
|
||||
if (newest) setSelectedAddressId(newest.id)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری آدرسها ممکن نشد.')
|
||||
}
|
||||
}
|
||||
|
||||
const canContinue =
|
||||
deliveryMode === 'pickup' || (deliveryMode === 'delivery' && selectedAddressId)
|
||||
|
||||
if (authLoading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/checkout/login?redirect=${encodeURIComponent('/checkout/delivery')}`}
|
||||
replace
|
||||
state={{ from: '/checkout/delivery' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<CheckoutBackButton
|
||||
label="بازگشت به سبد خرید"
|
||||
onClick={() => navigate('/checkout/cart')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>نحوه دریافت</h1>
|
||||
<p className={styles.stepDesc}>چگونه میخواهید سفارش را دریافت کنید؟</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.deliveryOptions}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.deliveryOption,
|
||||
deliveryMode === 'delivery' ? styles.deliveryOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setDeliveryMode('delivery')}
|
||||
>
|
||||
<MapPin size={22} />
|
||||
<span className={styles.deliveryOptionLabel}>ارسال به آدرس</span>
|
||||
<span className={styles.deliveryOptionHint}>ارسال به یکی از آدرسهای ذخیرهشده</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.deliveryOption,
|
||||
deliveryMode === 'pickup' ? styles.deliveryOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => {
|
||||
setDeliveryMode('pickup')
|
||||
setShowAddModal(false)
|
||||
}}
|
||||
>
|
||||
<Store size={22} />
|
||||
<span className={styles.deliveryOptionLabel}>تحویل حضوری</span>
|
||||
<span className={styles.deliveryOptionHint}>دریافت از فروشگاه</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deliveryMode === 'pickup' ? (
|
||||
<p className={styles.pickupNote}>
|
||||
سفارش را از فروشگاه تحویل میگیرید. پس از آمادهسازی به شما اطلاع میدهیم.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={styles.loading}>در حال بارگذاری آدرسها...</div>
|
||||
) : (
|
||||
<div className={styles.addressList}>
|
||||
{addresses.length === 0 && (
|
||||
<p className={styles.addressEmptyHint}>
|
||||
هنوز آدرسی ذخیره نکردهاید. یک آدرس جدید اضافه کنید یا تحویل حضوری را انتخاب
|
||||
کنید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{addresses.map((address) => {
|
||||
const active = selectedAddressId === address.id
|
||||
return (
|
||||
<button
|
||||
key={address.id}
|
||||
type="button"
|
||||
className={[
|
||||
styles.addressCard,
|
||||
active ? styles.addressCardActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setSelectedAddressId(address.id)}
|
||||
>
|
||||
<span className={styles.radio}>
|
||||
{active && <span className={styles.radioInner} />}
|
||||
</span>
|
||||
<span className={styles.addressCardBody}>
|
||||
<span className={styles.addressCardTitle}>
|
||||
{address.label || address.city}
|
||||
</span>
|
||||
<span className={styles.addressCardText}>
|
||||
{[
|
||||
address.label ? address.city : null,
|
||||
address.address,
|
||||
address.postalCode,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<CheckoutAddAddressRow onClick={() => setShowAddModal(true)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<CheckoutAddAddressModal
|
||||
open={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSaved={() => void handleAddressSaved()}
|
||||
/>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel="ادامه به پرداخت"
|
||||
onPrimary={() => navigate('/checkout/payment')}
|
||||
primaryDisabled={!canContinue}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import orderFailedImg from '../../assets/order-failed.png'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutFailedStep() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const message =
|
||||
(location.state as { message?: string } | null)?.message ||
|
||||
'ثبت سفارش ممکن نشد. لطفاً دوباره تلاش کنید.'
|
||||
|
||||
return (
|
||||
<div className={`${styles.stepContent} ${styles.successContent}`}>
|
||||
<div className={styles.successIcon}>
|
||||
<img
|
||||
src={orderFailedImg}
|
||||
alt="ثبت سفارش ناموفق"
|
||||
className={styles.successImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت نشد</h1>
|
||||
<p className={styles.stepDesc}>{message}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.successPrimaryBtn}
|
||||
onClick={() => navigate('/checkout/payment')}
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
<Link to="/checkout/cart" className={styles.successNeutralBtn}>
|
||||
بازگشت به سبد خرید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { RouteLoader } from '@meshkee/dashboard-ui'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { CheckoutStepper, type CheckoutStepId } from '../../components/checkout/CheckoutStepper'
|
||||
|
||||
/** Cart and checkout login are public. Delivery, payment, and success require auth. */
|
||||
const PUBLIC_STEPS = new Set<CheckoutStepId>(['cart', 'login'])
|
||||
|
||||
function stepFromPath(pathname: string): CheckoutStepId {
|
||||
if (pathname.endsWith('/login')) return 'login'
|
||||
if (pathname.endsWith('/delivery')) return 'delivery'
|
||||
if (pathname.endsWith('/payment')) return 'payment'
|
||||
if (pathname.endsWith('/success') || pathname.endsWith('/failed')) return 'payment'
|
||||
return 'cart'
|
||||
}
|
||||
|
||||
function loginRedirectPath(returnTo: string) {
|
||||
return `/checkout/login?redirect=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
export function CheckoutFlow() {
|
||||
const { user, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
const step = stepFromPath(location.pathname)
|
||||
const isAuthenticated = Boolean(user)
|
||||
|
||||
if (isLoading) {
|
||||
return <RouteLoader />
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !PUBLIC_STEPS.has(step)) {
|
||||
return (
|
||||
<Navigate
|
||||
to={loginRedirectPath(location.pathname)}
|
||||
replace
|
||||
state={{ from: location.pathname }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isAuthenticated && step === 'login') {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const fromQuery = params.get('redirect')
|
||||
const fromState = (location.state as { from?: string } | null)?.from
|
||||
const destination =
|
||||
fromQuery && fromQuery.startsWith('/') && !fromQuery.startsWith('//')
|
||||
? fromQuery
|
||||
: fromState ?? '/checkout/delivery'
|
||||
return <Navigate to={destination} replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{step !== 'login' &&
|
||||
location.pathname !== '/checkout/success' &&
|
||||
location.pathname !== '/checkout/failed' && (
|
||||
<CheckoutStepper current={step} isAuthenticated={isAuthenticated} />
|
||||
)}
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, Smartphone } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { ApiError } from '../../lib/api'
|
||||
import { toE164CellNumber } from '../../lib/cellNumber'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutLoginStep() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
function resolveRedirectPath() {
|
||||
const fromQuery = searchParams.get('redirect')
|
||||
if (fromQuery && fromQuery.startsWith('/') && !fromQuery.startsWith('//')) {
|
||||
return fromQuery
|
||||
}
|
||||
const fromState = (location.state as { from?: string } | null)?.from
|
||||
if (fromState && fromState.startsWith('/') && !fromState.startsWith('//')) {
|
||||
return fromState
|
||||
}
|
||||
return '/checkout/delivery'
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const cellNumber = toE164CellNumber(phone)
|
||||
await login(cellNumber, password)
|
||||
try {
|
||||
await syncGuestCartToServer()
|
||||
} catch {
|
||||
// Cart sync is best-effort; delivery/payment will retry
|
||||
}
|
||||
navigate(resolveRedirectPath(), { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'ورود ممکن نشد.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>برای ادامه وارد شوید</h1>
|
||||
<p className={styles.stepDesc}>
|
||||
سبد خرید شما ذخیره شده است. برای تعیین ارسال و پرداخت وارد شوید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className={styles.loginForm} onSubmit={(e) => void handleSubmit(e)}>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="checkout-phone">شماره موبایل</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Smartphone size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="checkout-phone"
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
placeholder="09122222222"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="checkout-password">رمز عبور</label>
|
||||
<div className={styles.inputWrap}>
|
||||
<Lock size={18} className={styles.inputIcon} />
|
||||
<input
|
||||
id="checkout-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="رمز عبور خود را وارد کنید"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.primaryBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'در حال ورود...' : 'ادامه به ارسال'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.footerLink}>
|
||||
حساب کاربری ندارید؟{' '}
|
||||
<Link to="/login?redirect=/checkout/cart">ثبتنام کنید</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Building2, CreditCard } from 'lucide-react'
|
||||
import { useToast } from '@meshkee/dashboard-ui'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import { ApiError, isAbortError } from '../../lib/api'
|
||||
import {
|
||||
checkoutCart,
|
||||
getCart,
|
||||
type CheckoutPaymentType,
|
||||
type OnlineGatewayType,
|
||||
} from '../../services/cartService'
|
||||
import { syncGuestCartToServer } from '../../services/syncGuestCart'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import { CheckoutActionBar } from '../../components/checkout/CheckoutActionBar'
|
||||
import { CheckoutBackButton } from '../../components/checkout/CheckoutBackButton'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
const ONLINE_GATEWAYS: { id: OnlineGatewayType; label: string }[] = [
|
||||
{ id: 'mellat_behpardakht', label: 'درگاه پرداخت بانک ملت - بهپرداخت' },
|
||||
{ id: 'saman_kish', label: 'درگاه پرداخت بانک سامان - سامان کیش' },
|
||||
]
|
||||
|
||||
export function CheckoutPaymentStep() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { deliveryMode, selectedAddressId, discountCode, setDiscountCode, setPlacedOrder } =
|
||||
useCheckout()
|
||||
const [subtotal, setSubtotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [paymentType, setPaymentType] = useState<CheckoutPaymentType>('e_payment_gate')
|
||||
const [gatewayType, setGatewayType] = useState<OnlineGatewayType>('mellat_behpardakht')
|
||||
const [transferAccount, setTransferAccount] = useState('')
|
||||
const [transferRefNumber, setTransferRefNumber] = useState('')
|
||||
const [discountApplied, setDiscountApplied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
let data = await getCart(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
|
||||
if (data.cart.items.length === 0) {
|
||||
try {
|
||||
const synced = await syncGuestCartToServer(controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
data = { cart: synced }
|
||||
} catch {
|
||||
// fall through to empty-cart redirect
|
||||
}
|
||||
}
|
||||
|
||||
setSubtotal(data.cart.subtotal)
|
||||
if (data.cart.items.length === 0) {
|
||||
navigate('/checkout/cart', { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setError(err instanceof ApiError ? err.message : 'بارگذاری سبد خرید ممکن نشد.')
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [navigate])
|
||||
|
||||
function handleApplyDiscount() {
|
||||
if (!discountCode.trim()) return
|
||||
setDiscountApplied(true)
|
||||
showToast('کد تخفیف هنوز فعال نیست — مبلغ تغییری نکرد.', 'info')
|
||||
}
|
||||
|
||||
function buildPaymentPayload() {
|
||||
if (paymentType === 'transfer') {
|
||||
return {
|
||||
type: 'transfer' as const,
|
||||
transferAccount: transferAccount.trim(),
|
||||
transferRefNumber: transferRefNumber.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'e_payment_gate' as const,
|
||||
gatewayType,
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlaceOrder() {
|
||||
setError('')
|
||||
|
||||
if (deliveryMode === 'delivery' && !selectedAddressId) {
|
||||
setError('لطفاً یک آدرس ارسال انتخاب کنید.')
|
||||
return
|
||||
}
|
||||
|
||||
// Only bank transfer requires extra fields before creating the order
|
||||
if (paymentType === 'transfer') {
|
||||
if (!transferAccount.trim() || !transferRefNumber.trim()) {
|
||||
setError('لطفاً شماره حساب/کارت و شماره مرجع را وارد کنید.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
|
||||
const notes: string[] = []
|
||||
if (deliveryMode === 'pickup') {
|
||||
notes.push('تحویل حضوری از فروشگاه')
|
||||
}
|
||||
if (discountCode.trim()) {
|
||||
notes.push(`کد تخفیف: ${discountCode.trim()}`)
|
||||
}
|
||||
|
||||
try {
|
||||
// Both types persist via existing backend TransactionType:
|
||||
// e_payment_gate (+ gatewayType) | transfer (+ account/ref)
|
||||
const payment = buildPaymentPayload()
|
||||
const payload =
|
||||
deliveryMode === 'pickup'
|
||||
? {
|
||||
shippingAddress: {
|
||||
province: 'Pickup',
|
||||
city: 'Store',
|
||||
address: 'In-store pickup',
|
||||
postalCode: '0000000000',
|
||||
},
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
payment,
|
||||
}
|
||||
: {
|
||||
addressId: selectedAddressId!,
|
||||
customerNotes: notes.join(' · ') || undefined,
|
||||
payment,
|
||||
}
|
||||
|
||||
const result = await checkoutCart(payload)
|
||||
setPlacedOrder(result.order)
|
||||
// Pass order in navigation state so success page does not race context updates
|
||||
navigate('/checkout/success', { replace: true, state: { order: result.order } })
|
||||
showToast('سفارش با موفقیت ثبت شد.', 'success')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'ثبت سفارش ممکن نشد.'
|
||||
navigate('/checkout/failed', { replace: true, state: { message } })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>در حال بارگذاری...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<CheckoutBackButton
|
||||
label="بازگشت به ارسال"
|
||||
onClick={() => navigate('/checkout/delivery')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h1 className={styles.stepTitle}>پرداخت</h1>
|
||||
<p className={styles.stepDesc}>کد تخفیف را وارد کنید و روش پرداخت را انتخاب کنید.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="discount-code">کد تخفیف</label>
|
||||
<div className={styles.discountRow}>
|
||||
<input
|
||||
id="discount-code"
|
||||
type="text"
|
||||
placeholder="کد را وارد کنید"
|
||||
value={discountCode}
|
||||
onChange={(e) => {
|
||||
setDiscountCode(e.target.value)
|
||||
setDiscountApplied(false)
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.applyBtn}
|
||||
onClick={handleApplyDiscount}
|
||||
disabled={submitting || !discountCode.trim()}
|
||||
>
|
||||
اعمال
|
||||
</button>
|
||||
</div>
|
||||
{discountApplied && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
کد ذخیره شد — در یادداشت سفارش ثبت میشود.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>روش پرداخت</label>
|
||||
<div className={styles.paymentOptions}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.paymentOption,
|
||||
paymentType === 'e_payment_gate' ? styles.paymentOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setPaymentType('e_payment_gate')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<CreditCard size={20} />
|
||||
<span className={styles.paymentOptionBody}>
|
||||
<span className={styles.paymentOptionTitle}>پرداخت آنلاین</span>
|
||||
<span className={styles.paymentOptionHint}>پرداخت امن از طریق درگاه</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{paymentType === 'e_payment_gate' && (
|
||||
<div className={styles.paymentSubOptions} role="radiogroup" aria-label="انتخاب درگاه">
|
||||
{ONLINE_GATEWAYS.map((gateway) => {
|
||||
const active = gatewayType === gateway.id
|
||||
return (
|
||||
<button
|
||||
key={gateway.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={[
|
||||
styles.paymentSubOption,
|
||||
active ? styles.paymentSubOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setGatewayType(gateway.id)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<span className={styles.paymentSubRadio}>
|
||||
{active ? <span className={styles.paymentSubRadioInner} /> : null}
|
||||
</span>
|
||||
<span>{gateway.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.paymentOption,
|
||||
paymentType === 'transfer' ? styles.paymentOptionActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={() => setPaymentType('transfer')}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Building2 size={20} />
|
||||
<span className={styles.paymentOptionBody}>
|
||||
<span className={styles.paymentOptionTitle}>جابجایی بانکی</span>
|
||||
<span className={styles.paymentOptionHint}>واریز به حساب و ثبت شماره مرجع</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{paymentType === 'transfer' && (
|
||||
<div className={styles.transferFields}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="transfer-account">شماره حساب بانکی یا کارت شما</label>
|
||||
<input
|
||||
id="transfer-account"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={transferAccount}
|
||||
onChange={(e) => setTransferAccount(e.target.value)}
|
||||
placeholder="شماره حساب یا کارت"
|
||||
disabled={submitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="transfer-ref">شماره مرجع</label>
|
||||
<input
|
||||
id="transfer-ref"
|
||||
type="text"
|
||||
value={transferRefNumber}
|
||||
onChange={(e) => setTransferRefNumber(e.target.value)}
|
||||
placeholder="شماره مرجع تراکنش"
|
||||
disabled={submitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>جمع جزء</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className={styles.summaryRow}>
|
||||
<span>تخفیف</span>
|
||||
<span className={styles.priceEn}>۰ IRT</span>
|
||||
</div>
|
||||
<div className={`${styles.summaryRow} ${styles.summaryRowTotal}`}>
|
||||
<span>مبلغ قابل پرداخت</span>
|
||||
<span className={styles.priceEn}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CheckoutActionBar
|
||||
primaryLabel={submitting ? 'در حال ثبت سفارش...' : 'پرداخت و ثبت سفارش'}
|
||||
onPrimary={() => void handlePlaceOrder()}
|
||||
primaryDisabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stepDesc {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.actionsStack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
flex: 1;
|
||||
padding: 13px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
|
||||
transition: transform 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.primaryBtn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.primaryBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: -8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cartList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cartItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.cartItemImage {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.cartItemImagePlaceholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cartItemInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.cartItemTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cartItemVariant {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cartItemFooter {
|
||||
display: flex;
|
||||
direction: ltr;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cartItemPrice {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.qtyControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qtyBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.qtyBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.qtyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.qtyValue {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.removeBtn:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.priceEn {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.summaryRowTotal {
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
background: #f4f4f5;
|
||||
border: 1px solid #e4e4e7;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.summaryRowTotal .priceEn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
.deliveryOptions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.deliveryOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 12px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deliveryOption:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.deliveryOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.deliveryOptionLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.deliveryOptionHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.addressList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addressEmptyHint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
.addressCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.addressCard:hover {
|
||||
border-color: rgba(var(--primary-rgb) / 0.4);
|
||||
}
|
||||
|
||||
.addressCardActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.addressCardBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.addressCardTitle {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addressCardText {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.5);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.addressCardActive .radio {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.radioInner {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pickupNote {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 14px;
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.discountRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discountRow input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.applyBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.paymentOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.paymentOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.paymentOptionBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.paymentOptionTitle {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paymentOptionHint {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.paymentOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paymentSubOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.paymentSubOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1.5px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-fa);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.paymentSubOptionActive {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.paymentSubRadio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(148, 163, 184, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.paymentSubOptionActive .paymentSubRadio {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.paymentSubRadioInner {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.transferFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.successImage {
|
||||
width: min(320px, 78vw);
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.successContent {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 18px;
|
||||
padding-block: 8px 4px;
|
||||
}
|
||||
|
||||
.successHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.successHeader .stepTitle,
|
||||
.successHeader .stepDesc {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.successMeta {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.successMetaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 11px 4px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.successMetaRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.successMetaRow dt {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.successMetaRow dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.successMetaValue {
|
||||
font-family: 'Montserrat', var(--font-en), sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.successActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.successPrimaryBtn,
|
||||
.successNeutralBtn,
|
||||
.successDarkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 188px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s, transform 0.2s, box-shadow 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.successDarkBtn {
|
||||
color: #f8fafc;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0f172a;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.successDarkBtn:hover {
|
||||
background: #0f172a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.successNeutralBtn {
|
||||
color: #1e293b;
|
||||
background: #e8eaed;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.successNeutralBtn:hover {
|
||||
background: #dfe3e8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Kept for failed-page retry button (non-theme) */
|
||||
.successPrimaryBtn {
|
||||
color: #f8fafc;
|
||||
background: #1e293b;
|
||||
border: 1px solid #0f172a;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.successPrimaryBtn:hover {
|
||||
background: #0f172a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.successOrder {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.successOrder strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputIcon {
|
||||
position: absolute;
|
||||
inset-inline-start: 12px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inputWrap input {
|
||||
width: 100%;
|
||||
min-height: var(--field-height);
|
||||
padding-block: var(--field-padding-y);
|
||||
padding-inline: 38px 40px;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.inputWrap input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
position: absolute;
|
||||
inset-inline-end: 12px;
|
||||
display: flex;
|
||||
color: var(--text-muted);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.footerLink {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footerLink button {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.deliveryOptions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useCheckout } from '../../context/CheckoutContext'
|
||||
import type { Order } from '../../services/orderService'
|
||||
import { getWebsiteUrl } from '../../services/websiteService'
|
||||
import { formatIrtPrice } from '../../utils/irtPrice'
|
||||
import orderSucceedImg from '../../assets/order-succeed.png'
|
||||
import styles from './CheckoutSteps.module.css'
|
||||
|
||||
export function CheckoutSuccessStep() {
|
||||
const location = useLocation()
|
||||
const { placedOrder, setPlacedOrder, resetCheckout } = useCheckout()
|
||||
const orderFromNav = (location.state as { order?: Order } | null)?.order
|
||||
const order = placedOrder ?? orderFromNav ?? null
|
||||
const websiteUrl = getWebsiteUrl()
|
||||
|
||||
useEffect(() => {
|
||||
if (!placedOrder && orderFromNav) {
|
||||
setPlacedOrder(orderFromNav)
|
||||
}
|
||||
}, [placedOrder, orderFromNav, setPlacedOrder])
|
||||
|
||||
if (!order) {
|
||||
return <Navigate to="/checkout/cart" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.stepContent} ${styles.successContent}`}>
|
||||
<div className={styles.successIcon}>
|
||||
<img
|
||||
src={orderSucceedImg}
|
||||
alt="سفارش با موفقیت ثبت شد"
|
||||
className={styles.successImage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.successHeader}>
|
||||
<h1 className={styles.stepTitle}>سفارش ثبت شد</h1>
|
||||
<p className={styles.stepDesc}>از خرید شما سپاسگزاریم — سفارش دریافت شد.</p>
|
||||
</div>
|
||||
|
||||
<dl className={styles.successMeta}>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>شماره سفارش</dt>
|
||||
<dd className={styles.successMetaValue}>{order.orderNumber}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>مبلغ</dt>
|
||||
<dd className={styles.successMetaValue}>{formatIrtPrice(order.total)}</dd>
|
||||
</div>
|
||||
<div className={styles.successMetaRow}>
|
||||
<dt>وضعیت</dt>
|
||||
<dd>در انتظار تأیید</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.successActions}>
|
||||
<Link to="/" className={styles.successDarkBtn} onClick={() => resetCheckout()}>
|
||||
رفتن به داشبورد مدیریت
|
||||
</Link>
|
||||
<a href={websiteUrl} className={styles.successNeutralBtn} onClick={() => resetCheckout()}>
|
||||
بازگشت به وبسایت
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
|
||||
export interface UserAddress {
|
||||
id: string
|
||||
label: string | null
|
||||
province: string
|
||||
city: string
|
||||
address: string
|
||||
postalCode: string | null
|
||||
landline: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UserAddressInput {
|
||||
label?: string
|
||||
province: string
|
||||
city: string
|
||||
address: string
|
||||
postalCode?: string
|
||||
landline?: string
|
||||
}
|
||||
|
||||
export async function listAddresses(signal?: AbortSignal) {
|
||||
return apiRequest<{ items: UserAddress[] }>('/auth/addresses', { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function createAddress(input: UserAddressInput) {
|
||||
return apiRequest<{ address: UserAddress }>('/auth/addresses', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAddress(addressId: string, input: UserAddressInput) {
|
||||
return apiRequest<{ address: UserAddress }>(`/auth/addresses/${addressId}`, {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeAddress(addressId: string) {
|
||||
return apiRequest<{ message: string }>(`/auth/addresses/${addressId}`, {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiRequest, setTokens, clearTokens } from '../lib/api'
|
||||
import { clearActiveBusiness } from '../lib/businessContext'
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginResponse,
|
||||
MeResponse,
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
UserProfile,
|
||||
} from '../types/auth'
|
||||
|
||||
export async function login(cellNumber: string, password: string) {
|
||||
const data = await apiRequest<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { cellNumber, password },
|
||||
})
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function register(input: {
|
||||
cellNumber: string
|
||||
password: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
domain: string
|
||||
}) {
|
||||
const data = await apiRequest<RegisterResponse>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
|
||||
setTokens(data.accessToken, data.refreshToken)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser(signal?: AbortSignal) {
|
||||
return apiRequest<MeResponse>('/auth/me', { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
payload: Partial<UserProfile> & {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
email?: string
|
||||
},
|
||||
) {
|
||||
return apiRequest<{ message: string; user: AuthUser }>('/auth/profile', {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string) {
|
||||
return apiRequest<{ message: string }>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendOtp(cellNumber: string) {
|
||||
return apiRequest<OtpSendResponse>('/auth/send-otp', {
|
||||
method: 'POST',
|
||||
body: { cellNumber },
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyOtp(cellNumber: string, code: string) {
|
||||
return apiRequest<OtpVerifyResponse>('/auth/verify-otp', {
|
||||
method: 'POST',
|
||||
body: { cellNumber, code },
|
||||
})
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens()
|
||||
clearActiveBusiness()
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type { Order } from './orderService'
|
||||
|
||||
export interface CartItemSelection {
|
||||
variationId: string
|
||||
variationName: string
|
||||
optionId: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
id: string
|
||||
storeItemId: string
|
||||
storeItemVariantId: string
|
||||
productId: string
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
sku: string
|
||||
selections: CartItemSelection[]
|
||||
label: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
compareAtPrice: number
|
||||
lineTotal: number
|
||||
stockQuantity: number | null
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
id: string
|
||||
businessId: string
|
||||
items: CartItem[]
|
||||
itemCount: number
|
||||
subtotal: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ShippingAddressInput {
|
||||
province: string
|
||||
city: string
|
||||
address: string
|
||||
postalCode: string
|
||||
landline?: string
|
||||
}
|
||||
|
||||
export type CheckoutPaymentType = 'e_payment_gate' | 'transfer'
|
||||
|
||||
export type OnlineGatewayType = 'mellat_behpardakht' | 'saman_kish'
|
||||
|
||||
export interface CheckoutPaymentInput {
|
||||
type: CheckoutPaymentType
|
||||
gatewayType?: string
|
||||
transferAccount?: string
|
||||
transferRefNumber?: string
|
||||
}
|
||||
|
||||
export interface CheckoutInput {
|
||||
addressId?: string
|
||||
shippingAddress?: ShippingAddressInput
|
||||
customerNotes?: string
|
||||
payment: CheckoutPaymentInput
|
||||
}
|
||||
|
||||
function cartPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/cart${suffix}`
|
||||
}
|
||||
|
||||
export async function getCart(signal?: AbortSignal) {
|
||||
return apiRequest<{ cart: Cart }>(cartPath(), { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function addCartItem(storeItemVariantId: string, quantity = 1) {
|
||||
return apiRequest<{ message: string; cart: Cart }>(cartPath('/items'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: { storeItemVariantId, quantity },
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateCartItem(itemId: string, quantity: number) {
|
||||
return apiRequest<{ message: string; cart: Cart }>(cartPath(`/items/${itemId}`), {
|
||||
method: 'PATCH',
|
||||
auth: true,
|
||||
body: { quantity },
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeCartItem(itemId: string) {
|
||||
return apiRequest<{ message: string; cart: Cart }>(cartPath(`/items/${itemId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkoutCart(input: CheckoutInput) {
|
||||
return apiRequest<{ message: string; order: Order }>(cartPath('/checkout'), {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import type { CityOption } from '@meshkee/dashboard-ui'
|
||||
|
||||
export type { CityOption }
|
||||
|
||||
export async function listIranProvinces(signal?: AbortSignal) {
|
||||
const data = await apiRequest<{ items: CityOption[] }>(
|
||||
'/cities?level=province&parentSlug=iran',
|
||||
{ signal },
|
||||
)
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
|
||||
const data = await apiRequest<{ items: CityOption[] }>(
|
||||
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
|
||||
{ signal },
|
||||
)
|
||||
return data.items
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
|
||||
export interface FavoriteListing {
|
||||
favoriteId: string
|
||||
productId: string
|
||||
createdAt: string
|
||||
productTitle: string
|
||||
productNameFa: string
|
||||
productImage: string | null
|
||||
productTotalStock: number
|
||||
variantCount: number
|
||||
displayPrice: number | null
|
||||
displayDiscountedPrice: number | null
|
||||
showFestival: boolean
|
||||
}
|
||||
|
||||
export interface FavoritesListResponse {
|
||||
items: FavoriteListing[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ListFavoritesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/favorites${suffix}`
|
||||
}
|
||||
|
||||
export async function listFavorites(params: ListFavoritesParams = {}, signal?: AbortSignal) {
|
||||
const q = new URLSearchParams()
|
||||
if (params.page !== undefined) q.set('page', String(params.page))
|
||||
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
|
||||
|
||||
const query = q.toString()
|
||||
const path = `${businessPath()}${query ? `?${query}` : ''}`
|
||||
return apiRequest<FavoritesListResponse>(path, { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function removeFavorite(productId: string) {
|
||||
return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
|
||||
export type OrderStatus =
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'processing'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'cancelled'
|
||||
|
||||
export type OrderSource = 'website' | 'admin' | 'app'
|
||||
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
storeItemVariantId: string | null
|
||||
productId: string
|
||||
productTitle: string
|
||||
productImage: string | null
|
||||
variantSku: string | null
|
||||
unitPrice: number
|
||||
compareAtPrice: number | null
|
||||
quantity: number
|
||||
lineTotal: number
|
||||
selections: {
|
||||
variationId: string
|
||||
variationName: string
|
||||
optionId: string
|
||||
value: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface OrderCustomer {
|
||||
id: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
cellNumber: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
businessId: string
|
||||
orderNumber: string
|
||||
status: OrderStatus
|
||||
processStepId: string
|
||||
processStepLabel?: string | null
|
||||
processStepColor?: string | null
|
||||
source: OrderSource
|
||||
subtotal: number
|
||||
shippingTotal: number
|
||||
discountTotal: number
|
||||
total: number
|
||||
shippingAddress: Record<string, unknown>
|
||||
addressId: string | null
|
||||
customerNotes: string | null
|
||||
adminNotes: string | null
|
||||
createdBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
customer: OrderCustomer
|
||||
items: OrderItem[]
|
||||
}
|
||||
|
||||
export interface OrdersListResponse {
|
||||
items: Order[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ListOrdersParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: OrderStatus
|
||||
orderNumber?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
function businessPath(suffix = '') {
|
||||
const businessId = getActiveBusinessId()
|
||||
if (!businessId) {
|
||||
throw new Error('No active business selected. Please sign in again.')
|
||||
}
|
||||
return `/businesses/${businessId}/orders${suffix}`
|
||||
}
|
||||
|
||||
export async function listOrders(params: ListOrdersParams = {}, signal?: AbortSignal) {
|
||||
const q = new URLSearchParams()
|
||||
if (params.page !== undefined) q.set('page', String(params.page))
|
||||
if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
|
||||
if (params.status) q.set('status', params.status)
|
||||
if (params.orderNumber) q.set('orderNumber', params.orderNumber)
|
||||
if (params.dateFrom) q.set('dateFrom', params.dateFrom)
|
||||
if (params.dateTo) q.set('dateTo', params.dateTo)
|
||||
|
||||
const query = q.toString()
|
||||
const path = `${businessPath()}${query ? `?${query}` : ''}`
|
||||
return apiRequest<OrdersListResponse>(path, { auth: true, signal })
|
||||
}
|
||||
|
||||
export async function getOrder(orderId: string, signal?: AbortSignal) {
|
||||
return apiRequest<{ order: Order }>(businessPath(`/${orderId}`), { auth: true, signal })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
clearGuestCart,
|
||||
loadGuestCart,
|
||||
type GuestCartItem,
|
||||
} from '../lib/guestCart'
|
||||
import { addCartItem, getCart, type Cart } from './cartService'
|
||||
|
||||
/**
|
||||
* Push guest-cart lines into the authenticated server cart.
|
||||
* Guest item `id` is the storeItemVariantId from the storefront.
|
||||
*/
|
||||
export async function syncGuestCartToServer(signal?: AbortSignal): Promise<Cart> {
|
||||
const guestItems = loadGuestCart()
|
||||
if (guestItems.length === 0) {
|
||||
const data = await getCart(signal)
|
||||
return data.cart
|
||||
}
|
||||
|
||||
let cart: Cart | null = null
|
||||
|
||||
for (const item of guestItems) {
|
||||
if (signal?.aborted) break
|
||||
const variantId = resolveVariantId(item)
|
||||
if (!variantId) continue
|
||||
const quantity = Math.max(1, Math.floor(item.quantity) || 1)
|
||||
const result = await addCartItem(variantId, quantity)
|
||||
cart = result.cart
|
||||
}
|
||||
|
||||
if (!cart) {
|
||||
const data = await getCart(signal)
|
||||
cart = data.cart
|
||||
}
|
||||
|
||||
if (cart.items.length > 0) {
|
||||
clearGuestCart()
|
||||
}
|
||||
|
||||
return cart
|
||||
}
|
||||
|
||||
function resolveVariantId(item: GuestCartItem): string | null {
|
||||
const id = item.id?.trim()
|
||||
if (!id) return null
|
||||
// Storefront guest cart uses variant id as item id
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
|
||||
|
||||
export interface ResolvedTenant {
|
||||
id: string
|
||||
name: string
|
||||
nameFa: string | null
|
||||
slug: string
|
||||
domain: string
|
||||
primaryColor: BusinessPrimaryColorId
|
||||
logoUrl?: string | null
|
||||
faviconUrl?: string | null
|
||||
}
|
||||
|
||||
export async function resolveTenantByDomain(host: string, signal?: AbortSignal) {
|
||||
const encodedHost = encodeURIComponent(host)
|
||||
return apiRequest<ResolvedTenant>(`/tenants/${encodedHost}`, { signal })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
import { getTenantDomain } from '../lib/config'
|
||||
|
||||
export interface WebsiteBusinessInfo {
|
||||
id: string
|
||||
name: string
|
||||
nameFa: string
|
||||
logoUrl: string | null
|
||||
faviconUrl: string | null
|
||||
}
|
||||
|
||||
export async function getWebsiteBusinessInfo(host: string, signal?: AbortSignal) {
|
||||
const encodedHost = encodeURIComponent(host)
|
||||
return apiRequest<WebsiteBusinessInfo>(`/tenants/${encodedHost}/website/business-info`, {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/** Public storefront URL for the tenant (e.g. https://sanihome.ir). */
|
||||
export function getWebsiteUrl(domain = getTenantDomain()) {
|
||||
return `${window.location.protocol}//${domain}`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
AuthUser,
|
||||
DashboardType,
|
||||
LoginResponse,
|
||||
MeResponse,
|
||||
OtpSendResponse,
|
||||
OtpVerifyResponse,
|
||||
RegisterResponse,
|
||||
UserProfile,
|
||||
} from '@meshkee/dashboard-core'
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
getBusinessPrimaryColorTokens,
|
||||
type BusinessPrimaryColorId,
|
||||
} from './businessPrimaryColors'
|
||||
|
||||
const CSS_VAR_DEFAULTS: Record<string, string> = {
|
||||
'--primary': '#3b82f6',
|
||||
'--primary-glow': '#3b82f6',
|
||||
'--primary-light': '#dbeafe',
|
||||
'--primary-dark': '#2563eb',
|
||||
'--primary-rgb': '59 130 246',
|
||||
'--primary-dark-rgb': '37 99 235',
|
||||
}
|
||||
|
||||
export function applyBusinessPrimaryColor(colorId?: BusinessPrimaryColorId | null) {
|
||||
const root = document.documentElement
|
||||
const tokens = getBusinessPrimaryColorTokens(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
|
||||
|
||||
root.style.setProperty('--primary', tokens.primary)
|
||||
root.style.setProperty('--primary-glow', tokens.primaryGlow)
|
||||
root.style.setProperty('--primary-light', tokens.primaryLight)
|
||||
root.style.setProperty('--primary-dark', tokens.primaryDark)
|
||||
root.style.setProperty('--primary-rgb', tokens.primaryRgb)
|
||||
root.style.setProperty('--primary-dark-rgb', tokens.primaryDarkRgb)
|
||||
}
|
||||
|
||||
export function resetBusinessPrimaryColor() {
|
||||
const root = document.documentElement
|
||||
|
||||
for (const [name, value] of Object.entries(CSS_VAR_DEFAULTS)) {
|
||||
root.style.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
export const BUSINESS_PRIMARY_COLOR_IDS = [
|
||||
'red',
|
||||
'yellow',
|
||||
'black',
|
||||
'cyan',
|
||||
'purple',
|
||||
'light-blue',
|
||||
'dark-blue',
|
||||
] as const
|
||||
|
||||
export type BusinessPrimaryColorId = (typeof BUSINESS_PRIMARY_COLOR_IDS)[number]
|
||||
|
||||
export const DEFAULT_BUSINESS_PRIMARY_COLOR_ID: BusinessPrimaryColorId = 'dark-blue'
|
||||
|
||||
export type BusinessPrimaryColorTokens = {
|
||||
label: string
|
||||
primary: string
|
||||
primaryDark: string
|
||||
primaryLight: string
|
||||
primaryGlow: string
|
||||
primaryRgb: string
|
||||
primaryDarkRgb: string
|
||||
}
|
||||
|
||||
export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
|
||||
BusinessPrimaryColorId,
|
||||
BusinessPrimaryColorTokens
|
||||
> = {
|
||||
red: {
|
||||
label: 'Red',
|
||||
primary: '#ef4444',
|
||||
primaryDark: '#dc2626',
|
||||
primaryLight: '#fee2e2',
|
||||
primaryGlow: '#ef4444',
|
||||
primaryRgb: '239 68 68',
|
||||
primaryDarkRgb: '220 38 38',
|
||||
},
|
||||
yellow: {
|
||||
label: 'Yellow',
|
||||
primary: '#eab308',
|
||||
primaryDark: '#ca8a04',
|
||||
primaryLight: '#fef9c3',
|
||||
primaryGlow: '#eab308',
|
||||
primaryRgb: '234 179 8',
|
||||
primaryDarkRgb: '202 138 4',
|
||||
},
|
||||
black: {
|
||||
label: 'Black',
|
||||
primary: '#1e293b',
|
||||
primaryDark: '#0f172a',
|
||||
primaryLight: '#e2e8f0',
|
||||
primaryGlow: '#334155',
|
||||
primaryRgb: '30 41 59',
|
||||
primaryDarkRgb: '15 23 42',
|
||||
},
|
||||
cyan: {
|
||||
label: 'Cyan',
|
||||
primary: '#06b6d4',
|
||||
primaryDark: '#0891b2',
|
||||
primaryLight: '#cffafe',
|
||||
primaryGlow: '#06b6d4',
|
||||
primaryRgb: '6 182 212',
|
||||
primaryDarkRgb: '8 145 178',
|
||||
},
|
||||
purple: {
|
||||
label: 'Purple',
|
||||
primary: '#a855f7',
|
||||
primaryDark: '#9333ea',
|
||||
primaryLight: '#f3e8ff',
|
||||
primaryGlow: '#a855f7',
|
||||
primaryRgb: '168 85 247',
|
||||
primaryDarkRgb: '147 51 234',
|
||||
},
|
||||
'light-blue': {
|
||||
label: 'Light Blue',
|
||||
primary: '#38bdf8',
|
||||
primaryDark: '#0ea5e9',
|
||||
primaryLight: '#e0f2fe',
|
||||
primaryGlow: '#38bdf8',
|
||||
primaryRgb: '56 189 248',
|
||||
primaryDarkRgb: '14 165 233',
|
||||
},
|
||||
'dark-blue': {
|
||||
label: 'Dark Blue',
|
||||
primary: '#3b82f6',
|
||||
primaryDark: '#2563eb',
|
||||
primaryLight: '#dbeafe',
|
||||
primaryGlow: '#3b82f6',
|
||||
primaryRgb: '59 130 246',
|
||||
primaryDarkRgb: '37 99 235',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeBusinessPrimaryColorId(value: unknown): BusinessPrimaryColorId {
|
||||
if (
|
||||
typeof value === 'string' &&
|
||||
BUSINESS_PRIMARY_COLOR_IDS.includes(value as BusinessPrimaryColorId)
|
||||
) {
|
||||
return value as BusinessPrimaryColorId
|
||||
}
|
||||
|
||||
return DEFAULT_BUSINESS_PRIMARY_COLOR_ID
|
||||
}
|
||||
|
||||
export function getBusinessPrimaryColorTokens(
|
||||
colorId: BusinessPrimaryColorId | undefined | null,
|
||||
): BusinessPrimaryColorTokens {
|
||||
return BUSINESS_PRIMARY_COLOR_PALETTE[
|
||||
normalizeBusinessPrimaryColorId(colorId ?? DEFAULT_BUSINESS_PRIMARY_COLOR_ID)
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
calcDiscountPercent,
|
||||
formatIrtInput,
|
||||
formatIrtPrice,
|
||||
hasStoreItemDiscount,
|
||||
parseIrtInput,
|
||||
} from '@meshkee/dashboard-core'
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface OrderProcessStep {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||
{ id: 'processing', label: 'Under processing', color: '#3B82F6' },
|
||||
{ id: 'ready-for-shipping', label: 'Ready for shipping', color: '#F59E0B' },
|
||||
{ id: 'shipped', label: 'Shipped', color: '#8B5CF6' },
|
||||
{ id: 'delivered', label: 'Delivered', color: '#22C55E' },
|
||||
]
|
||||
|
||||
export function stepLabel(
|
||||
steps: OrderProcessStep[],
|
||||
processStepId: string,
|
||||
processStepLabel?: string | null,
|
||||
) {
|
||||
if (processStepLabel?.trim()) return processStepLabel.trim()
|
||||
return steps.find((step) => step.id === processStepId)?.label ?? processStepId
|
||||
}
|
||||
|
||||
export function stepColor(
|
||||
steps: OrderProcessStep[],
|
||||
processStepId: string,
|
||||
processStepColor?: string | null,
|
||||
) {
|
||||
if (processStepColor?.trim()) return processStepColor.trim()
|
||||
|
||||
const index = steps.findIndex((step) => step.id === processStepId)
|
||||
const step = index >= 0 ? steps[index] : steps[0]
|
||||
|
||||
if (step?.color) return step.color
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const themed = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--primary')
|
||||
.trim()
|
||||
if (themed) return themed
|
||||
}
|
||||
|
||||
return '#3b82f6'
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const STEP_COLOR_PRESETS = [
|
||||
'#EF4444',
|
||||
'#F97316',
|
||||
'#F59E0B',
|
||||
'#EAB308',
|
||||
'#84CC16',
|
||||
'#22C55E',
|
||||
'#10B981',
|
||||
'#14B8A6',
|
||||
'#06B6D4',
|
||||
'#0EA5E9',
|
||||
'#3B82F6',
|
||||
'#6366F1',
|
||||
'#8B5CF6',
|
||||
'#A855F7',
|
||||
'#D946EF',
|
||||
'#EC4899',
|
||||
'#F43F5E',
|
||||
'#78716C',
|
||||
'#6B7280',
|
||||
'#64748B',
|
||||
'#111827',
|
||||
'#92400E',
|
||||
'#1E3A5F',
|
||||
'#D4AF37',
|
||||
] as const
|
||||
|
||||
function hexToRgb(hex: string) {
|
||||
const normalized = hex.replace('#', '')
|
||||
const value =
|
||||
normalized.length === 3
|
||||
? normalized
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
: normalized
|
||||
|
||||
const int = Number.parseInt(value, 16)
|
||||
return {
|
||||
r: (int >> 16) & 255,
|
||||
g: (int >> 8) & 255,
|
||||
b: int & 255,
|
||||
}
|
||||
}
|
||||
|
||||
export function stepBadgeStyle(color: string) {
|
||||
const { r, g, b } = hexToRgb(color)
|
||||
return {
|
||||
color,
|
||||
background: `rgba(${r}, ${g}, ${b}, 0.14)`,
|
||||
border: `1px solid rgba(${r}, ${g}, ${b}, 0.32)`,
|
||||
} as const
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function formatVariantCount(count: number): string {
|
||||
return count === 1 ? '1 variant' : `${count} variants`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user