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:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+7
View File
@@ -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
+25
View File
@@ -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?
+22
View File
@@ -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>
+1468
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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

+71
View File
@@ -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;
}
}
+135
View File
@@ -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);
}
+100
View File
@@ -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;
}
}
+108
View File
@@ -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);
}
+18
View File
@@ -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>
)
}
+141
View File
@@ -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
}
+40
View File
@@ -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;
}
+23
View File
@@ -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;
}
+6
View File
@@ -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;
}
+20
View File
@@ -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'
+22
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
export { toE164CellNumber, formatCellForDisplay } from '@meshkee/dashboard-core'
+33
View File
@@ -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()
}
+144
View File
@@ -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
}
+19
View File
@@ -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'] },
]
+12
View File
@@ -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);
}
+259
View File
@@ -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);
}
}
+138
View File
@@ -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>
)
}
+75
View File
@@ -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;
}
}
+720
View File
@@ -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&apos;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;
}
+201
View File
@@ -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 &amp; 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;
}
}
+213
View File
@@ -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,
})
}
+83
View File
@@ -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()
}
+106
View File
@@ -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,
})
}
+105
View File
@@ -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}`
}
+10
View File
@@ -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)
]
}
+7
View File
@@ -0,0 +1,7 @@
export {
calcDiscountPercent,
formatIrtInput,
formatIrtPrice,
hasStoreItemDiscount,
parseIrtInput,
} from '@meshkee/dashboard-core'
+43
View File
@@ -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'
}
+53
View File
@@ -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`
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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