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
+11
View File
@@ -0,0 +1,11 @@
node_modules/
dist/
.env
.env.local
*.local
.DS_Store
.certs/
*.log
.idea/
.vscode/
.cursor/
+67
View File
@@ -0,0 +1,67 @@
# MeshkeeApp Monorepo
Dashboard applications and shared packages for the Meshkee platform.
**Agent context:** see [docs/PROJECT_CONTEXT.md](./docs/PROJECT_CONTEXT.md) and `.cursor/rules/`.
## Structure
```
MeshkeeApp/
apps/
super-admin/ # port 5174 — meshkee.app
business/ # port 5173 — business.{domain}
customer/ # port 5175 — customer.{domain}
packages/
dashboard-core/ # @meshkee/dashboard-core
dashboard-ui/ # @meshkee/dashboard-ui
```
## Setup
```bash
cd MeshkeeApp
npm install
```
## Dev servers
From the monorepo root:
```bash
npm run dev:customer # http://customer.sanihome.ir:5175
npm run dev:business # http://business.sanihome.ir:5173
npm run dev:super-admin # https://meshkee.app:5174
```
Or from an app directory:
```bash
cd apps/customer && npm run dev
```
## Build
```bash
npm run build # packages + all apps
npm run build:customer
npm run build:business
npm run build:super-admin
```
## Shared packages
See [packages/README.md](./packages/README.md).
- **dashboard-core** — API client, auth types, utils, design tokens
- **dashboard-ui** — Breadcrumbs, SectionCard, Pagination, Toast, RouteLoader, DomainGuard
Customer dashboard uses both packages. Business and Super Admin will migrate incrementally.
## Local hosts
```
127.0.0.1 customer.sanihome.ir
127.0.0.1 business.sanihome.ir
127.0.0.1 meshkee.app
```
+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 business.{domain} in /etc/hosts.
# Leave unset for multi-tenant dev — tenant is resolved from hostname
# (e.g. business.sanihome.ir → sanihome.ir, business.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?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+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&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
rel="stylesheet"
/>
<title>Business Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1453
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@meshkee/business-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-ui": "*",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.2.0",
"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

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+118
View File
@@ -0,0 +1,118 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { BusinessDomainGuard } from './components/BusinessDomainGuard'
import { DashboardDocumentTitle } from './components/DashboardDocumentTitle'
import { AuthProvider } from './context/AuthContext'
import { BusinessThemeProvider } from './context/BusinessThemeContext'
import { TenantBrandingProvider } from './context/TenantBrandingContext'
import { ToastProvider } from './context/ToastContext'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { HomePage } from './pages/HomePage'
import { ProductsPage } from './pages/ProductsPage'
import { CategoriesPage } from './pages/CategoriesPage'
import { BrandsPage } from './pages/BrandsPage'
import { AddNewProductPage } from './pages/AddNewProductPage'
import { MyProductsPage } from './pages/MyProductsPage'
import { ProductDetailsPage } from './pages/ProductDetailsPage'
import { ProductSettingsPage } from './pages/ProductSettingsPage'
import { BusinessProfilePage } from './pages/BusinessProfilePage'
import { StorePage } from './pages/StorePage'
import { StoreItemsPage } from './pages/StoreItemsPage'
import { StoreSpecialsPage } from './pages/StoreSpecialsPage'
import { CustomersPage } from './pages/CustomersPage'
import { OrdersPage } from './pages/OrdersPage'
import { LoginPage } from './pages/LoginPage'
import { ShoppingCardsPage } from './pages/ShoppingCardsPage'
import { StoreSettingsPage } from './pages/StoreSettingsPage'
import { BlogPage } from './pages/BlogPage'
import { BlogListPage } from './pages/BlogListPage'
import { AddNewBlogPage } from './pages/AddNewBlogPage'
import { BlogCategoriesPage } from './pages/BlogCategoriesPage'
import { BlogSettingsPage } from './pages/BlogSettingsPage'
import { BlogDetailsPage } from './pages/BlogDetailsPage'
import { PortfoliosPage } from './pages/PortfoliosPage'
import { PortfolioListPage } from './pages/PortfolioListPage'
import { AddNewPortfolioPage } from './pages/AddNewPortfolioPage'
import { PortfolioCategoriesPage } from './pages/PortfolioCategoriesPage'
import { PortfolioSettingsPage } from './pages/PortfolioSettingsPage'
import { PortfolioDetailsPage } from './pages/PortfolioDetailsPage'
import { WebsitePage } from './pages/WebsitePage'
import { WebsiteContactPage } from './pages/WebsiteContactPage'
import { WebsiteSubscriptionsPage } from './pages/WebsiteSubscriptionsPage'
import { WebsiteFaqPage } from './pages/WebsiteFaqPage'
import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage'
import { WebsiteEPaymentPage } from './pages/WebsiteEPaymentPage'
import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage'
import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage'
import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
function App() {
return (
<BusinessDomainGuard>
<BrowserRouter>
<TenantBrandingProvider>
<DashboardDocumentTitle />
<AuthProvider>
<BusinessThemeProvider>
<ToastProvider>
<Routes>
<Route element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="business-profile" element={<BusinessProfilePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/categories" element={<CategoriesPage />} />
<Route path="products/brands" element={<BrandsPage />} />
<Route path="products/new" element={<AddNewProductPage />} />
<Route path="products/edit/:id" element={<AddNewProductPage />} />
<Route path="products/list" element={<MyProductsPage />} />
<Route path="products/detail/:id" element={<ProductDetailsPage />} />
<Route path="products/settings" element={<ProductSettingsPage />} />
<Route path="store" element={<StorePage />} />
<Route path="store/items" element={<StoreItemsPage />} />
<Route path="store/orders" element={<OrdersPage />} />
<Route path="store/cards" element={<ShoppingCardsPage />} />
<Route path="store/settings" element={<StoreSettingsPage />} />
<Route path="customers" element={<CustomersPage />} />
<Route path="blog" element={<BlogPage />} />
<Route path="blog/list" element={<BlogListPage />} />
<Route path="blog/detail/:id" element={<BlogDetailsPage />} />
<Route path="blog/new" element={<AddNewBlogPage />} />
<Route path="blog/edit/:id" element={<AddNewBlogPage />} />
<Route path="blog/categories" element={<BlogCategoriesPage />} />
<Route path="blog/settings" element={<BlogSettingsPage />} />
<Route path="portfolios" element={<PortfoliosPage />} />
<Route path="portfolios/list" element={<PortfolioListPage />} />
<Route path="portfolios/detail/:id" element={<PortfolioDetailsPage />} />
<Route path="portfolios/new" element={<AddNewPortfolioPage />} />
<Route path="portfolios/edit/:id" element={<AddNewPortfolioPage />} />
<Route path="portfolios/categories" element={<PortfolioCategoriesPage />} />
<Route path="portfolios/settings" element={<PortfolioSettingsPage />} />
<Route path="website" element={<WebsitePage />} />
<Route path="website/sliders" element={<WebsiteSlidersPage />} />
<Route path="website/special-categories" element={<WebsiteSpecialCategoriesPage />} />
<Route path="website/special-brands" element={<WebsiteSpecialBrandsPage />} />
<Route path="website/special-items" element={<StoreSpecialsPage />} />
<Route path="website/contact" element={<WebsiteContactPage />} />
<Route path="website/subscriptions" element={<WebsiteSubscriptionsPage />} />
<Route path="website/faq" element={<WebsiteFaqPage />} />
<Route path="website/badges" element={<WebsiteBadgesPage />} />
<Route path="website/e-payment" element={<WebsiteEPaymentPage />} />
</Route>
</Route>
</Routes>
</ToastProvider>
</BusinessThemeProvider>
</AuthProvider>
</TenantBrandingProvider>
</BrowserRouter>
</BusinessDomainGuard>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,22 @@
.formGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.formFull {
grid-column: 1 / -1;
}
.hint {
margin-bottom: 12px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
@media (max-width: 520px) {
.formGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,209 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import {
createCustomer,
type BusinessCustomerListItem,
} from '../services/customerService'
import formStyles from './AddCustomerModal.module.css'
import modalStyles from './VariationsModal.module.css'
interface AddCustomerModalProps {
open: boolean
onClose: () => void
onCreated: (customer: BusinessCustomerListItem) => void
}
const ANIMATION_MS = 220
export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [cellNumber, setCellNumber] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setFirstName('')
setLastName('')
setCellNumber('')
setPassword('')
setEmail('')
setError('')
} 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) return null
const canSubmit =
firstName.trim().length >= 2 &&
lastName.trim().length >= 2 &&
toE164CellNumber(cellNumber.trim()).length > 0
async function handleSubmit() {
const normalizedCell = toE164CellNumber(cellNumber.trim())
if (!canSubmit || !normalizedCell) return
setIsSubmitting(true)
setError('')
try {
const created = await createCustomer({
cellNumber: normalizedCell,
firstName: firstName.trim(),
lastName: lastName.trim(),
...(password.trim() ? { password: password.trim() } : {}),
...(email.trim() ? { email: email.trim() } : {}),
})
onCreated({
id: created.id,
cellNumber: created.cellNumber,
firstName: created.firstName,
lastName: created.lastName,
email: created.email,
label: created.label,
createdAt: created.createdAt,
isEnabled: created.isEnabled,
})
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to add customer.')
} finally {
setIsSubmitting(false)
}
}
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-customer-title"
>
<div className={modalStyles.header}>
<div>
<h3 id="add-customer-title" className={modalStyles.title}>
Add customer
</h3>
<p className={modalStyles.subtitle}>
Creates a verified customer account or links an existing user to your business.
</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={18} />
</button>
</div>
<div className={modalStyles.body}>
<p className={formStyles.hint}>
Password is required only for new accounts. Existing users are added as verified
customers.
</p>
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={formStyles.formGrid}>
<div className={modalStyles.field}>
<label htmlFor="add-customer-first-name">First name</label>
<input
id="add-customer-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="add-customer-last-name">Last name</label>
<input
id="add-customer-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="add-customer-cell">Cell number</label>
<input
id="add-customer-cell"
value={cellNumber}
onChange={(e) => setCellNumber(e.target.value)}
placeholder="0912..."
autoComplete="off"
disabled={isSubmitting}
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="add-customer-password">Password (new users)</label>
<input
id="add-customer-password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
disabled={isSubmitting}
/>
</div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="add-customer-email">Email (optional)</label>
<input
id="add-customer-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSubmit}
>
Add customer
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,25 @@
.headerBlock {
flex: 1;
min-width: 0;
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.45;
}
.error {
margin: 0;
padding: 10px 12px;
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);
}
.header {
align-items: flex-start;
}
@@ -0,0 +1,234 @@
import { useEffect, useMemo, useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { createProductByAi, type ProductAiLanguage } from '../services/productAiService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddProductByAiModal.module.css'
import fieldStyles from '../pages/AddNewProductPage.module.css'
interface AddProductByAiModalProps {
open: boolean
onClose: () => void
onCreated: (productId: string) => void
}
const ANIMATION_MS = 220
export function AddProductByAiModal({
open,
onClose,
onCreated,
}: AddProductByAiModalProps) {
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [name, setName] = useState('')
const [language, setLanguage] = useState<ProductAiLanguage>('en')
const [isLoadingCategories, setIsLoadingCategories] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = useMemo(() => flattenCategories(categories), [categories])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCategoryId('')
setName('')
setLanguage('en')
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [open])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, isSubmitting, onClose])
async function loadCategories(signal?: AbortSignal) {
setIsLoadingCategories(true)
setError('')
try {
const items = await listProductCategories(signal)
setCategories(items.map(mapProductCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load categories.')
}
} finally {
setIsLoadingCategories(false)
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!categoryId || !name.trim()) return
setIsSubmitting(true)
setError('')
try {
const result = await createProductByAi({
categoryId,
name: name.trim(),
language,
})
showToast(result.message, 'success')
onCreated(result.product.id)
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to create product with AI.')
}
} finally {
setIsSubmitting(false)
}
}
if (!mounted) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isSubmitting && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="add-product-ai-title"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="add-product-ai-title" className={modalStyles.title}>
Add Product by AI
</h2>
<p className={styles.subtitle}>
AI will generate product content and technical data. You can add images and
variations afterward.
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isSubmitting}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="ai-product-name">Product name</label>
<input
id="ai-product-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
language === 'fa' ? 'نام محصول را وارد کنید' : 'Enter product name'
}
dir={language === 'fa' ? 'rtl' : 'ltr'}
className={language === 'fa' ? 'faText' : undefined}
disabled={isSubmitting}
required
/>
</div>
<div className={`${modalStyles.field} ${fieldStyles.field}`}>
<label htmlFor="ai-product-language">Language</label>
<select
id="ai-product-language"
value={language}
onChange={(e) => setLanguage(e.target.value as ProductAiLanguage)}
disabled={isSubmitting}
>
<option value="en">English</option>
<option value="fa">Persian (Farsi)</option>
</select>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={
isSubmitting || isLoadingCategories || !categoryId || !name.trim()
}
>
{isSubmitting ? (
<>
<Sparkles size={16} />
Creating...
</>
) : (
<>
<Sparkles size={16} />
Create product
</>
)}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState } from 'react'
import { Plus, X } from 'lucide-react'
import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation'
import styles from './VariationsModal.module.css'
interface AddVariationModalProps {
open: boolean
onClose: () => void
onSubmit: (variation: Omit<Variation, 'id'>) => void
}
const ANIMATION_MS = 220
export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [type, setType] = useState<VariationType>('color')
const [customName, setCustomName] = useState('')
const [selectedColors, setSelectedColors] = useState<string[]>([])
const [selectedSizes, setSelectedSizes] = useState<string[]>([])
const [customValues, setCustomValues] = useState<string[]>([''])
const valueInputRefs = useRef<(HTMLInputElement | null)[]>([])
const pendingFocusIndex = useRef<number | null>(null)
useEffect(() => {
if (pendingFocusIndex.current === null) return
valueInputRefs.current[pendingFocusIndex.current]?.focus()
pendingFocusIndex.current = null
}, [customValues.length])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setType('color')
setCustomName('')
setSelectedColors([])
setSelectedSizes([])
setCustomValues([''])
} 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) return null
function toggleOption(value: string, selected: string[], setSelected: (v: string[]) => void) {
setSelected(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value],
)
}
function updateCustomValue(index: number, value: string) {
setCustomValues((prev) => prev.map((v, i) => (i === index ? value : v)))
}
function addCustomRow() {
setCustomValues((prev) => [...prev, ''])
}
function removeCustomRow(index: number) {
setCustomValues((prev) => prev.filter((_, i) => i !== index))
}
function handleValueKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Enter') return
e.preventDefault()
const nextIndex = index + 1
if (nextIndex < customValues.length) {
valueInputRefs.current[nextIndex]?.focus()
return
}
pendingFocusIndex.current = nextIndex
setCustomValues((prev) => [...prev, ''])
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (type === 'color') {
onSubmit({ name: 'Color', type: 'color', values: selectedColors })
} else if (type === 'size') {
onSubmit({ name: 'Size', type: 'size', values: selectedSizes })
} else {
const name = customName.trim() || 'Custom'
const values = customValues.map((v) => v.trim()).filter(Boolean)
onSubmit({ name, type: 'custom', values })
}
}
const canSubmit =
type === 'color'
? selectedColors.length > 0
: type === 'size'
? selectedSizes.length > 0
: customValues.some((v) => v.trim())
return (
<div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-variation-title"
>
<div className={styles.header}>
<h3 id="add-variation-title" className={styles.title}>
Add Variation
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form className={styles.body} onSubmit={handleSubmit}>
<div className={styles.field}>
<label htmlFor="variationType">Type</label>
<select
id="variationType"
value={type}
onChange={(e) => setType(e.target.value as VariationType)}
>
<option value="color">Color</option>
<option value="size">Size</option>
<option value="custom">Your own</option>
</select>
</div>
{type === 'color' && (
<div className={styles.field}>
<label>Select colors</label>
<div className={styles.chipGrid}>
{COLOR_OPTIONS.map((color) => (
<button
key={color}
type="button"
className={`${styles.chip} ${selectedColors.includes(color) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(color, selectedColors, setSelectedColors)}
>
{color}
</button>
))}
</div>
</div>
)}
{type === 'size' && (
<div className={styles.field}>
<label>Select sizes</label>
<div className={styles.chipGrid}>
{SIZE_OPTIONS.map((size) => (
<button
key={size}
type="button"
className={`${styles.chip} ${selectedSizes.includes(size) ? styles.chipSelected : ''}`}
onClick={() => toggleOption(size, selectedSizes, setSelectedSizes)}
>
{size}
</button>
))}
</div>
</div>
)}
{type === 'custom' && (
<>
<div className={styles.field}>
<label htmlFor="customName">Variation name</label>
<input
id="customName"
type="text"
placeholder="e.g. Guarantee, Material..."
value={customName}
onChange={(e) => setCustomName(e.target.value)}
/>
</div>
<div className={styles.field}>
<label>Values</label>
<div className={styles.customValues}>
{customValues.map((value, index) => (
<div key={index} className={styles.customRow}>
<input
ref={(el) => {
valueInputRefs.current[index] = el
}}
type="text"
placeholder={`Value ${index + 1}`}
value={value}
onChange={(e) => updateCustomValue(index, e.target.value)}
onKeyDown={(e) => handleValueKeyDown(index, e)}
/>
{customValues.length > 1 && (
<button
type="button"
className={styles.removeValueBtn}
onClick={() => removeCustomRow(index)}
aria-label="Remove value"
>
<X size={16} />
</button>
)}
</div>
))}
<button type="button" className={styles.addValueBtn} onClick={addCustomRow}>
<Plus size={14} />
Add value
</button>
</div>
</div>
</>
)}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Cancel
</button>
<button type="submit" className={styles.submitBtn} disabled={!canSubmit}>
Add
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,3 @@
.modalWide {
max-width: 560px;
}
@@ -0,0 +1,153 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper'
import modalStyles from './CategoryModal.module.css'
import styles from './AddWebsiteSliderSlideModal.module.css'
export interface WebsiteSliderSlideFormData {
image: string | null
title: string
linkUrl: string
}
interface AddWebsiteSliderSlideModalProps {
open: boolean
onClose: () => void
onSubmit: (data: WebsiteSliderSlideFormData) => void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function AddWebsiteSliderSlideModal({
open,
onClose,
onSubmit,
isSubmitting = false,
}: AddWebsiteSliderSlideModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [image, setImage] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [linkUrl, setLinkUrl] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setImage(null)
setTitle('')
setLinkUrl('')
} 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) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!image) return
onSubmit({
image,
title: title.trim(),
linkUrl: linkUrl.trim(),
})
}
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="add-slide-title"
>
<div className={modalStyles.header}>
<h3 id="add-slide-title" className={modalStyles.title}>
Add slide
</h3>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form ref={formRef} className={modalStyles.form} onSubmit={handleSubmit}>
<div className={modalStyles.field}>
<label>Slide image</label>
<ImageCropper
value={image}
onChange={setImage}
aspect={9 / 4}
uploadLabel="Upload slide image"
hint="9:4 banner ratio recommended"
changeLabel="Change image"
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="slide-title">Title (optional)</label>
<input
id="slide-title"
type="text"
placeholder="e.g. Summer sale"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="slide-link">Link URL (optional)</label>
<input
id="slide-link"
type="url"
dir="ltr"
placeholder="https://example.com/promo"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={modalStyles.submitBtn}
disabled={isSubmitting || !image}
>
{isSubmitting ? 'Adding...' : 'Add slide'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,145 @@
.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;
overflow: hidden;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.1);
}
.clickable {
display: flex;
flex-direction: column;
flex: 1;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.clickable:hover .title {
color: var(--primary);
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
background: rgba(148, 163, 184, 0.08);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.imagePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
rgba(148, 163, 184, 0.04) 100%
);
}
.statusBadge {
position: absolute;
top: 8px;
left: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
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;
}
.waitingBadge {
color: #c2410c;
}
.verifiedBadge {
color: #15803d;
}
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px 16px;
}
.title {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text-primary);
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.abstract {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.metaDot {
margin: 0 6px;
}
.verifyActive {
color: #15803d !important;
background: rgba(34, 197, 94, 0.12) !important;
}
.verifyActive:hover {
background: rgba(34, 197, 94, 0.2) !important;
color: #15803d !important;
}
.verifyWaiting {
color: #c2410c !important;
background: rgba(249, 115, 22, 0.12) !important;
}
.verifyWaiting:hover {
background: rgba(249, 115, 22, 0.2) !important;
color: #c2410c !important;
}
+120
View File
@@ -0,0 +1,120 @@
import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2, BadgeCheck, Clock } from 'lucide-react'
import type { Blog } from '../types/blog'
import {
formatBlogAuthor,
formatBlogCardDate,
isBlogVerified,
} from '../services/blogService'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
interface BlogCardProps {
blog: Blog
commentCount: number
onEdit: (id: string) => void
onComments: (id: string) => void
onToggleVerify: (id: string) => void
onRemove: (id: string) => void
isVerifying?: boolean
}
export function BlogCard({
blog,
commentCount,
onEdit,
onComments,
onToggleVerify,
onRemove,
isVerifying = false,
}: BlogCardProps) {
const verified = isBlogVerified(blog)
const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt)
return (
<article className={cardStyles.card}>
<Link to={`/blog/detail/${blog.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{blog.titleImageUrl ? (
<img
src={blog.titleImageUrl}
alt={blog.title}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true" />
)}
{!verified && (
<span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}>
<Clock size={11} aria-hidden="true" />
Waiting
</span>
)}
{verified && (
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
Verified
</span>
)}
</div>
<div className={cardStyles.body}>
<h3 className={cardStyles.title}>{blog.title}</h3>
{blog.abstract ? (
<p className={cardStyles.abstract}>{blog.abstract}</p>
) : (
<p className={cardStyles.abstract}>No summary yet.</p>
)}
<p className={cardStyles.meta}>
<span>{formatBlogAuthor(blog.author)}</span>
<span className={cardStyles.metaDot}>·</span>
<span>{publishDate}</span>
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label="Edit blog">
<button type="button" onClick={() => onEdit(blog.id)} aria-label="Edit">
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={verified ? 'Unverify blog' : 'Verify blog'}>
<button
type="button"
className={verified ? cardStyles.verifyActive : cardStyles.verifyWaiting}
disabled={isVerifying}
onClick={() => onToggleVerify(blog.id)}
aria-label={verified ? 'Unverify blog' : 'Verify blog'}
>
{verified ? <BadgeCheck size={16} /> : <Clock size={16} />}
</button>
</Tooltip>
<Tooltip label="View comments">
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onComments(blog.id)}
aria-label={`Comments (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={controlStyles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label="Remove blog">
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(blog.id)}
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,81 @@
import { FolderPlus, Trash2, ChevronRight } from 'lucide-react'
import type { Category } from '../types/category'
import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css'
interface BlogCategoryRowProps {
category: Category
depth: number
hasChildren: boolean
expanded: boolean
onToggle: () => void
onAddSub: (id: string) => void
onRemove: (id: string) => void
}
export function BlogCategoryRow({
category,
depth,
hasChildren,
expanded,
onToggle,
onAddSub,
onRemove,
}: BlogCategoryRowProps) {
return (
<div className={styles.row} style={{ marginLeft: depth * 28 }}>
<div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
onClick={hasChildren ? onToggle : undefined}
onKeyDown={
hasChildren
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
aria-expanded={hasChildren ? expanded : undefined}
>
<div className={styles.textBlock}>
<div className={styles.names}>
<span className={styles.nameEn}>{category.nameEn}</span>
<span className={styles.separator}>·</span>
<span className={styles.nameFa}>{category.nameFa}</span>
{hasChildren && (
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={18} />
</span>
)}
</div>
{category.description && <p className={styles.description}>{category.description}</p>}
</div>
</div>
<div className={styles.controls}>
<Tooltip label="Add sub category">
<button
className={styles.controlBtn}
onClick={() => onAddSub(category.id)}
aria-label="Add sub category"
>
<FolderPlus size={17} />
</button>
</Tooltip>
<Tooltip label="Remove category">
<button
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)}
aria-label="Remove"
>
<Trash2 size={17} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,64 @@
import type { Category } from '../types/category'
import { BlogCategoryRow } from './BlogCategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface BlogCategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function BlogCategoryTree({
categories,
expandedIds,
onToggle,
onAddSub,
onRemove,
parentId = null,
depth = 0,
}: BlogCategoryTreeProps) {
const nodes = getChildren(categories, parentId)
return (
<>
{nodes.map((category) => {
const childCount = hasChildren(categories, category.id)
const expanded = expandedIds.has(category.id)
return (
<div key={category.id}>
<BlogCategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<BlogCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,262 @@
import { useEffect, useState } from 'react'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listBlogComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './ProductCommentsModal.module.css'
interface BlogCommentsModalProps {
open: boolean
blogId: string
blogTitle: string
onClose: () => void
onCountChange?: (count: number) => void
}
const ANIMATION_MS = 220
export function BlogCommentsModal({
open,
blogId,
blogTitle,
onClose,
onCountChange,
}: BlogCommentsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCurrentPage(1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !blogId) return
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [open, blogId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listBlogComments(blogId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages)
}
}, [currentPage, totalPages])
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) return null
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="blog-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="blog-comments-title" className={styles.title}>
Comments
</h3>
{blogTitle && (
<p className={styles.subtitle}>
{blogTitle} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this blog post.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,157 @@
.section {
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;
}
.header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.count {
font-size: 13px;
color: var(--text-secondary);
}
.emptyText {
font-size: 14px;
color: var(--text-muted);
text-align: center;
padding: 24px 12px;
}
.errorText {
font-size: 13px;
color: #dc2626;
margin-bottom: 12px;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: var(--radius-sm);
}
.commentApproved {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.04);
}
.commentHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.commentMeta {
min-width: 0;
}
.author {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.dateTime {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.likes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
flex-shrink: 0;
}
.text {
font-size: 14px;
line-height: 1.55;
color: var(--text-primary);
white-space: pre-wrap;
}
.commentActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.approveBtn,
.rejectBtn,
.removeBtn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
transition: background 0.2s, color 0.2s;
}
.approveBtn {
color: #15803d;
background: rgba(34, 197, 94, 0.1);
}
.approveBtn:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.18);
}
.rejectBtn {
color: #b45309;
background: rgba(251, 191, 36, 0.12);
}
.rejectBtn:hover:not(:disabled) {
background: rgba(251, 191, 36, 0.2);
}
.removeBtn {
color: #dc2626;
background: rgba(239, 68, 68, 0.08);
}
.removeBtn:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.14);
}
.approveBtn:disabled,
.rejectBtn:disabled,
.removeBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.paginationWrap {
margin-top: 16px;
}
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listBlogComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './BlogCommentsSection.module.css'
interface BlogCommentsSectionProps {
blogId: string
onCountChange?: (count: number) => void
}
export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSectionProps) {
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [blogId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listBlogComments(blogId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<section className={styles.section}>
<div className={styles.header}>
<h3 className={styles.title}>Comments</h3>
<span className={styles.count}>
{totalComments} {totalComments === 1 ? 'comment' : 'comments'}
</span>
</div>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this blog post.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>{formatCommentDate(comment.createdAt)}</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</section>
)
}
@@ -0,0 +1,184 @@
.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;
}
.modal {
width: 100%;
max-width: 520px;
background: rgba(255, 255, 255, 0.85);
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);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
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);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
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);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
box-sizing: border-box;
}
.field input {
min-height: var(--field-height);
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field textarea {
resize: vertical;
min-height: calc(var(--field-height) + 24px);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
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: 8px 18px;
font-size: 13px;
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, box-shadow 0.2s;
}
.submitBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.overlayIn {
animation: overlayFadeIn 0.22s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.22s ease forwards;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
.modalOut {
animation: modalFadeOut 0.22s ease forwards;
}
@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);
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper'
import type { Brand, BrandFormData } from '../types/brand'
import styles from './BrandModal.module.css'
interface BrandModalProps {
open: boolean
onClose: () => void
onSubmit: (data: BrandFormData) => void
editingBrand?: Brand | null
title?: string
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function BrandModal({
open,
onClose,
onSubmit,
editingBrand = null,
title = 'Add Brand',
isSubmitting = false,
}: BrandModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [about, setAbout] = useState('')
const [image, setImage] = useState<string | null>(null)
const [imageMediaId, setImageMediaId] = useState<string | null>(null)
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setNameEn(editingBrand?.nameEn ?? '')
setNameFa(editingBrand?.nameFa ?? '')
setAbout(editingBrand?.about ?? '')
setImage(editingBrand?.imageUrl ?? null)
setImageMediaId(editingBrand?.imageMediaId ?? null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, editingBrand, 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) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit({
nameFa: nameFa.trim(),
nameEn: nameEn.trim(),
about: about.trim(),
image,
imageMediaId,
})
}
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="brand-modal-title"
>
<div className={styles.header}>
<h3 id="brand-modal-title" className={styles.title}>
{title}
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label>Brand logo</label>
<ImageCropper
value={image}
onChange={setImage}
outputFormat="png"
accept="image/png"
uploadLabel="Upload brand logo"
hint="PNG only — transparent background recommended"
changeLabel="Change logo"
/>
</div>
<div className={styles.field}>
<label htmlFor="nameFa">Name (FA)</label>
<input
id="nameFa"
name="nameFa"
type="text"
dir="rtl"
className="faText"
placeholder="نام برند"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="nameEn">Name (EN)</label>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder="Brand name"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="about">About</label>
<textarea
id="about"
name="about"
rows={3}
placeholder="Short description of this brand"
value={about}
onChange={(e) => setAbout(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Brand'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,17 @@
.logo {
width: 40px;
height: 40px;
flex-shrink: 0;
object-fit: contain;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
}
.info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
+56
View File
@@ -0,0 +1,56 @@
import { Pencil, Trash2 } from 'lucide-react'
import type { Brand } from '../types/brand'
import { Tooltip } from './Tooltip'
import rowStyles from './CategoryRow.module.css'
import styles from './BrandRow.module.css'
interface BrandRowProps {
brand: Brand
onEdit: (id: string) => void
onRemove: (id: string) => void
}
export function BrandRow({ brand, onEdit, onRemove }: BrandRowProps) {
return (
<div className={rowStyles.row}>
<div className={styles.info}>
{brand.imageUrl && (
<img src={brand.imageUrl} alt="" className={styles.logo} />
)}
<div className={rowStyles.textBlock}>
<div className={rowStyles.names}>
<span className={rowStyles.nameEn}>{brand.nameEn}</span>
{brand.nameFa && (
<>
<span className={rowStyles.separator}>·</span>
<span className={rowStyles.nameFa}>{brand.nameFa}</span>
</>
)}
</div>
{brand.about && <p className={rowStyles.description}>{brand.about}</p>}
</div>
</div>
<div className={rowStyles.controls}>
<Tooltip label="Edit brand">
<button
className={rowStyles.controlBtn}
onClick={() => onEdit(brand.id)}
aria-label="Edit brand"
>
<Pencil size={17} />
</button>
</Tooltip>
<Tooltip label="Remove brand">
<button
className={`${rowStyles.controlBtn} ${rowStyles.danger}`}
onClick={() => onRemove(brand.id)}
aria-label="Remove brand"
>
<Trash2 size={17} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,45 @@
.breadcrumbs {
margin-bottom: 20px;
}
.list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
list-style: none;
}
.item {
display: flex;
align-items: center;
gap: 4px;
}
.separator {
flex-shrink: 0;
color: var(--text-muted);
}
.link {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
transition: color 0.2s;
}
.link:hover {
color: var(--primary);
}
.text {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.current {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import styles from './Breadcrumbs.module.css'
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbsProps {
items: BreadcrumbItem[]
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav className={styles.breadcrumbs} aria-label="Breadcrumb">
<ol className={styles.list}>
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<li key={`${item.label}-${index}`} className={styles.item}>
{index > 0 && (
<ChevronRight size={14} className={styles.separator} aria-hidden="true" />
)}
{item.href && !isLast ? (
<Link to={item.href} className={styles.link}>
{item.label}
</Link>
) : (
<span className={isLast ? styles.current : styles.text}>{item.label}</span>
)}
</li>
)
})}
</ol>
</nav>
)
}
@@ -0,0 +1,42 @@
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
background: var(--bg-gradient-start);
}
.card {
max-width: 32rem;
padding: 2rem;
border-radius: 1rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.text {
color: var(--text-secondary);
line-height: 1.6;
}
.hint {
margin-top: 1rem;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.6;
}
.hint code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.85em;
background: rgba(0, 0, 0, 0.06);
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
}
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { getBusinessDashboardHostForApp, isAllowedBusinessHost } from '../lib/config'
import styles from './BusinessDomainGuard.module.css'
export function BusinessDomainGuard({ children }: { children: ReactNode }) {
if (isAllowedBusinessHost()) {
return children
}
const expectedHost = getBusinessDashboardHostForApp()
return (
<div className={styles.page}>
<div className={styles.card}>
<h1 className={styles.title}>Wrong domain</h1>
<p className={styles.text}>
This business dashboard is only available at{' '}
<strong>{expectedHost}</strong>.
</p>
<p className={styles.hint}>
Add <code>127.0.0.1 {expectedHost}</code> to your hosts file, then open{' '}
<code>
http://{expectedHost}
{window.location.port ? `:${window.location.port}` : ''}
</code>
.
</p>
</div>
</div>
)
}
@@ -0,0 +1,29 @@
.modal {
max-width: 560px;
}
.headerBlock {
flex: 1;
min-width: 0;
}
.header {
align-items: flex-start;
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
line-height: 1.45;
}
.error {
margin: 0;
padding: 10px 12px;
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);
}
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react'
import { Sparkles, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export const DEFAULT_CATEGORY_AI_PROMPT = `Create a practical product category tree for my store.
Include English names, Farsi names in Persian script, and short English descriptions.
Use 3-5 main categories with relevant subcategories where it helps shoppers browse.`
interface CategoryAiPromptModalProps {
open: boolean
onClose: () => void
onRun: (prompt: string) => Promise<void>
isRunning: boolean
}
const ANIMATION_MS = 220
export function CategoryAiPromptModal({
open,
onClose,
onRun,
isRunning,
}: CategoryAiPromptModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(DEFAULT_CATEGORY_AI_PROMPT)
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(DEFAULT_CATEGORY_AI_PROMPT)
setError('')
} 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' && !isRunning) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, isRunning, onClose])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = prompt.trim()
if (trimmed.length < 10) {
setError('Prompt must be at least 10 characters.')
return
}
setError('')
try {
await onRun(trimmed)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to generate categories with AI.')
}
}
}
if (!mounted) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="category-ai-prompt-title"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="category-ai-prompt-title" className={modalStyles.title}>
Fill Categories with AI
</h2>
<p className={styles.subtitle}>
Edit the prompt below, then run it to generate and save a category tree.
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isRunning}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="category-ai-prompt">Prompt</label>
<textarea
id="category-ai-prompt"
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={8}
disabled={isRunning}
required
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isRunning}
>
Cancel
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={isRunning || prompt.trim().length < 10}
>
{isRunning ? (
<>
<Sparkles size={16} />
Running...
</>
) : (
<>
<Sparkles size={16} />
Run
</>
)}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,189 @@
.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;
}
.modal {
width: 100%;
max-width: 480px;
background: rgba(255, 255, 255, 0.85);
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);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
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);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field textarea {
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
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);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
box-sizing: border-box;
}
.field input {
min-height: var(--field-height);
}
.field select {
padding-right: var(--select-padding-end);
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.field textarea {
resize: vertical;
min-height: calc(var(--field-height) + 24px);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
.cancelBtn {
padding: 8px 16px;
font-size: 13px;
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: 8px 18px;
font-size: 13px;
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, box-shadow 0.2s;
}
.submitBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(var(--primary-rgb) / 0.35);
}
.overlayIn {
animation: overlayFadeIn 0.22s ease forwards;
}
.overlayOut {
animation: overlayFadeOut 0.22s ease forwards;
}
.modalIn {
animation: modalFadeIn 0.25s ease forwards;
}
.modalOut {
animation: modalFadeOut 0.22s ease forwards;
}
@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,174 @@
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import styles from './CategoryModal.module.css'
interface CategoryModalProps {
open: boolean
onClose: () => void
onSubmit: (data: CategoryFormData) => void
categories: Category[]
defaultParentId?: string
editingCategory?: Category | null
title?: string
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function CategoryModal({
open,
onClose,
onSubmit,
categories,
defaultParentId = '',
editingCategory = null,
title = 'Add Category',
isSubmitting = false,
}: CategoryModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [parentId, setParentId] = useState(defaultParentId)
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [description, setDescription] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setParentId(editingCategory?.parentId ?? defaultParentId)
setNameEn(editingCategory?.nameEn ?? '')
setNameFa(editingCategory?.nameFa ?? '')
setDescription(editingCategory?.description ?? '')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, defaultParentId, editingCategory, 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) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
onSubmit({
parentId,
nameFa: nameFa.trim(),
nameEn: nameEn.trim(),
description: description.trim(),
})
}
const parentOptions = flattenCategories(
categories.filter((category) => category.id !== editingCategory?.id),
)
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="category-modal-title"
>
<div className={styles.header}>
<h3 id="category-modal-title" className={styles.title}>
{title}
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label>Parent Category</label>
<SearchableSelect
options={parentOptions}
value={parentId}
onChange={setParentId}
/>
</div>
<div className={styles.field}>
<label htmlFor="nameFa">Name (FA)</label>
<input
id="nameFa"
name="nameFa"
type="text"
dir="rtl"
className="faText"
placeholder="نام دسته‌بندی"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="nameEn">Name (EN)</label>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder="Category name"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
rows={3}
placeholder="Short description of this category"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Category'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,173 @@
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
transition: background 0.2s;
}
.row:last-child {
border-bottom: none;
}
.row:hover {
background: rgba(var(--primary-rgb) / 0.04);
}
.info {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 8px;
}
.clickable {
cursor: pointer;
border-radius: 8px;
margin: -8px;
padding: 8px;
transition: background 0.2s;
}
.clickable:hover {
background: rgba(var(--primary-rgb) / 0.06);
}
.textBlock {
flex: 1;
min-width: 0;
}
.chevron {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 4px;
color: var(--text-muted);
transition: transform 0.2s ease;
vertical-align: middle;
}
.chevronOpen {
transform: rotate(90deg);
}
.names {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.nameEn {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.separator {
color: var(--text-muted);
font-size: 14px;
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
direction: rtl;
}
.description {
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.controlBtn {
position: relative;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.iconBtn {
position: relative;
}
.variationBadge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
font-size: 9px;
font-weight: 700;
color: white;
background: var(--primary);
border-radius: 50px;
line-height: 1;
pointer-events: none;
}
.technicalBadge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
font-size: 9px;
font-weight: 700;
color: white;
background: #0d9488;
border-radius: 50px;
line-height: 1;
pointer-events: none;
}
.controlBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controlBtn.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
@media (max-width: 640px) {
.row {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.controls {
align-self: flex-end;
}
}
@@ -0,0 +1,129 @@
import { FolderPlus, Layers, ListTree, Trash2, ChevronRight, ClipboardList } from 'lucide-react'
import type { Category } from '../types/category'
import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css'
interface CategoryRowProps {
category: Category
depth: number
hasChildren: boolean
expanded: boolean
variationCount: number
technicalFieldCount: number
onToggle: () => void
onAddSub: (id: string) => void
onVariations: (id: string) => void
onTechnicalForm: (id: string) => void
onOptions: (id: string) => void
onRemove: (id: string) => void
}
export function CategoryRow({
category,
depth,
hasChildren,
expanded,
variationCount,
technicalFieldCount,
onToggle,
onAddSub,
onVariations,
onTechnicalForm,
onOptions,
onRemove,
}: CategoryRowProps) {
return (
<div
className={styles.row}
style={{ marginLeft: depth * 28 }}
>
<div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
onClick={hasChildren ? onToggle : undefined}
onKeyDown={
hasChildren
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
aria-expanded={hasChildren ? expanded : undefined}
>
<div className={styles.textBlock}>
<div className={styles.names}>
<span className={styles.nameEn}>{category.nameEn}</span>
<span className={styles.separator}>·</span>
<span className={styles.nameFa}>{category.nameFa}</span>
{hasChildren && (
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={18} />
</span>
)}
</div>
{category.description && (
<p className={styles.description}>{category.description}</p>
)}
</div>
</div>
<div className={styles.controls}>
<Tooltip label="Add sub category">
<button
className={styles.controlBtn}
onClick={() => onAddSub(category.id)}
aria-label="Add sub category"
>
<FolderPlus size={17} />
</button>
</Tooltip>
<Tooltip label="Manage variations">
<button
className={`${styles.controlBtn} ${styles.iconBtn}`}
onClick={() => onVariations(category.id)}
aria-label={`Variations (${variationCount})`}
>
<Layers size={17} />
{variationCount > 0 && (
<span className={styles.variationBadge}>{variationCount}</span>
)}
</button>
</Tooltip>
<Tooltip label="Technical data form">
<button
className={`${styles.controlBtn} ${styles.iconBtn}`}
onClick={() => onTechnicalForm(category.id)}
aria-label={`Technical data form (${technicalFieldCount})`}
>
<ClipboardList size={17} />
{technicalFieldCount > 0 && (
<span className={styles.technicalBadge}>{technicalFieldCount}</span>
)}
</button>
</Tooltip>
<Tooltip label="Category options">
<button
className={styles.controlBtn}
onClick={() => onOptions(category.id)}
aria-label="Options"
>
<ListTree size={17} />
</button>
</Tooltip>
<Tooltip label="Remove category">
<button
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)}
aria-label="Remove"
>
<Trash2 size={17} />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -0,0 +1,13 @@
.childrenWrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease;
}
.childrenWrap.expanded {
grid-template-rows: 1fr;
}
.childrenInner {
overflow: hidden;
}
@@ -0,0 +1,84 @@
import type { Category } from '../types/category'
import { CategoryRow } from './CategoryRow'
import { getChildren, hasChildren } from '../utils/categories'
import styles from './CategoryTree.module.css'
interface CategoryTreeProps {
categories: Category[]
expandedIds: Set<string>
variationCounts: Record<string, number>
technicalFieldCounts: Record<string, number>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onVariations: (id: string) => void
onTechnicalForm: (id: string) => void
onOptions: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
}
export function CategoryTree({
categories,
expandedIds,
variationCounts,
technicalFieldCounts,
onToggle,
onAddSub,
onVariations,
onTechnicalForm,
onOptions,
onRemove,
parentId = null,
depth = 0,
}: CategoryTreeProps) {
const nodes = getChildren(categories, parentId)
return (
<>
{nodes.map((category) => {
const childCount = hasChildren(categories, category.id)
const expanded = expandedIds.has(category.id)
return (
<div key={category.id}>
<CategoryRow
category={category}
depth={depth}
hasChildren={childCount}
expanded={expanded}
variationCount={variationCounts[category.id] ?? category.variationCount ?? 0}
technicalFieldCount={technicalFieldCounts[category.id] ?? 0}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onVariations={onVariations}
onTechnicalForm={onTechnicalForm}
onOptions={onOptions}
onRemove={onRemove}
/>
{childCount && (
<div className={`${styles.childrenWrap} ${expanded ? styles.expanded : ''}`}>
<div className={styles.childrenInner}>
<CategoryTree
categories={categories}
expandedIds={expandedIds}
variationCounts={variationCounts}
technicalFieldCounts={technicalFieldCounts}
onToggle={onToggle}
onAddSub={onAddSub}
onVariations={onVariations}
onTechnicalForm={onTechnicalForm}
onOptions={onOptions}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
/>
</div>
</div>
)}
</div>
)
})}
</>
)
}
@@ -0,0 +1,122 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(127, 29, 29, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.modal {
position: relative;
width: 100%;
max-width: 400px;
padding: 32px 28px 24px;
text-align: center;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(239, 68, 68, 0.15);
}
.iconWrap {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
background: rgba(239, 68, 68, 0.12);
color: #ef4444;
border-radius: 50%;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.message {
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
margin-bottom: 24px;
}
.actions {
display: flex;
gap: 10px;
justify-content: center;
}
.cancelBtn {
padding: 10px 20px;
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);
}
.deleteBtn {
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
color: white;
background: #ef4444;
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35);
transition: background 0.2s, transform 0.2s;
}
.deleteBtn:hover {
background: #dc2626;
transform: translateY(-1px);
}
.closeBtn {
position: absolute;
top: 14px;
right: 14px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-muted);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@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,86 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
interface ConfirmDeleteModalProps {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
const ANIMATION_MS = 220
export function ConfirmDeleteModal({
open,
title,
message,
onConfirm,
onCancel,
}: ConfirmDeleteModalProps) {
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') onCancel()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onCancel])
if (!mounted) return null
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-delete-title"
>
<div className={styles.iconWrap}>
<AlertTriangle size={28} />
</div>
<h3 id="confirm-delete-title" className={styles.title}>
{title}
</h3>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
Cancel
</button>
<button type="button" className={styles.deleteBtn} onClick={onConfirm}>
Delete
</button>
</div>
<button className={styles.closeBtn} onClick={onCancel} aria-label="Close">
<X size={18} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,62 @@
.modal {
width: min(560px, calc(100vw - 32px));
}
.body {
padding: 0 20px 20px;
}
.metaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin: 0 0 18px;
}
.metaItem {
min-width: 0;
}
.metaItem dt {
margin: 0 0 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
}
.metaItem dd {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
word-break: break-word;
}
.messageBlock {
padding-top: 16px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.messageLabel {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.messageText {
margin: 0;
font-size: 14px;
line-height: 1.65;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 520px) {
.metaGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { ContactSubmission } from '../types/contactSubmission'
import { formatCellForDisplay } from '../lib/cellNumber'
import modalStyles from './VariationsModal.module.css'
import styles from './ContactSubmissionDetailModal.module.css'
interface ContactSubmissionDetailModalProps {
open: boolean
submission: ContactSubmission | null
onClose: () => void
}
const ANIMATION_MS = 220
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' }),
}
}
export function ContactSubmissionDetailModal({
open,
submission,
onClose,
}: ContactSubmissionDetailModalProps) {
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 || !submission) return null
const { date, time } = formatDateTime(submission.createdAt)
return (
<div
className={`${modalStyles.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="contact-submission-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="contact-submission-title" className={modalStyles.title}>
Contact submission
</h2>
<p className={modalStyles.subtitle}>{submission.title}</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className={styles.body}>
<dl className={styles.metaGrid}>
<div className={styles.metaItem}>
<dt>Name</dt>
<dd>{submission.name}</dd>
</div>
<div className={styles.metaItem}>
<dt>Email</dt>
<dd>{submission.email ?? '—'}</dd>
</div>
<div className={styles.metaItem}>
<dt>Cell number</dt>
<dd>
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
</dd>
</div>
<div className={styles.metaItem}>
<dt>Date</dt>
<dd>{date}</dd>
</div>
<div className={styles.metaItem}>
<dt>Time</dt>
<dd>{time || '—'}</dd>
</div>
</dl>
<div className={styles.messageBlock}>
<h3 className={styles.messageLabel}>Message</h3>
<p className={styles.messageText}>{submission.text}</p>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { ProductVariant } from '../types/productVariant'
import { buildVariantLabel } from '../types/productVariant'
import type { ProductVariantAttribute } from '../services/productVariantService'
import styles from './VariationsModal.module.css'
interface CreateProductVariantModalProps {
open: boolean
attributes: ProductVariantAttribute[]
existingVariants: ProductVariant[]
isSubmitting?: boolean
error?: string
onClose: () => void
onSubmit: (payload: { variationId: string; optionId: string }) => void
}
const ANIMATION_MS = 220
export function CreateProductVariantModal({
open,
attributes,
existingVariants,
isSubmitting = false,
error = '',
onClose,
onSubmit,
}: CreateProductVariantModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [variationId, setVariationId] = useState('')
const [optionId, setOptionId] = useState('')
const selectedVariation = attributes.find((item) => item.id === variationId)
const options = selectedVariation?.options ?? []
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
const first = attributes[0]
setVariationId(first?.id ?? '')
setOptionId(first?.options[0]?.id ?? '')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, attributes])
useEffect(() => {
if (!selectedVariation) return
if (!options.some((item) => item.id === optionId)) {
setOptionId(options[0]?.id ?? '')
}
}, [selectedVariation, options, optionId])
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) return null
const selectedOption = options.find((item) => item.id === optionId)
const selectionList = selectedVariation && selectedOption
? [
{
attributeId: selectedVariation.id,
attributeName: selectedVariation.name,
optionId: selectedOption.id,
value: selectedOption.label,
},
]
: []
const label = buildVariantLabel(selectionList)
const isDuplicate = existingVariants.some((variant) =>
variant.selections.some((selection) => selection.optionId === optionId),
)
const canSubmit =
Boolean(variationId && optionId && selectionList.length) &&
!isDuplicate &&
!isSubmitting
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
onSubmit({ variationId, optionId })
}
return (
<div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="create-variant-title"
>
<div className={styles.header}>
<h3 id="create-variant-title" className={styles.title}>
Create Variation
</h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form className={styles.body} onSubmit={handleSubmit}>
{attributes.length === 0 ? (
<p className={styles.emptyText}>
This product category has no variations. Add variations on the category first.
</p>
) : (
<>
<div className={styles.field}>
<label htmlFor="variation-type">Variation</label>
<select
id="variation-type"
value={variationId}
onChange={(e) => setVariationId(e.target.value)}
>
{attributes.map((attr) => (
<option key={attr.id} value={attr.id}>
{attr.name}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="variation-value">Value</label>
<select
id="variation-value"
value={optionId}
onChange={(e) => setOptionId(e.target.value)}
disabled={!selectedVariation}
>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
</>
)}
{label && (
<div className={styles.preview}>
<span className={styles.previewLabel}>Preview:</span> {label}
</div>
)}
{isDuplicate && (
<p className={styles.errorText}>This variation value already exists.</p>
)}
{error && <p className={styles.errorText}>{error}</p>}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Cancel
</button>
<button
type="submit"
className={styles.submitBtn}
disabled={!canSubmit || attributes.length === 0}
>
{isSubmitting ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,170 @@
.itemRows {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.itemRow {
display: grid;
gap: 8px;
align-items: center;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
.itemRowHeader {
display: grid;
gap: 8px;
padding: 0 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.removeCell {
display: flex;
justify-content: flex-end;
align-items: center;
}
.compactInput {
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-color: 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);
}
.compactInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.itemRow select.compactInput,
.field select {
appearance: none;
-webkit-appearance: none;
padding-right: 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: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
.addRowBtn {
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;
margin-bottom: 8px;
}
.addRowBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
}
.modalWide {
max-width: 920px;
}
.modalCompact {
max-width: 420px;
}
.discountHeader,
.discountRow {
display: grid;
grid-template-columns: minmax(140px, 1.4fr) minmax(120px, 1fr) minmax(160px, 1fr);
gap: 10px;
align-items: center;
}
.discountHeaderFestival,
.discountRowFestival {
display: grid;
grid-template-columns: 1fr 112px;
gap: 10px;
align-items: center;
}
.discountHeaderFestival {
padding: 0 4px 8px;
margin-bottom: 2px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
}
.discountHeaderFestival span:last-child {
text-align: right;
}
.discountRowFestival {
padding: 8px 4px;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.discountRowFestival:last-child {
border-bottom: none;
}
.discountHeader {
padding: 0 12px 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.discountRows {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.discountRowsCompact {
gap: 0;
margin-bottom: 16px;
}
.discountRow {
padding: 10px 12px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
.discountLabel {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.discountPricePreview {
min-width: 0;
}
@@ -0,0 +1,399 @@
import { useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import {
getProductVariationValues,
type ProductVariationSelection,
} from '../services/productVariationService'
import {
batchCreateStoreItems,
type CreateStoreItemPayload,
} from '../services/storeItemService'
import { createId } from '../utils/id'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import {
createEmptyStoreItemRow,
getVariationOptions,
type StoreItemDraftRow,
} from '../utils/storeItemRows'
import { ProductSearchSelect } from './ProductSearchSelect'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css'
interface CreateStoreItemsModalProps {
open: boolean
existingProductIds: string[]
onClose: () => void
onCreated?: () => void
onEditExisting?: (productId: string) => void
}
const ANIMATION_MS = 220
export function CreateStoreItemsModal({
open,
existingProductIds,
onClose,
onCreated,
onEditExisting,
}: CreateStoreItemsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
const [productId, setProductId] = useState('')
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [isLoadingProducts, setIsLoadingProducts] = useState(false)
const [isLoadingVariations, setIsLoadingVariations] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const formVariations = useMemo(
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations],
)
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ')
return cols || 'minmax(120px, 1fr) 90px 32px'
}, [formVariations])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
setProductId('')
setVariations([])
setRows([])
setError('')
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadProducts(controller.signal)
return () => controller.abort()
}, [open])
useEffect(() => {
if (!productId) {
setVariations([])
setRows([])
return
}
const controller = new AbortController()
void loadVariations(productId, controller.signal)
return () => controller.abort()
}, [productId])
async function loadProducts(signal?: AbortSignal) {
setIsLoadingProducts(true)
setError('')
try {
const items = await listAllProducts(signal)
setProducts(
items.map((item) => ({
id: item.id,
title: item.title,
nameFa: item.nameFa,
})),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load products.')
}
} finally {
setIsLoadingProducts(false)
}
}
async function loadVariations(nextProductId: string, signal?: AbortSignal) {
setIsLoadingVariations(true)
setError('')
try {
const data = await getProductVariationValues(nextProductId, signal)
setVariations(data.variations)
setRows([createEmptyStoreItemRow()])
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product variations.')
}
setVariations([])
setRows([])
} finally {
setIsLoadingVariations(false)
}
}
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) return null
function handleProductChange(nextProductId: string) {
if (nextProductId && existingProductIds.includes(nextProductId)) {
onEditExisting?.(nextProductId)
onClose()
return
}
setProductId(nextProductId)
}
function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) {
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, ...patch } : row)))
}
function updateRowSelection(rowId: string, variationId: string, optionId: string) {
setRows((prev) =>
prev.map((row) => {
if (row.id !== rowId) return row
const nextSelections = { ...row.selections }
if (!optionId) {
delete nextSelections[variationId]
} else {
nextSelections[variationId] = optionId
}
return { ...row, selections: nextSelections }
}),
)
}
function addRow() {
setRows((prev) => [...prev, { ...createEmptyStoreItemRow(), id: createId() }])
}
function removeRow(rowId: string) {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.id !== rowId)))
}
function buildPayload(): CreateStoreItemPayload[] {
return rows.map((row) => ({
selections: formVariations.flatMap((variation) => {
const optionId = row.selections[variation.id]
if (!optionId) return []
return [{ variationId: variation.id, optionId }]
}),
price: parseIrtInput(row.price) ?? undefined,
stockQuantity: row.stock.trim() ? Number(row.stock) : undefined,
}))
}
const canSubmit =
Boolean(productId) &&
rows.length > 0 &&
rows.every((row) => {
const price = parseIrtInput(row.price)
const stock = Number(row.stock)
return price !== null && price >= 0 && row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
}) &&
!isSubmitting &&
!isLoadingVariations
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
setIsSubmitting(true)
setError('')
try {
await batchCreateStoreItems({
productId,
items: buildPayload(),
})
onCreated?.()
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to create store items.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="create-store-items-title"
>
<div className={modalStyles.header}>
<div>
<h3 id="create-store-items-title" className={modalStyles.title}>
Add Store Items
</h3>
<p className={modalStyles.subtitle}>
Create sellable variants from a product&apos;s variations.
</p>
</div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}>
<label htmlFor="store-item-product">Product</label>
<ProductSearchSelect
id="store-item-product"
options={products}
value={productId}
onChange={handleProductChange}
placeholder="Type 3+ characters to search products"
disabled={isLoadingProducts || isSubmitting}
/>
</div>
{productId && isLoadingVariations && (
<p className={modalStyles.emptyText}>Loading product variations...</p>
)}
{productId && !isLoadingVariations && (
<>
<div
className={rowStyles.itemRowHeader}
style={{ gridTemplateColumns: gridTemplate }}
>
{formVariations.map((variation) => (
<span key={variation.id}>{variation.name}</span>
))}
<span>Price (IRT)</span>
<span>Stock</span>
<span />
</div>
<div className={rowStyles.itemRows}>
{rows.map((row) => (
<div
key={row.id}
className={rowStyles.itemRow}
style={{ gridTemplateColumns: gridTemplate }}
>
{formVariations.map((variation) => {
const options = getVariationOptions(variation)
return (
<select
key={variation.id}
className={rowStyles.compactInput}
value={row.selections[variation.id] ?? ''}
onChange={(e) =>
updateRowSelection(row.id, variation.id, e.target.value)
}
disabled={isSubmitting}
>
<option value=""></option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
)
})}
<input
type="text"
inputMode="numeric"
className={rowStyles.compactInput}
value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0"
disabled={isSubmitting}
/>
<input
type="number"
min="0"
step="1"
className={rowStyles.compactInput}
value={row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })}
disabled={isSubmitting}
/>
<div className={rowStyles.removeCell}>
<Tooltip label="Remove row">
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting}
aria-label="Remove row"
>
<X size={16} />
</button>
</Tooltip>
</div>
</div>
))}
</div>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={isSubmitting}
>
<Plus size={16} />
Add row
</button>
</>
)}
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={modalStyles.submitBtn}
disabled={!canSubmit}
>
{isSubmitting ? 'Creating…' : 'Create store items'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,18 @@
import { useLocation } from 'react-router-dom'
import { useDashboardDocumentTitle } from '@meshkee/dashboard-ui'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { BUSINESS_DASHBOARD_NAME, businessRouteTitleRules } from '../lib/routeTitles'
export function DashboardDocumentTitle() {
const { pathname } = useLocation()
const { businessName } = useTenantBranding()
useDashboardDocumentTitle({
businessName,
dashboardName: BUSINESS_DASHBOARD_NAME,
pathname,
routeRules: businessRouteTitleRules,
})
return null
}
@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import { updateCustomer, type BusinessCustomerListItem } from '../services/customerService'
import modalStyles from './VariationsModal.module.css'
interface EditCustomerModalProps {
open: boolean
customer: BusinessCustomerListItem | null
onClose: () => void
onSaved: (customer: BusinessCustomerListItem) => void
}
const ANIMATION_MS = 220
export function EditCustomerModal({
open,
customer,
onClose,
onSaved,
}: EditCustomerModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [cellNumber, setCellNumber] = useState('')
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (open && customer) {
setMounted(true)
setClosing(false)
setFirstName(customer.firstName ?? '')
setLastName(customer.lastName ?? '')
setCellNumber(formatCellForDisplay(customer.cellNumber))
setEmail(customer.email ?? '')
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, customer, 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 || !customer) return null
const canSave =
firstName.trim().length >= 1 &&
lastName.trim().length >= 1 &&
toE164CellNumber(cellNumber.trim()).length > 0
async function handleSubmit() {
if (!customer || !canSave) return
const normalizedCell = toE164CellNumber(cellNumber.trim())
if (!normalizedCell) return
setIsSubmitting(true)
setError('')
try {
const updated = await updateCustomer(customer.id, {
firstName: firstName.trim(),
lastName: lastName.trim(),
cellNumber: normalizedCell,
email: email.trim(),
})
onSaved({
...customer,
firstName: updated.firstName,
lastName: updated.lastName,
cellNumber: updated.cellNumber,
email: updated.email,
label: updated.label,
isEnabled: updated.isEnabled,
})
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
} finally {
setIsSubmitting(false)
}
}
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="edit-customer-title"
>
<div className={modalStyles.header}>
<div>
<h3 id="edit-customer-title" className={modalStyles.title}>
Edit customer
</h3>
<p className={modalStyles.subtitle}>Update customer contact details.</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={18} />
</button>
</div>
<div className={modalStyles.body}>
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.field}>
<label htmlFor="edit-customer-first-name">First name</label>
<input
id="edit-customer-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-last-name">Last name</label>
<input
id="edit-customer-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-cell">Cell number</label>
<input
id="edit-customer-cell"
value={cellNumber}
onChange={(e) => setCellNumber(e.target.value)}
autoComplete="off"
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="edit-customer-email">Email (optional)</label>
<input
id="edit-customer-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSave}
>
Save
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,350 @@
import { useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
getProductVariationValues,
type ProductVariationSelection,
} from '../services/productVariationService'
import {
syncProductStoreItems,
type StoreItem,
} from '../services/storeItemService'
import { createId } from '../utils/id'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import {
createEmptyStoreItemRow,
getVariationOptions,
type StoreItemDraftRow,
} from '../utils/storeItemRows'
import { Tooltip } from './Tooltip'
import modalStyles from './VariationsModal.module.css'
import rowStyles from './CreateStoreItemsModal.module.css'
interface EditStoreItemsModalProps {
open: boolean
productTitle: string
productNameFa: string
items: StoreItem[]
onClose: () => void
onSaved?: (items: StoreItem[]) => void
}
const ANIMATION_MS = 220
function itemsToRows(storeItems: StoreItem[]): StoreItemDraftRow[] {
if (storeItems.length === 0) return [createEmptyStoreItemRow()]
return storeItems.map((item) => {
const selections: Record<string, string> = {}
for (const selection of item.selections) {
selections[selection.variationId] = selection.optionId
}
return {
id: createId(),
storeItemId: item.id,
selections,
price: item.price !== null ? formatIrtInput(String(item.price)) : '',
stock: item.stockQuantity !== null ? String(item.stockQuantity) : '1',
}
})
}
export function EditStoreItemsModal({
open,
productTitle,
productNameFa,
items,
onClose,
onSaved,
}: EditStoreItemsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
const [rows, setRows] = useState<StoreItemDraftRow[]>([])
const [removedIds, setRemovedIds] = useState<string[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const productId = items[0]?.productId ?? ''
const formVariations = useMemo(
() => variations.filter((variation) => getVariationOptions(variation).length > 0),
[variations],
)
const gridTemplate = useMemo(() => {
const variationCols = formVariations.map(() => 'minmax(110px, 1fr)').join(' ')
const cols = [variationCols, 'minmax(120px, 1fr)', '90px', '32px'].filter((part) => part).join(' ')
return cols || 'minmax(120px, 1fr) 90px 32px'
}, [formVariations])
useEffect(() => {
if (open && productId) {
setMounted(true)
setClosing(false)
setRemovedIds([])
setRows(itemsToRows(items))
setError('')
const controller = new AbortController()
void loadVariations(productId, controller.signal)
return () => controller.abort()
}
if (mounted && !open) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, productId, items, mounted])
async function loadVariations(nextProductId: string, signal?: AbortSignal) {
setIsLoading(true)
try {
const data = await getProductVariationValues(nextProductId, signal)
setVariations(data.variations)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product variations.')
}
} finally {
setIsLoading(false)
}
}
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 || !productId) return null
function updateRow(rowId: string, patch: Partial<StoreItemDraftRow>) {
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, ...patch } : row)))
}
function updateRowSelection(rowId: string, variationId: string, optionId: string) {
setRows((prev) =>
prev.map((row) => {
if (row.id !== rowId) return row
const nextSelections = { ...row.selections }
if (!optionId) {
delete nextSelections[variationId]
} else {
nextSelections[variationId] = optionId
}
return { ...row, selections: nextSelections }
}),
)
}
function addRow() {
setRows((prev) => [...prev, { ...createEmptyStoreItemRow(), id: createId() }])
}
function removeRow(rowId: string) {
setRows((prev) => {
if (prev.length <= 1) return prev
const target = prev.find((row) => row.id === rowId)
if (target?.storeItemId) {
setRemovedIds((ids) => [...ids, target.storeItemId!])
}
return prev.filter((row) => row.id !== rowId)
})
}
const canSubmit =
rows.length > 0 &&
rows.every((row) => {
const price = parseIrtInput(row.price)
const stock = Number(row.stock)
return price !== null && price >= 0 && row.stock.trim() !== '' && !Number.isNaN(stock) && stock >= 0
}) &&
!isSubmitting &&
!isLoading
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
setIsSubmitting(true)
setError('')
try {
const data = await syncProductStoreItems({
productId,
items: rows.map((row) => ({
id: row.storeItemId,
selections: formVariations.flatMap((variation) => {
const optionId = row.selections[variation.id]
if (!optionId) return []
return [{ variationId: variation.id, optionId }]
}),
price: parseIrtInput(row.price)!,
stockQuantity: Number(row.stock),
})),
removedIds,
})
onSaved?.(data.items)
onClose()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save store items.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${rowStyles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="edit-store-items-title"
>
<div className={modalStyles.header}>
<div>
<h3 id="edit-store-items-title" className={modalStyles.title}>
Edit Store Items
</h3>
<p className={modalStyles.subtitle}>
{productTitle}
{productNameFa ? ` / ${productNameFa}` : ''}
</p>
</div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
{isLoading ? (
<p className={modalStyles.emptyText}>Loading product variations...</p>
) : (
<>
<div
className={rowStyles.itemRowHeader}
style={{ gridTemplateColumns: gridTemplate }}
>
{formVariations.map((variation) => (
<span key={variation.id}>{variation.name}</span>
))}
<span>Price (IRT)</span>
<span>Stock</span>
<span />
</div>
<div className={rowStyles.itemRows}>
{rows.map((row) => (
<div
key={row.id}
className={rowStyles.itemRow}
style={{ gridTemplateColumns: gridTemplate }}
>
{formVariations.map((variation) => {
const options = getVariationOptions(variation)
return (
<select
key={variation.id}
className={rowStyles.compactInput}
value={row.selections[variation.id] ?? ''}
onChange={(e) =>
updateRowSelection(row.id, variation.id, e.target.value)
}
disabled={isSubmitting}
>
<option value=""></option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
)
})}
<input
type="text"
inputMode="numeric"
className={rowStyles.compactInput}
value={row.price}
onChange={(e) => updateRow(row.id, { price: formatIrtInput(e.target.value) })}
placeholder="0"
disabled={isSubmitting}
/>
<input
type="number"
min="0"
step="1"
className={rowStyles.compactInput}
value={row.stock}
onChange={(e) => updateRow(row.id, { stock: e.target.value })}
disabled={isSubmitting}
/>
<div className={rowStyles.removeCell}>
<Tooltip label="Remove row">
<button
type="button"
className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting}
aria-label="Remove row"
>
<X size={16} />
</button>
</Tooltip>
</div>
</div>
))}
</div>
<button
type="button"
className={rowStyles.addRowBtn}
onClick={addRow}
disabled={isSubmitting}
>
<Plus size={16} />
Add row
</button>
</>
)}
{error && <p className={modalStyles.errorText}>{error}</p>}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import styles from './RouteLoader.module.css'
export function GuestRoute() {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className={styles.loaderWrap}>
<div className={styles.loader} aria-label="Loading" />
</div>
)
}
if (user) {
return <Navigate to="/" 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;
}
}
+152
View File
@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Menu, Bell, MessageSquare, ChevronDown, User, Settings, 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'
const profileMenuItems = [
{ icon: User, label: 'Profile', to: '/profile' },
{ icon: Settings, label: 'Setting', to: '/settings' },
]
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 || 'User'
const roleLabel =
user?.roleLabel ??
(user?.isSuperAdmin || user?.roles.includes('super_admin')
? 'Super Admin'
: user?.businesses[0]?.isOwner
? 'Business Owner'
: user?.businesses[0]?.teamRole ?? 'Staff')
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}>Admin Dashboard</h1>
</div>
<div className={styles.right}>
<button className={styles.iconBtn} aria-label="Messages">
<MessageSquare size={20} />
<span className={styles.badge}>5</span>
</button>
<button className={styles.iconBtn} aria-label="Notifications">
<Bell size={20} />
<span className={styles.badge}>3</span>
</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}>{roleLabel}</span>
</div>
<ChevronDown
size={16}
className={`${styles.chevron} ${menuOpen ? styles.chevronOpen : ''}`}
/>
</button>
{menuOpen && (
<div className={styles.dropdown} role="menu">
{profileMenuItems.map(({ icon: Icon, label, to }) => (
<Link
key={label}
to={to}
className={styles.dropdownItem}
role="menuitem"
onClick={() => setMenuOpen(false)}
>
<Icon size={16} />
<span>{label}</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,159 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.uploadZone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
min-height: 120px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.uploadZone:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb) / 0.04);
color: var(--primary);
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
.preview {
position: relative;
width: 100%;
min-height: 120px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(-45deg, #e2e8f0 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #e2e8f0 75%),
linear-gradient(-45deg, transparent 75%, #e2e8f0 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
}
.previewImg {
width: 100%;
height: 100%;
object-fit: cover;
}
.removeBtn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.6);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.cropPanel {
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
background: rgba(255, 255, 255, 0.5);
}
.cropArea {
position: relative;
width: 100%;
min-height: 160px;
background: #1e293b;
}
.cropControls {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.zoomLabel {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text-secondary);
}
.zoomLabel input {
flex: 1;
accent-color: var(--primary);
}
.cropActions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.cancelBtn {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
border-radius: 8px;
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.applyBtn {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: white;
background: var(--primary);
border-radius: 8px;
transition: background 0.2s;
}
.applyBtn:hover {
background: var(--primary-dark);
}
.changeBtn {
display: inline-flex;
align-self: flex-start;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--primary);
border: 1px solid rgba(var(--primary-rgb) / 0.3);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.changeBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
}
@@ -0,0 +1,132 @@
import { useCallback, useState } from 'react'
import Cropper, { type Area } from 'react-easy-crop'
import { ImagePlus, X } from 'lucide-react'
import { getCroppedImage } from '../utils/cropImage'
import styles from './ImageCropper.module.css'
interface ImageCropperProps {
value: string | null
onChange: (value: string | null) => void
aspect?: number
outputFormat?: 'jpeg' | 'png'
accept?: string
uploadLabel?: string
hint?: string
changeLabel?: string
}
export function ImageCropper({
value,
onChange,
aspect = 1,
outputFormat = 'jpeg',
accept = 'image/*',
uploadLabel = 'Upload thumbnail image',
hint = 'Click to select, then crop',
changeLabel = 'Change thumbnail',
}: ImageCropperProps) {
const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedArea, setCroppedArea] = useState<Area | null>(null)
const onCropComplete = useCallback((_: Area, pixels: Area) => {
setCroppedArea(pixels)
}, [])
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => setImageSrc(reader.result as string)
reader.readAsDataURL(file)
e.target.value = ''
}
async function applyCrop() {
if (!imageSrc || !croppedArea) return
const cropped = await getCroppedImage(imageSrc, croppedArea, outputFormat)
onChange(cropped)
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function cancelCrop() {
setImageSrc(null)
setZoom(1)
setCrop({ x: 0, y: 0 })
}
function removeThumbnail() {
onChange(null)
}
const frameStyle = { aspectRatio: `${aspect}` as const }
return (
<div className={styles.wrapper}>
{value && !imageSrc && (
<div className={styles.preview} style={frameStyle}>
<img src={value} alt="Thumbnail preview" className={styles.previewImg} />
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail">
<X size={16} />
</button>
</div>
)}
{!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={28} />
<span>{uploadLabel}</span>
<span className={styles.hint}>{hint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
{imageSrc && (
<div className={styles.cropPanel}>
<div className={styles.cropArea} style={frameStyle}>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
<div className={styles.cropControls}>
<label className={styles.zoomLabel}>
Zoom
<input
type="range"
min={1}
max={3}
step={0.05}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
</label>
<div className={styles.cropActions}>
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
Cancel
</button>
<button type="button" className={styles.applyBtn} onClick={applyCrop}>
Apply Crop
</button>
</div>
</div>
</div>
)}
{value && !imageSrc && (
<label className={styles.changeBtn}>
{changeLabel}
<input type="file" accept={accept} onChange={handleFile} hidden />
</label>
)}
</div>
)
}
@@ -0,0 +1,166 @@
.overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.overlayIn {
animation: fadeIn 0.2s ease forwards;
}
.overlayOut {
animation: fadeOut 0.2s ease forwards;
}
.closeBtn {
position: absolute;
top: 20px;
right: 20px;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: rgba(255, 255, 255, 0.12);
transition: background 0.2s;
}
.closeBtn:hover {
background: rgba(255, 255, 255, 0.22);
}
.content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: min(92vw, 960px);
max-height: calc(100vh - 140px);
}
.figure {
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 100%;
}
.image {
max-width: 100%;
max-height: calc(100vh - 180px);
object-fit: contain;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.counter {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
}
.navBtn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: rgba(255, 255, 255, 0.14);
transition: background 0.2s;
}
.navBtn:hover {
background: rgba(255, 255, 255, 0.24);
}
.navPrev {
left: -56px;
}
.navNext {
right: -56px;
}
.strip {
display: flex;
gap: 8px;
max-width: min(92vw, 720px);
overflow-x: auto;
padding: 4px;
}
.thumb {
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
border: 2px solid transparent;
background: #fff;
opacity: 0.72;
transition: opacity 0.2s, border-color 0.2s, transform 0.2s;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 4px;
}
.thumb:hover {
opacity: 1;
}
.thumbActive {
opacity: 1;
border-color: white;
transform: translateY(-2px);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@media (max-width: 768px) {
.overlay {
padding: 16px;
}
.navPrev {
left: 8px;
}
.navNext {
right: 8px;
}
.content {
width: 100%;
}
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import styles from './ImageLightbox.module.css'
interface ImageLightboxProps {
open: boolean
images: string[]
initialIndex?: number
alt?: string
onClose: () => void
}
export function ImageLightbox({
open,
images,
initialIndex = 0,
alt = 'Product image',
onClose,
}: ImageLightboxProps) {
const [index, setIndex] = useState(initialIndex)
const [mounted, setMounted] = useState(open)
useEffect(() => {
if (open) {
setMounted(true)
setIndex(initialIndex)
}
}, [open, initialIndex])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
if (event.key === 'ArrowLeft') setIndex((current) => Math.max(0, current - 1))
if (event.key === 'ArrowRight') {
setIndex((current) => Math.min(images.length - 1, current + 1))
}
}
document.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [open, images.length, onClose])
useEffect(() => {
if (!mounted || open) return
const timer = setTimeout(() => setMounted(false), 200)
return () => clearTimeout(timer)
}, [mounted, open])
if (!mounted || images.length === 0) return null
const current = images[index] ?? images[0]
const hasPrev = index > 0
const hasNext = index < images.length - 1
return (
<div
className={`${styles.overlay} ${open ? styles.overlayIn : styles.overlayOut}`}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Product image gallery"
>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close gallery">
<X size={22} />
</button>
<div className={styles.content} onClick={(event) => event.stopPropagation()}>
{hasPrev && (
<button
type="button"
className={`${styles.navBtn} ${styles.navPrev}`}
onClick={() => setIndex((current) => current - 1)}
aria-label="Previous image"
>
<ChevronLeft size={24} />
</button>
)}
<figure className={styles.figure}>
<img src={current} alt={`${alt} ${index + 1}`} className={styles.image} />
<figcaption className={styles.counter}>
{index + 1} / {images.length}
</figcaption>
</figure>
{hasNext && (
<button
type="button"
className={`${styles.navBtn} ${styles.navNext}`}
onClick={() => setIndex((current) => current + 1)}
aria-label="Next image"
>
<ChevronRight size={24} />
</button>
)}
</div>
{images.length > 1 && (
<div className={styles.strip} onClick={(event) => event.stopPropagation()}>
{images.map((src, imageIndex) => (
<button
key={`${src}-${imageIndex}`}
type="button"
className={`${styles.thumb} ${imageIndex === index ? styles.thumbActive : ''}`}
onClick={() => setIndex(imageIndex)}
aria-label={`View image ${imageIndex + 1}`}
>
<img src={src} alt="" />
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,82 @@
.grid {
--thumb-height: 140px;
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: stretch;
}
.item {
position: relative;
height: var(--thumb-height);
width: fit-content;
max-width: 100%;
flex: 0 0 auto;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--glass-border);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.item img {
height: var(--thumb-height);
width: auto;
display: block;
object-fit: contain;
}
.removeBtn {
position: absolute;
top: 6px;
right: 6px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(15, 23, 42, 0.65);
color: white;
transition: background 0.2s;
}
.removeBtn:hover {
background: #ef4444;
}
.addBtn {
height: var(--thumb-height);
width: var(--thumb-height);
flex: 0 0 var(--thumb-height);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed rgba(148, 163, 184, 0.4);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.4);
color: var(--text-secondary);
font-size: 12px;
transition: border-color 0.2s, color 0.2s, background 0.2s;
}
.addBtn:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.04);
}
.wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
.hint {
font-size: 12px;
color: var(--text-muted);
}
@@ -0,0 +1,75 @@
import { useRef } from 'react'
import { ImagePlus, X } from 'lucide-react'
import styles from './ImageUploader.module.css'
interface ImageUploaderProps {
images: string[]
onChange: (images: string[]) => void
}
export function ImageUploader({ images, onChange }: ImageUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const readers = files.map(
(file) =>
new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.readAsDataURL(file)
}),
)
Promise.all(readers).then((results) => {
onChange([...images, ...results])
})
e.target.value = ''
}
function removeImage(index: number) {
onChange(images.filter((_, i) => i !== index))
}
return (
<div className={styles.wrapper}>
<div className={styles.grid}>
{images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={`Product image ${index + 1}`} />
<button
type="button"
className={styles.removeBtn}
onClick={() => removeImage(index)}
aria-label={`Remove image ${index + 1}`}
>
<X size={14} />
</button>
</div>
))}
<button
type="button"
className={styles.addBtn}
onClick={() => inputRef.current?.click()}
>
<ImagePlus size={24} />
<span>Add images</span>
</button>
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={handleFiles}
/>
<p className={styles.hint}>Upload multiple product images. Click + to add more.</p>
</div>
)
}
@@ -0,0 +1,197 @@
.filtersPanel {
margin-bottom: 12px;
padding: 14px 16px;
background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
}
.filtersTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 10px;
}
.filtersGrid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 10px 12px;
align-items: end;
}
.filtersInputs {
grid-column: span 10;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 10px 12px;
align-items: end;
}
.filterActions {
grid-column: span 2;
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
justify-content: flex-end;
align-self: end;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldCol2 {
grid-column: span 2;
}
.fieldHalfWidth input {
width: 50%;
min-width: 88px;
}
.fieldCol3 {
grid-column: span 3;
}
.fieldCol4 {
grid-column: span 4;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field select {
width: 100%;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
color: var(--text-primary);
background: rgba(255, 255, 255, 0.65);
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: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
}
.field input:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
}
.switchField {
display: flex;
flex-direction: column;
gap: 4px;
}
.switchFieldSpacer {
font-size: 12px;
font-weight: 500;
line-height: 1.3;
visibility: hidden;
user-select: none;
}
.switchInline {
display: flex;
align-items: center;
gap: 8px;
height: var(--field-height);
}
.switchInline label,
.switchLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.iconActionBtn {
width: 40px;
height: 40px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
transition: background 0.2s, color 0.2s, transform 0.15s;
}
.iconActionBtn:hover:not(:disabled) {
transform: translateY(-1px);
}
.iconActionBtnPrimary {
color: white;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
box-shadow: 0 4px 14px rgba(var(--primary-dark-rgb) / 0.3);
}
.iconActionBtnPrimary:hover:not(:disabled) {
color: white;
}
.iconActionBtnGhost {
background: rgba(var(--primary-dark-rgb) / 0.06);
border: 1px solid rgba(var(--primary-dark-rgb) / 0.18);
color: var(--primary);
}
.iconActionBtnGhost:hover:not(:disabled) {
background: rgba(var(--primary-dark-rgb) / 0.12);
color: var(--primary);
}
.iconActionBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 1100px) {
.filtersInputs {
grid-column: span 12;
}
.filterActions {
grid-column: span 12;
justify-content: flex-end;
}
.fieldCol2,
.fieldCol3,
.fieldCol4 {
grid-column: span 6;
}
}
@media (max-width: 768px) {
.filtersInputs {
grid-template-columns: 1fr;
}
.fieldCol2,
.fieldCol3,
.fieldCol4 {
grid-column: span 1;
}
}
@@ -0,0 +1,175 @@
.wrapper {
position: relative;
}
.trigger {
width: 100%;
min-height: var(--field-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
text-align: left;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
}
.trigger:hover:not(:disabled) {
border-color: rgba(var(--primary-dark-rgb) / 0.35);
}
.triggerOpen,
.trigger:focus-visible {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
outline: none;
}
.trigger:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.triggerPlaceholder .triggerText {
color: var(--text-muted);
}
.triggerText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.triggerSearchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
display: flex;
flex-direction: column;
max-height: 240px;
overflow: hidden;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--glass-shadow);
}
.searchWrap {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
flex-shrink: 0;
}
.searchIcon {
flex-shrink: 0;
color: var(--text-muted);
}
.searchInput {
width: 100%;
min-width: 0;
padding: 0;
font-size: 13px;
font-family: inherit;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.searchInput::placeholder {
color: var(--text-muted);
}
.optionsList {
overflow-y: auto;
margin: 0;
padding: 6px;
list-style: none;
}
.option {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
font-size: 13px;
color: var(--text-primary);
text-align: left;
border-radius: 8px;
transition: background 0.15s;
}
.option:hover {
background: rgba(var(--primary-dark-rgb) / 0.08);
}
.optionSelected {
color: var(--primary);
font-weight: 500;
}
.check {
width: 16px;
height: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid rgba(148, 163, 184, 0.55);
border-radius: 4px;
color: white;
background: white;
}
.checkSelected {
border-color: var(--primary);
background: var(--primary);
}
.empty {
padding: 10px 12px;
font-size: 13px;
color: var(--text-muted);
text-align: center;
}
.measureText {
position: absolute;
visibility: hidden;
height: 0;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
}
@@ -0,0 +1,216 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronDown, Search } from 'lucide-react'
import styles from './MultiSelectDropdown.module.css'
export interface MultiSelectOption<T extends string | number = number> {
value: T
label: string
depth?: number
}
interface MultiSelectDropdownProps<T extends string | number = number> {
options: MultiSelectOption<T>[]
value: T[]
onChange: (value: T[]) => void
placeholder?: string
searchPlaceholder?: string
searchable?: boolean
disabled?: boolean
id?: string
}
export function MultiSelectDropdown<T extends string | number = number>({
options,
value,
onChange,
placeholder = 'Select options',
searchPlaceholder = 'Search...',
searchable = false,
disabled = false,
id,
}: MultiSelectDropdownProps<T>) {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [triggerLabel, setTriggerLabel] = useState(placeholder)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const measureRef = useRef<HTMLSpanElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const selectedLabels = useMemo(
() =>
options
.filter((option) => value.includes(option.value))
.map((option) => option.label),
[options, value],
)
useLayoutEffect(() => {
const trigger = triggerRef.current
const measure = measureRef.current
if (!trigger || !measure) {
setTriggerLabel(selectedLabels.length === 0 ? placeholder : selectedLabels.join(', '))
return
}
const triggerEl = trigger
const measureEl = measure
function buildLabel(visibleCount: number) {
const hiddenCount = selectedLabels.length - visibleCount
if (hiddenCount <= 0) {
return selectedLabels.join(', ')
}
return `${selectedLabels.slice(0, visibleCount).join(', ')} +${hiddenCount}`
}
function fitLabel() {
if (selectedLabels.length === 0) {
setTriggerLabel(placeholder)
return
}
const style = getComputedStyle(triggerEl)
measureEl.style.font = style.font
const padding =
parseFloat(style.paddingLeft) +
parseFloat(style.paddingRight)
const gap = parseFloat(style.columnGap || style.gap || '10')
const reservedWidth = padding + gap + 16 + (searchable ? gap + 16 : 0)
const availableWidth = triggerEl.clientWidth - reservedWidth
for (let visibleCount = selectedLabels.length; visibleCount >= 1; visibleCount -= 1) {
const label = buildLabel(visibleCount)
measureEl.textContent = label
if (measureEl.offsetWidth <= availableWidth) {
setTriggerLabel(label)
return
}
}
setTriggerLabel(buildLabel(1))
}
fitLabel()
const observer = new ResizeObserver(fitLabel)
observer.observe(triggerEl)
return () => observer.disconnect()
}, [placeholder, searchable, selectedLabels])
const filteredOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase()
if (!searchable || !query) return options
return options.filter((option) => option.label.toLowerCase().includes(query))
}, [options, searchQuery, searchable])
useEffect(() => {
if (!open) {
setSearchQuery('')
return
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [open])
useEffect(() => {
if (open && searchable) {
searchInputRef.current?.focus()
}
}, [open, searchable])
function toggleOption(optionValue: T) {
if (value.includes(optionValue)) {
onChange(value.filter((item) => item !== optionValue))
return
}
onChange([...value, optionValue])
}
return (
<div className={styles.wrapper} ref={containerRef}>
<span ref={measureRef} className={styles.measureText} aria-hidden="true" />
<button
ref={triggerRef}
id={id}
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${
selectedLabels.length === 0 ? styles.triggerPlaceholder : ''
}`}
onClick={() => !disabled && setOpen((prev) => !prev)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
{searchable && <Search size={16} className={styles.triggerSearchIcon} aria-hidden="true" />}
<span className={styles.triggerText}>{triggerLabel}</span>
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</button>
{open && (
<div className={styles.dropdown}>
{searchable && (
<div className={styles.searchWrap}>
<Search size={16} className={styles.searchIcon} aria-hidden="true" />
<input
ref={searchInputRef}
type="search"
className={styles.searchInput}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
)}
<ul className={styles.optionsList} role="listbox" aria-multiselectable="true">
{filteredOptions.length === 0 ? (
<li className={styles.empty}>
{options.length === 0 ? 'No options available.' : 'No matching options.'}
</li>
) : (
filteredOptions.map((option) => {
const selected = value.includes(option.value)
return (
<li key={String(option.value)}>
<button
type="button"
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionSelected : ''}`}
style={{ paddingLeft: `${10 + (option.depth ?? 0) * 18}px` }}
onClick={() => toggleOption(option.value)}
>
<span className={`${styles.check} ${selected ? styles.checkSelected : ''}`}>
{selected && <Check size={12} strokeWidth={3} />}
</span>
<span>{option.label}</span>
</button>
</li>
)
})
)}
</ul>
</div>
)}
</div>
)
}
@@ -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,74 @@
.stepList {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.stepOption {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 14px;
text-align: left;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.45);
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.stepColorDot {
width: 14px;
height: 14px;
flex-shrink: 0;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.12);
}
.stepOption:hover {
border-color: rgba(var(--primary-rgb) / 0.35);
background: rgba(var(--primary-rgb) / 0.05);
}
.stepOptionSelected {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.1);
}
.stepOptionSaving {
opacity: 0.7;
cursor: wait;
}
.stepOption:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.stepNumber {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.12);
border-radius: 50%;
}
.stepLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.emptyText {
font-size: 13px;
color: var(--text-muted);
text-align: center;
padding: 12px;
}
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import { updateOrder, type Order } from '../services/orderService'
import type { OrderProcessStep } from '../services/settingsService'
import { defaultStepColorForId, normalizeStepColor, stepBadgeStyle } from '../utils/stepColors'
import modalStyles from './VariationsModal.module.css'
import styles from './OrderStepModal.module.css'
interface OrderStepModalProps {
open: boolean
order: Order | null
steps: OrderProcessStep[]
onClose: () => void
onSaved?: (order: Order) => void
}
const ANIMATION_MS = 220
export function OrderStepModal({
open,
order,
steps,
onClose,
onSaved,
}: OrderStepModalProps) {
const { showToast } = useToast()
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [savingStepId, setSavingStepId] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
if (open && order) {
setMounted(true)
setClosing(false)
setError('')
setSavingStepId(null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, order])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !savingStepId) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose, savingStepId])
async function handleSelectStep(stepId: string) {
if (!order || savingStepId) return
if (stepId === order.processStepId) {
onClose()
return
}
setSavingStepId(stepId)
setError('')
try {
const result = await updateOrder(order.id, { processStepId: stepId })
showToast(`Order ${order.orderNumber} step updated.`, 'success')
onSaved?.(result.order)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update order step.')
} finally {
setSavingStepId(null)
}
}
if (!mounted || !order) return null
const currentLabel =
order.processStepLabel?.trim() ||
steps.find((step) => step.id === order.processStepId)?.label ||
order.processStepId
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={() => {
if (!savingStepId) onClose()
}}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="order-step-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="order-step-title" className={modalStyles.title}>
Change order step
</h2>
<p className={modalStyles.subtitle}>
{order.orderNumber} · Current: {currentLabel}
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={Boolean(savingStepId)}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error && (
<div className={modalStyles.errorText} role="alert">
{error}
</div>
)}
<div className={styles.stepList} role="listbox" aria-label="Order process step">
{steps.map((step, index) => {
const selected = order.processStepId === step.id
const saving = savingStepId === step.id
const color = normalizeStepColor(step.color, defaultStepColorForId(step.id, index))
return (
<button
key={step.id}
type="button"
role="option"
aria-selected={selected}
className={`${styles.stepOption} ${selected ? styles.stepOptionSelected : ''} ${saving ? styles.stepOptionSaving : ''}`}
style={selected ? stepBadgeStyle(color) : undefined}
disabled={Boolean(savingStepId)}
onClick={() => void handleSelectStep(step.id)}
>
<span className={styles.stepColorDot} style={{ backgroundColor: color }} />
<span className={styles.stepNumber}>{index + 1}</span>
<span className={styles.stepLabel}>
{saving ? 'Saving...' : step.label}
</span>
</button>
)
})}
</div>
{!steps.length && (
<p className={styles.emptyText}>
No order steps configured. Add steps in Store settings first.
</p>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,123 @@
.modalWide {
max-width: 720px;
}
.body {
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 16px;
}
.txList {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
overflow-y: auto;
}
.txCard {
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.55);
}
.txHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.txType {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.txAmount {
font-size: 14px;
font-weight: 700;
color: var(--primary);
white-space: nowrap;
}
.txMeta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
}
.txMetaItem strong {
color: var(--text-primary);
font-weight: 600;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
text-transform: capitalize;
}
.statusPending {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.statusCompleted {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.statusFailed {
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
}
.statusRefunded {
color: #6d28d9;
background: rgba(139, 92, 246, 0.12);
}
.empty {
font-size: 13px;
color: var(--text-muted);
padding: 12px 0;
}
.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);
}
@media (max-width: 640px) {
.txMeta {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice'
import modalStyles from './VariationsModal.module.css'
import styles from './OrderTransactionsModal.module.css'
interface OrderTransactionsModalProps {
open: boolean
order: Order | null
onClose: () => void
}
const ANIMATION_MS = 220
function transactionTypeLabel(type: TransactionType) {
switch (type) {
case 'pos':
return 'POS'
case 'cash':
return 'Cash'
case 'transfer':
return 'Transfer'
case 'e_payment_gate':
return 'E-payment gateway'
default:
return type
}
}
function statusClass(status: TransactionStatus) {
switch (status) {
case 'completed':
return styles.statusCompleted
case 'failed':
return styles.statusFailed
case 'refunded':
return styles.statusRefunded
case 'pending':
default:
return styles.statusPending
}
}
function formatDateTime(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function transactionDetails(transaction: OrderTransaction) {
const rows: { label: string; value: string }[] = [
{ label: 'Status', value: transaction.status },
{ label: 'Date', value: formatDateTime(transaction.createdAt) },
]
if (transaction.type === 'pos' && transaction.posType) {
rows.push({ label: 'POS type', value: transaction.posType })
}
if (transaction.type === 'transfer') {
if (transaction.transferAccount) {
rows.push({ label: 'Account number', value: transaction.transferAccount })
}
if (transaction.transferRefNumber) {
rows.push({ label: 'Ref code', value: transaction.transferRefNumber })
}
}
if (transaction.type === 'e_payment_gate' && transaction.gatewayType) {
rows.push({ label: 'Gateway', value: transaction.gatewayType })
}
if (transaction.notes) {
rows.push({ label: 'Notes', value: transaction.notes })
}
return rows
}
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const transactions = order?.transactions ?? []
const totalPaid = useMemo(
() => transactions.reduce((sum, tx) => sum + tx.amount, 0),
[transactions],
)
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
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-transactions-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="order-transactions-title" className={modalStyles.title}>
Transaction details
</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}`}>
{transactions.length === 0 ? (
<p className={styles.empty}>No payment transactions recorded for this order.</p>
) : (
<ul className={styles.txList}>
{transactions.map((transaction) => {
const details = transactionDetails(transaction)
return (
<li key={transaction.id} className={styles.txCard}>
<div className={styles.txHeader}>
<div className={styles.txType}>{transactionTypeLabel(transaction.type)}</div>
<div className={styles.txAmount}>{formatIrtPrice(transaction.amount)}</div>
</div>
<div className={styles.txMeta}>
{details.map((detail) => (
<div key={detail.label} className={styles.txMetaItem}>
{detail.label}:{' '}
{detail.label === 'Status' ? (
<span className={`${styles.statusBadge} ${statusClass(transaction.status)}`}>
{detail.value}
</span>
) : (
<strong>{detail.value}</strong>
)}
</div>
))}
</div>
</li>
)
})}
</ul>
)}
<div className={styles.summary}>
<span className={styles.summaryLabel}>
{transactions.length} {transactions.length === 1 ? 'transaction' : 'transactions'}
</span>
<span className={styles.summaryValue}>{formatIrtPrice(totalPaid)}</span>
</div>
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
Close
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
.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(6, 1fr);
gap: 20px;
}
.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: 1536px) {
.gridHome {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
}
}
@media (max-width: 1200px) {
.gridHome {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.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,20 @@
import { Outlet } from 'react-router-dom'
import { DraftCartProvider } from '../context/DraftCartContext'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import styles from './PageLayout.module.css'
export function PageLayout() {
return (
<DraftCartProvider>
<div className={styles.layout}>
<Sidebar />
<div className={styles.main}>
<Header />
<Outlet />
</div>
</div>
</DraftCartProvider>
)
}
@@ -0,0 +1,61 @@
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 32px;
padding-top: 24px;
}
.navBtn {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
.navBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.navBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pages {
display: flex;
align-items: center;
gap: 4px;
}
.pageBtn {
min-width: 38px;
height: 38px;
padding: 0 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.pageBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.pageBtn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
@@ -0,0 +1,53 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import styles from './Pagination.module.css'
interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
return (
<nav className={styles.pagination} aria-label="Product pages">
<button
type="button"
className={styles.navBtn}
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<ChevronLeft size={18} />
</button>
<div className={styles.pages}>
{pages.map((page) => (
<button
key={page}
type="button"
className={`${styles.pageBtn} ${page === currentPage ? styles.active : ''}`}
onClick={() => onPageChange(page)}
aria-label={`Page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
</div>
<button
type="button"
className={styles.navBtn}
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
<ChevronRight size={18} />
</button>
</nav>
)
}
@@ -0,0 +1,62 @@
.modalWide {
max-width: 560px;
}
.empty {
padding: 32px 12px;
text-align: center;
font-size: 13px;
color: var(--text-secondary);
}
.error {
margin-bottom: 12px;
padding: 10px 12px;
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);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
margin-top: 4px;
}
.cancelBtn,
.confirmBtn {
height: 38px;
padding: 0 18px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-sm);
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
.cancelBtn {
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.12);
}
.cancelBtn:hover:not(:disabled) {
background: rgba(148, 163, 184, 0.2);
}
.confirmBtn {
color: #fff;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
}
.confirmBtn:hover:not(:disabled) {
opacity: 0.92;
}
.cancelBtn:disabled,
.confirmBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllStoreItems } from '../services/storeItemService'
import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
import {
StoreItemMultiSearchSelect,
type StoreItemSearchOption,
} from './StoreItemMultiSearchSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreSpecialItemsModal.module.css'
interface PickStoreSpecialItemsModalProps {
open: boolean
specialTitle: string
existingStoreItemIds: string[]
onClose: () => void
onConfirm: (storeItemIds: string[]) => void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function PickStoreSpecialItemsModal({
open,
specialTitle,
existingStoreItemIds,
onClose,
onConfirm,
isSubmitting = false,
}: PickStoreSpecialItemsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const existingSet = useMemo(() => new Set(existingStoreItemIds), [existingStoreItemIds])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setSelectedIds([])
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadOptions(controller.signal)
return () => controller.abort()
}, [open, existingStoreItemIds])
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])
async function loadOptions(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listAllStoreItems(signal)
const listings = groupStoreItemsByProduct(items)
const nextOptions = listings
.map((listing) => {
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
return {
id: storeItemId,
title: listing.productTitle,
nameFa: listing.productNameFa,
image: listing.productImage,
}
})
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load store items.')
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(storeItemId: string) {
setSelectedIds((prev) =>
prev.includes(storeItemId)
? prev.filter((id) => id !== storeItemId)
: [...prev, storeItemId],
)
}
function handleConfirm() {
if (selectedIds.length === 0) return
onConfirm(selectedIds)
}
if (!mounted) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="pick-special-items-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="pick-special-items-title" className={modalStyles.title}>
Add to {specialTitle}
</h2>
<p className={modalStyles.subtitle}>Select store items to feature in this category.</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading store items...</p>
) : options.length === 0 ? (
<p className={styles.empty}>All store items are already in this category.</p>
) : (
<StoreItemMultiSearchSelect
options={options}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder="Type 3+ characters to search store items"
disabled={isSubmitting}
/>
)}
<div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0}
>
{isSubmitting ? 'Adding...' : `Add ${selectedIds.length || ''} item${selectedIds.length === 1 ? '' : 's'}`}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
.body {
padding-top: 8px;
}
.list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(255, 255, 255, 0.65);
text-align: left;
transition: border-color 0.2s, background 0.2s, transform 0.15s;
}
.option:hover {
border-color: rgba(var(--primary-rgb) / 0.45);
background: rgba(var(--primary-rgb) / 0.06);
transform: translateY(-1px);
}
.optionMain {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.optionLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.optionStock {
font-size: 11px;
color: var(--text-muted);
}
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { StoreItem } from '../services/storeItemService'
import { StoreItemPrice } from './StoreItemPrice'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreVariantModal.module.css'
interface PickStoreVariantModalProps {
open: boolean
productTitle: string
productNameFa: string
variants: StoreItem[]
onClose: () => void
onSelect: (variant: StoreItem) => void
}
const ANIMATION_MS = 220
export function PickStoreVariantModal({
open,
productTitle,
productNameFa,
variants,
onClose,
onSelect,
}: PickStoreVariantModalProps) {
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) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
>
<div
className={`${modalStyles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="pick-variant-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="pick-variant-title" className={modalStyles.title}>
Choose variant
</h2>
<p className={modalStyles.subtitle}>
{productTitle}
{productNameFa ? ` / ${productNameFa}` : ''}
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<div className={`${modalStyles.body} ${styles.body}`}>
<ul className={styles.list}>
{variants.map((variant) => (
<li key={variant.id}>
<button
type="button"
className={styles.option}
onClick={() => onSelect(variant)}
>
<div className={styles.optionMain}>
<span className={styles.optionLabel}>{variant.label}</span>
{variant.stockQuantity !== null && (
<span className={styles.optionStock}>
{variant.stockQuantity} in stock
</span>
)}
</div>
<StoreItemPrice
price={variant.price}
discountedPrice={variant.discountedPrice}
/>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
@@ -0,0 +1,174 @@
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllBrands } from '../services/brandService'
import {
StoreItemMultiSearchSelect,
type StoreItemSearchOption,
} from './StoreItemMultiSearchSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreSpecialItemsModal.module.css'
interface PickWebsiteBrandsModalProps {
open: boolean
groupTitle: string
existingBrandIds: string[]
onClose: () => void
onConfirm: (brandIds: string[]) => void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function PickWebsiteBrandsModal({
open,
groupTitle,
existingBrandIds,
onClose,
onConfirm,
isSubmitting = false,
}: PickWebsiteBrandsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const existingSet = useMemo(() => new Set(existingBrandIds), [existingBrandIds])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setSelectedIds([])
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadOptions(controller.signal)
return () => controller.abort()
}, [open, existingBrandIds])
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])
async function loadOptions(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listAllBrands(signal)
const nextOptions = items
.map((brand) => ({
id: brand.id,
title: brand.nameEn,
nameFa: brand.nameFa ?? '',
image: brand.imageUrl,
}))
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load brands.')
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(brandId: string) {
setSelectedIds((prev) =>
prev.includes(brandId) ? prev.filter((id) => id !== brandId) : [...prev, brandId],
)
}
function handleConfirm() {
if (selectedIds.length === 0) return
onConfirm(selectedIds)
}
if (!mounted) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="pick-brands-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="pick-brands-title" className={modalStyles.title}>
Add to {groupTitle}
</h2>
<p className={modalStyles.subtitle}>Select brands to feature in this group.</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading brands...</p>
) : options.length === 0 ? (
<p className={styles.empty}>All brands are already in this group.</p>
) : (
<StoreItemMultiSearchSelect
options={options}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder="Type 3+ characters to search brands"
disabled={isSubmitting}
/>
)}
<div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0}
>
{isSubmitting
? 'Adding...'
: `Add ${selectedIds.length || ''} brand${selectedIds.length === 1 ? '' : 's'}`}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,175 @@
import { useEffect, useMemo, useState } from 'react'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import {
StoreItemMultiSearchSelect,
type StoreItemSearchOption,
} from './StoreItemMultiSearchSelect'
import modalStyles from './VariationsModal.module.css'
import styles from './PickStoreSpecialItemsModal.module.css'
interface PickWebsiteCategoriesModalProps {
open: boolean
groupTitle: string
existingCategoryIds: string[]
onClose: () => void
onConfirm: (categoryIds: string[]) => void
isSubmitting?: boolean
}
const ANIMATION_MS = 220
export function PickWebsiteCategoriesModal({
open,
groupTitle,
existingCategoryIds,
onClose,
onConfirm,
isSubmitting = false,
}: PickWebsiteCategoriesModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const existingSet = useMemo(() => new Set(existingCategoryIds), [existingCategoryIds])
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setSelectedIds([])
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open) return
const controller = new AbortController()
void loadOptions(controller.signal)
return () => controller.abort()
}, [open, existingCategoryIds])
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])
async function loadOptions(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listProductCategories(signal)
const nextOptions = items
.map(mapProductCategoryToUi)
.map((category) => ({
id: category.id,
title: category.nameEn,
nameFa: category.nameFa,
image: null,
}))
.filter((option) => !existingSet.has(option.id))
setOptions(nextOptions)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load categories.')
}
} finally {
setIsLoading(false)
}
}
function toggleSelection(categoryId: string) {
setSelectedIds((prev) =>
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId],
)
}
function handleConfirm() {
if (selectedIds.length === 0) return
onConfirm(selectedIds)
}
if (!mounted) return null
return (
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="pick-categories-title"
>
<div className={modalStyles.header}>
<div>
<h2 id="pick-categories-title" className={modalStyles.title}>
Add to {groupTitle}
</h2>
<p className={modalStyles.subtitle}>Select product categories to feature in this group.</p>
</div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={modalStyles.body}>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading categories...</p>
) : options.length === 0 ? (
<p className={styles.empty}>All categories are already in this group.</p>
) : (
<StoreItemMultiSearchSelect
options={options}
selectedIds={selectedIds}
onToggle={toggleSelection}
placeholder="Type 3+ characters to search categories"
disabled={isSubmitting}
/>
)}
<div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button
type="button"
className={styles.confirmBtn}
onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0}
>
{isSubmitting
? 'Adding...'
: `Add ${selectedIds.length || ''} categor${selectedIds.length === 1 ? 'y' : 'ies'}`}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,97 @@
.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;
overflow: hidden;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 32px rgba(31, 38, 135, 0.1);
}
.clickable {
display: flex;
flex-direction: column;
flex: 1;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.clickable:hover .title {
color: var(--primary);
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
background: rgba(148, 163, 184, 0.08);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.imagePlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
rgba(148, 163, 184, 0.04) 100%
);
}
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px 16px;
flex: 1;
}
.title {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--text-primary);
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.abstract {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
margin: 0;
font-size: 12px;
line-height: 1.4;
color: var(--text-muted);
}
.metaDot {
margin: 0 6px;
}
@@ -0,0 +1,93 @@
import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2 } from 'lucide-react'
import type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService'
import { Tooltip } from './Tooltip'
import cardStyles from './PortfolioCard.module.css'
import controlStyles from './ProductCard.module.css'
interface PortfolioCardProps {
portfolio: Portfolio
commentCount: number
onEdit: (id: string) => void
onComments: (id: string) => void
onRemove: (id: string) => void
}
export function PortfolioCard({
portfolio,
commentCount,
onEdit,
onComments,
onRemove,
}: PortfolioCardProps) {
const publishDate = formatPortfolioCardDate(portfolio.publishedAt ?? portfolio.createdAt)
return (
<article className={cardStyles.card}>
<Link to={`/portfolios/detail/${portfolio.id}`} className={cardStyles.clickable}>
<div className={cardStyles.imageWrap}>
{portfolio.titleImageUrl ? (
<img
src={portfolio.titleImageUrl}
alt={portfolio.title}
className={cardStyles.image}
loading="lazy"
/>
) : (
<div className={cardStyles.imagePlaceholder} aria-hidden="true" />
)}
</div>
<div className={cardStyles.body}>
<h3 className={cardStyles.title}>{portfolio.title}</h3>
{portfolio.abstract ? (
<p className={cardStyles.abstract}>{portfolio.abstract}</p>
) : (
<p className={cardStyles.abstract}>No summary yet.</p>
)}
<p className={cardStyles.meta}>
{portfolio.categoryName ? (
<>
<span>{portfolio.categoryName}</span>
<span className={cardStyles.metaDot}>·</span>
</>
) : null}
<span>{publishDate}</span>
</p>
</div>
</Link>
<div className={controlStyles.controls}>
<Tooltip label="Edit portfolio">
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit">
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label="View comments">
<button
type="button"
className={controlStyles.iconBtn}
onClick={() => onComments(portfolio.id)}
aria-label={`Comments (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={controlStyles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label="Remove portfolio">
<button
type="button"
className={controlStyles.danger}
onClick={() => onRemove(portfolio.id)}
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,262 @@
import { useEffect, useState } from 'react'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listPortfolioComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './ProductCommentsModal.module.css'
interface PortfolioCommentsModalProps {
open: boolean
portfolioId: string
portfolioTitle: string
onClose: () => void
onCountChange?: (count: number) => void
}
const ANIMATION_MS = 220
export function PortfolioCommentsModal({
open,
portfolioId,
portfolioTitle,
onClose,
onCountChange,
}: PortfolioCommentsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCurrentPage(1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !portfolioId) return
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [open, portfolioId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listPortfolioComments(portfolioId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages)
}
}, [currentPage, totalPages])
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) return null
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="portfolio-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="portfolio-comments-title" className={styles.title}>
Comments
</h3>
{portfolioTitle && (
<p className={styles.subtitle}>
{portfolioTitle} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this portfolio item.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
import { useEffect, useState } from 'react'
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listPortfolioComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './BlogCommentsSection.module.css'
interface PortfolioCommentsSectionProps {
portfolioId: string
onCountChange?: (count: number) => void
}
export function PortfolioCommentsSection({
portfolioId,
onCountChange,
}: PortfolioCommentsSectionProps) {
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [portfolioId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listPortfolioComments(portfolioId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<section className={styles.section}>
<div className={styles.header}>
<h3 className={styles.title}>Comments</h3>
<span className={styles.count}>
{totalComments} {totalComments === 1 ? 'comment' : 'comments'}
</span>
</div>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this portfolio item.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>{formatCommentDate(comment.createdAt)}</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</section>
)
}
@@ -0,0 +1,133 @@
.card {
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;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.legendItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legendDot {
width: 10px;
height: 10px;
border-radius: 3px;
}
.legendAdded {
background: var(--primary);
}
.legendUpdated {
background: var(--primary-dark);
}
.status,
.error {
font-size: 14px;
text-align: center;
padding: 40px 12px;
}
.status {
color: var(--text-muted);
}
.error {
color: #dc2626;
}
.chartWrap {
overflow-x: auto;
}
.chart {
display: flex;
align-items: flex-end;
gap: 14px;
min-width: 100%;
padding-bottom: 4px;
}
.group {
flex: 1;
min-width: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bars {
display: flex;
align-items: flex-end;
height: 200px;
}
.bar {
width: 12px;
border-radius: 4px 4px 2px 2px;
transition: height 0.3s ease;
}
.barAdded {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary) 55%, #ffffff) 0%,
var(--primary) 100%
);
}
.barUpdated {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--primary-dark) 55%, #ffffff) 0%,
var(--primary-dark) 100%
);
}
.label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
}
@media (max-width: 768px) {
.header {
flex-direction: column;
}
}
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
import { aggregateProductActivity, type ProductMonthActivity } from '../utils/productActivity'
import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200
const BAR_GAP = 6
export function ProductActivityChart() {
const [data, setData] = useState<ProductMonthActivity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadActivity(controller.signal)
return () => controller.abort()
}, [])
async function loadActivity(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const products = await listAllProducts(signal)
setData(aggregateProductActivity(products))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product activity.')
}
} finally {
setIsLoading(false)
}
}
const maxValue = useMemo(() => {
const peak = Math.max(...data.flatMap((item) => [item.added, item.updated]), 0)
return peak > 0 ? peak : 1
}, [data])
const totals = useMemo(
() => ({
added: data.reduce((sum, item) => sum + item.added, 0),
updated: data.reduce((sum, item) => sum + item.updated, 0),
}),
[data],
)
return (
<section className={styles.card} aria-label="Product activity chart">
<div className={styles.header}>
<div>
<h3 className={styles.title}>Product activity</h3>
<p className={styles.subtitle}>Products added or updated in the last 12 months</p>
</div>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendAdded}`} />
Added ({totals.added})
</span>
<span className={styles.legendItem}>
<span className={`${styles.legendDot} ${styles.legendUpdated}`} />
Updated ({totals.updated})
</span>
</div>
</div>
{isLoading ? (
<p className={styles.status}>Loading chart...</p>
) : error ? (
<p className={styles.error} role="alert">
{error}
</p>
) : (
<div className={styles.chartWrap}>
<div
className={styles.chart}
style={{ height: CHART_HEIGHT + 32 }}
role="img"
aria-label="Bar chart of products added and updated per month"
>
{data.map((item) => {
const addedHeight = (item.added / maxValue) * CHART_HEIGHT
const updatedHeight = (item.updated / maxValue) * CHART_HEIGHT
return (
<div key={item.monthKey} className={styles.group}>
<div className={styles.bars} style={{ gap: BAR_GAP }}>
<div
className={`${styles.bar} ${styles.barAdded}`}
style={{ height: Math.max(addedHeight, item.added > 0 ? 4 : 0) }}
title={`${item.label}: ${item.added} added`}
/>
<div
className={`${styles.bar} ${styles.barUpdated}`}
style={{ height: Math.max(updatedHeight, item.updated > 0 ? 4 : 0) }}
title={`${item.label}: ${item.updated} updated`}
/>
</div>
<span className={styles.label}>{item.label}</span>
</div>
)
})}
</div>
</div>
)}
</section>
)
}
@@ -0,0 +1,164 @@
.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);
}
.clickable {
display: flex;
flex-direction: column;
flex: 1;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.clickable:hover .nameEn {
color: var(--primary);
}
.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;
}
.badge {
position: absolute;
top: 8px;
left: 8px;
padding: 3px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #b45309;
background: rgba(251, 191, 36, 0.9);
border-radius: 50px;
}
.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;
transition: color 0.2s;
}
.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: 6px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.categoryChip {
display: inline-block;
padding: 3px 10px;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
}
.controls {
display: flex;
align-items: center;
justify-content: space-around;
padding: 8px 6px 10px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
overflow: visible;
}
.controls button {
position: relative;
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;
}
.iconBtn {
position: relative;
}
.variantBadge,
.commentBadge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
font-size: 9px;
font-weight: 700;
color: white;
border-radius: 50px;
line-height: 1;
pointer-events: none;
}
.variantBadge {
background: var(--primary);
}
.commentBadge {
background: #ef4444;
}
.controls button:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.controls button.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
@@ -0,0 +1,102 @@
import { Link } from 'react-router-dom'
import { Pencil, Info, Trash2, Layers, MessageSquare, ClipboardList } from 'lucide-react'
import type { Product } from '../types/product'
import { Tooltip } from './Tooltip'
import styles from './ProductCard.module.css'
interface ProductCardProps {
product: Product
commentCount: number
variantCount: number
onEdit: (id: string) => void
onQuickInfo: (id: string) => void
onComments: (id: string) => void
onTechnicalInfo: (id: string) => void
onRemove: (id: string) => void
onVariations: (id: string) => void
}
export function ProductCard({
product,
commentCount,
variantCount,
onEdit,
onQuickInfo,
onComments,
onTechnicalInfo,
onRemove,
onVariations,
}: ProductCardProps) {
return (
<article className={styles.card}>
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
<div className={styles.imageWrap}>
<img src={product.image} alt={product.nameEn} className={styles.image} loading="lazy" />
{product.status === 'draft' && <span className={styles.badge}>Draft</span>}
</div>
<div className={styles.body}>
<h3 className={styles.nameEn}>{product.nameEn}</h3>
<p className={styles.nameFa}>{product.nameFa}</p>
<span className={styles.categoryChip}>{product.category}</span>
</div>
</Link>
<div className={styles.controls}>
<Tooltip label="Edit product">
<button type="button" onClick={() => onEdit(product.id)} aria-label="Edit">
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label="Quick info">
<button type="button" onClick={() => onQuickInfo(product.id)} aria-label="Quick info">
<Info size={16} />
</button>
</Tooltip>
<Tooltip label="Manage variations">
<button
type="button"
className={styles.iconBtn}
onClick={() => onVariations(product.id)}
aria-label={`Variations (${variantCount})`}
>
<Layers size={16} />
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
</button>
</Tooltip>
<Tooltip label="View comments">
<button
type="button"
className={styles.iconBtn}
onClick={() => onComments(product.id)}
aria-label={`Comments (${commentCount})`}
>
<MessageSquare size={16} />
{commentCount > 0 && (
<span className={styles.commentBadge}>{commentCount}</span>
)}
</button>
</Tooltip>
<Tooltip label="Technical data">
<button
type="button"
onClick={() => onTechnicalInfo(product.id)}
aria-label="Technical data"
>
<ClipboardList size={16} />
</button>
</Tooltip>
<Tooltip label="Remove product">
<button
type="button"
className={styles.danger}
onClick={() => onRemove(product.id)}
aria-label="Remove"
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
)
}
@@ -0,0 +1,219 @@
.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;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modal {
width: 100%;
max-width: 640px;
max-height: 90vh;
display: flex;
flex-direction: column;
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;
flex-shrink: 0;
}
.title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.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: 16px 24px 24px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.emptyText {
font-size: 14px;
color: var(--text-muted);
text-align: center;
padding: 32px 12px;
}
.errorText {
font-size: 13px;
color: #dc2626;
margin-bottom: 12px;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
.commentApproved {
border-color: rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.04);
}
.commentHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.commentMeta {
min-width: 0;
}
.author {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.dateTime {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.likes {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
padding: 4px 10px;
background: rgba(148, 163, 184, 0.12);
border-radius: 50px;
}
.text {
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
margin-bottom: 12px;
}
.commentActions {
display: flex;
align-items: center;
gap: 8px;
}
.approveBtn,
.rejectBtn,
.removeBtn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
transition: background 0.2s, color 0.2s;
}
.approveBtn {
color: #16a34a;
background: rgba(34, 197, 94, 0.1);
}
.approveBtn:hover {
background: rgba(34, 197, 94, 0.18);
}
.rejectBtn {
color: #d97706;
background: rgba(245, 158, 11, 0.1);
}
.rejectBtn:hover {
background: rgba(245, 158, 11, 0.18);
}
.removeBtn {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.removeBtn:hover {
background: rgba(239, 68, 68, 0.15);
}
.paginationWrap {
margin-top: 16px;
padding-top: 8px;
}
.paginationWrap nav {
margin-top: 0;
padding-top: 0;
}
@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,262 @@
import { useEffect, useState } from 'react'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
COMMENTS_PER_PAGE,
deleteComment,
listProductComments,
updateCommentApproval,
} from '../services/commentService'
import { Pagination } from './Pagination'
import styles from './ProductCommentsModal.module.css'
interface ProductCommentsModalProps {
open: boolean
productId: string
productName: string
onClose: () => void
onCountChange?: (count: number) => void
}
const ANIMATION_MS = 220
export function ProductCommentsModal({
open,
productId,
productName,
onClose,
onCountChange,
}: ProductCommentsModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalComments / COMMENTS_PER_PAGE))
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setCurrentPage(1)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted])
useEffect(() => {
if (!open || !productId) return
const controller = new AbortController()
void loadComments(currentPage, controller.signal)
return () => controller.abort()
}, [open, productId, currentPage])
async function loadComments(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listProductComments(productId, page, COMMENTS_PER_PAGE, signal)
setComments(data.items)
setTotalComments(data.total)
onCountChange?.(data.total)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load comments.')
}
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages)
}
}, [currentPage, totalPages])
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) return null
async function toggleApproval(comment: ProductComment) {
setActionId(comment.id)
setError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((c) => (c.id === comment.id ? updated : c)))
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setError('')
try {
await deleteComment(commentId)
const nextTotal = totalComments - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / COMMENTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setTotalComments(nextTotal)
onCountChange?.(nextTotal)
if (nextPage !== currentPage) {
setCurrentPage(nextPage)
} else {
setComments((prev) => prev.filter((c) => c.id !== commentId))
}
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
return (
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="product-comments-title"
>
<div className={styles.header}>
<div>
<h3 id="product-comments-title" className={styles.title}>
Comments
</h3>
{productName && (
<p className={styles.subtitle}>
{productName} · {totalComments}{' '}
{totalComments === 1 ? 'comment' : 'comments'}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className={styles.body}>
{error && (
<p className={styles.errorText} role="alert">
{error}
</p>
)}
{isLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : totalComments === 0 ? (
<p className={styles.emptyText}>No comments yet for this product.</p>
) : (
<>
<div className={styles.list}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)}
</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.text}>{comment.text}</p>
<div className={styles.commentActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
{totalComments > COMMENTS_PER_PAGE && (
<div className={styles.paginationWrap}>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,289 @@
.tabsSection {
margin-top: 20px;
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);
overflow: hidden;
}
.tabList {
display: flex;
gap: 4px;
padding: 8px 8px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
}
.tab:hover {
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.06);
}
.tabActive {
color: var(--primary);
background: rgba(255, 255, 255, 0.75);
box-shadow: inset 0 -2px 0 var(--primary);
}
.tabBadge {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
font-size: 10px;
font-weight: 700;
color: white;
background: var(--primary);
border-radius: 50px;
}
.tabPanel {
padding: 20px 24px 24px;
min-height: 160px;
}
.emptyText,
.hintText,
.errorText {
font-size: 14px;
color: var(--text-muted);
}
.errorText {
color: #b91c1c;
}
.hintText {
margin-top: 12px;
font-size: 13px;
}
.techList {
display: flex;
flex-direction: column;
gap: 0;
}
.techRow {
display: grid;
grid-template-columns: minmax(120px, 220px) 1fr;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
}
.techRow:last-child {
border-bottom: none;
padding-bottom: 0;
}
.techRow dt {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.techRow dd {
font-size: 14px;
color: var(--text-primary);
line-height: 1.5;
margin: 0;
}
.commentList,
.reviewList {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment,
.review {
padding: 14px 16px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: var(--radius-sm);
}
.commentApproved {
border-color: rgba(34, 197, 94, 0.25);
background: rgba(34, 197, 94, 0.04);
}
.commentHeader,
.reviewHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.author,
.reviewTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.likes,
.rating {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.rating {
color: #d97706;
}
.commentText,
.reviewSummary {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
}
.pendingBadge {
display: inline-block;
margin-top: 8px;
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
color: #b45309;
background: rgba(251, 191, 36, 0.15);
border-radius: 50px;
}
.reviewApproved {
border-color: rgba(34, 197, 94, 0.25);
background: rgba(34, 197, 94, 0.04);
}
.itemActions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.approveBtn,
.rejectBtn,
.removeBtn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
transition: background 0.2s, color 0.2s;
}
.approveBtn {
color: #16a34a;
background: rgba(34, 197, 94, 0.1);
}
.approveBtn:hover {
background: rgba(34, 197, 94, 0.18);
}
.rejectBtn {
color: #d97706;
background: rgba(245, 158, 11, 0.1);
}
.rejectBtn:hover {
background: rgba(245, 158, 11, 0.18);
}
.removeBtn {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.removeBtn:hover {
background: rgba(239, 68, 68, 0.15);
}
.pointsGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.pointsBlock {
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.08);
}
.pointsHeading {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pointsList {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.pointsList li + li {
margin-top: 4px;
}
.pointsListNegative {
color: #9a3412;
}
@media (max-width: 640px) {
.pointsGrid {
grid-template-columns: 1fr;
}
.techRow {
grid-template-columns: 1fr;
gap: 4px;
}
.tabPanel {
padding: 16px;
}
}
@@ -0,0 +1,446 @@
import { useEffect, useState } from 'react'
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api'
import {
deleteComment,
listProductComments,
updateCommentApproval,
} from '../services/commentService'
import {
deleteExpertReview,
formatReviewDate,
listProductExpertReviews,
updateExpertReviewApproval,
} from '../services/expertReviewService'
import { getProductTechnicalInfo } from '../services/productTechnicalInfoService'
import type { ProductComment } from '../types/comment'
import type { ProductExpertReview } from '../types/expertReview'
import type { ProductTechnicalValue } from '../types/technicalForm'
import styles from './ProductDetailsTabs.module.css'
type DetailTab = 'technical' | 'comments' | 'reviews'
interface ProductDetailsTabsProps {
productId: string
commentCount: number
}
function formatTechnicalValue(item: ProductTechnicalValue): string {
if (item.type === 'multi_select') {
const labels = item.optionLabels ?? (Array.isArray(item.value) ? item.value : [])
return labels.length ? labels.join(', ') : '—'
}
if (item.type === 'select') {
return item.optionLabel ?? (item.value ? String(item.value) : '—')
}
return item.value ? String(item.value) : '—'
}
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
const [activeTab, setActiveTab] = useState<DetailTab>('technical')
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
const [technicalMessage, setTechnicalMessage] = useState('')
const [technicalLoading, setTechnicalLoading] = useState(false)
const [technicalError, setTechnicalError] = useState('')
const [comments, setComments] = useState<ProductComment[]>([])
const [commentsLoading, setCommentsLoading] = useState(false)
const [commentsError, setCommentsError] = useState('')
const [reviews, setReviews] = useState<ProductExpertReview[]>([])
const [reviewsLoading, setReviewsLoading] = useState(false)
const [reviewsError, setReviewsError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
useEffect(() => {
if (activeTab !== 'technical') return
const controller = new AbortController()
void loadTechnicalInfo(controller.signal)
return () => controller.abort()
}, [activeTab, productId])
useEffect(() => {
if (activeTab !== 'comments') return
const controller = new AbortController()
void loadComments(controller.signal)
return () => controller.abort()
}, [activeTab, productId])
useEffect(() => {
if (activeTab !== 'reviews') return
const controller = new AbortController()
void loadReviews(controller.signal)
return () => controller.abort()
}, [activeTab, productId])
async function loadComments(signal?: AbortSignal) {
setCommentsLoading(true)
setCommentsError('')
try {
const data = await listProductComments(productId, 1, 100, signal)
setComments(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to load comments.')
}
} finally {
setCommentsLoading(false)
}
}
async function loadReviews(signal?: AbortSignal) {
setReviewsLoading(true)
setReviewsError('')
try {
const data = await listProductExpertReviews(productId, 1, 100, signal)
setReviews(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to load expert reviews.')
}
} finally {
setReviewsLoading(false)
}
}
async function loadTechnicalInfo(signal?: AbortSignal) {
setTechnicalLoading(true)
setTechnicalError('')
setTechnicalMessage('')
try {
const data = await getProductTechnicalInfo(productId, signal)
setTechnicalValues(data.values)
if (data.message) {
setTechnicalMessage(data.message)
} else if (!data.form) {
setTechnicalMessage('No technical form is defined for this product category.')
} else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) {
setTechnicalMessage('No technical data has been added for this product yet.')
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setTechnicalError(err.message)
} else {
setTechnicalError('Unable to load technical info.')
}
} finally {
setTechnicalLoading(false)
}
}
async function toggleCommentApproval(comment: ProductComment) {
setActionId(comment.id)
setCommentsError('')
try {
const updated = await updateCommentApproval(comment.id, !comment.approved)
setComments((prev) => prev.map((item) => (item.id === comment.id ? updated : item)))
} catch (err) {
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to update comment approval.')
}
} finally {
setActionId(null)
}
}
async function removeComment(commentId: string) {
setActionId(commentId)
setCommentsError('')
try {
await deleteComment(commentId)
setComments((prev) => prev.filter((item) => item.id !== commentId))
} catch (err) {
if (err instanceof ApiError) {
setCommentsError(err.message)
} else {
setCommentsError('Unable to delete comment.')
}
} finally {
setActionId(null)
}
}
async function toggleReviewApproval(review: ProductExpertReview) {
setActionId(review.id)
setReviewsError('')
try {
const updated = await updateExpertReviewApproval(review.id, !review.approved)
setReviews((prev) => prev.map((item) => (item.id === review.id ? updated : item)))
} catch (err) {
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to update expert review approval.')
}
} finally {
setActionId(null)
}
}
async function removeReview(reviewId: string) {
setActionId(reviewId)
setReviewsError('')
try {
await deleteExpertReview(reviewId)
setReviews((prev) => prev.filter((item) => item.id !== reviewId))
} catch (err) {
if (err instanceof ApiError) {
setReviewsError(err.message)
} else {
setReviewsError('Unable to delete expert review.')
}
} finally {
setActionId(null)
}
}
const displayedCommentCount = comments.length > 0 ? comments.length : commentCount
return (
<section className={styles.tabsSection}>
<div className={styles.tabList} role="tablist" aria-label="Product details">
<button
type="button"
role="tab"
aria-selected={activeTab === 'technical'}
className={`${styles.tab} ${activeTab === 'technical' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('technical')}
>
Technical Info
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'comments'}
className={`${styles.tab} ${activeTab === 'comments' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('comments')}
>
Comments
{displayedCommentCount > 0 && (
<span className={styles.tabBadge}>{displayedCommentCount}</span>
)}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'reviews'}
className={`${styles.tab} ${activeTab === 'reviews' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('reviews')}
>
Expert Reviews
{reviews.length > 0 && (
<span className={styles.tabBadge}>{reviews.length}</span>
)}
</button>
</div>
<div className={styles.tabPanel} role="tabpanel">
{activeTab === 'technical' && (
<>
{technicalLoading ? (
<p className={styles.emptyText}>Loading technical info...</p>
) : technicalError ? (
<p className={styles.errorText}>{technicalError}</p>
) : technicalMessage && technicalValues.length === 0 ? (
<p className={styles.emptyText}>{technicalMessage}</p>
) : technicalValues.length === 0 ? (
<p className={styles.emptyText}>No technical data available.</p>
) : (
<dl className={styles.techList}>
{technicalValues.map((item) => (
<div key={item.fieldKey} className={styles.techRow}>
<dt>{item.fieldLabel}</dt>
<dd>{formatTechnicalValue(item)}</dd>
</div>
))}
</dl>
)}
{technicalMessage && technicalValues.length > 0 && (
<p className={styles.hintText}>{technicalMessage}</p>
)}
</>
)}
{activeTab === 'comments' && (
<>
{commentsError && <p className={styles.errorText}>{commentsError}</p>}
{commentsLoading ? (
<p className={styles.emptyText}>Loading comments...</p>
) : comments.length === 0 ? (
<p className={styles.emptyText}>No comments yet for this product.</p>
) : (
<div className={styles.commentList}>
{comments.map((comment) => (
<article
key={comment.id}
className={`${styles.comment} ${comment.approved ? styles.commentApproved : ''}`}
>
<div className={styles.commentHeader}>
<div>
<div className={styles.author}>{comment.author}</div>
<div className={styles.meta}>{formatCommentDate(comment.createdAt)}</div>
</div>
<span className={styles.likes}>
<ThumbsUp size={13} />
{comment.likesCount}
</span>
</div>
<p className={styles.commentText}>{comment.text}</p>
{!comment.approved && (
<span className={styles.pendingBadge}>Pending approval</span>
)}
<div className={styles.itemActions}>
{comment.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === comment.id}
onClick={() => void toggleCommentApproval(comment)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === comment.id}
onClick={() => void toggleCommentApproval(comment)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === comment.id}
onClick={() => void removeComment(comment.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
)}
</>
)}
{activeTab === 'reviews' && (
<>
{reviewsError && <p className={styles.errorText}>{reviewsError}</p>}
{reviewsLoading ? (
<p className={styles.emptyText}>Loading expert reviews...</p>
) : reviews.length === 0 ? (
<p className={styles.emptyText}>No expert reviews yet.</p>
) : (
<div className={styles.reviewList}>
{reviews.map((review) => (
<article
key={review.id}
className={`${styles.review} ${review.approved ? styles.reviewApproved : ''}`}
>
<div className={styles.reviewHeader}>
<div>
<div className={styles.reviewTitle}>{review.authorName}</div>
<div className={styles.meta}>{formatReviewDate(review.createdAt)}</div>
</div>
<div className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}>
<Star size={14} fill="currentColor" />
{review.rate}/10
</div>
</div>
{(review.positivePoints.length > 0 || review.negativePoints.length > 0) && (
<div className={styles.pointsGrid}>
{review.positivePoints.length > 0 && (
<div className={styles.pointsBlock}>
<h4 className={styles.pointsHeading}>Positive points</h4>
<ul className={styles.pointsList}>
{review.positivePoints.map((point) => (
<li key={point}>{point}</li>
))}
</ul>
</div>
)}
{review.negativePoints.length > 0 && (
<div className={styles.pointsBlock}>
<h4 className={styles.pointsHeading}>Negative points</h4>
<ul className={`${styles.pointsList} ${styles.pointsListNegative}`}>
{review.negativePoints.map((point) => (
<li key={point}>{point}</li>
))}
</ul>
</div>
)}
</div>
)}
<p className={styles.reviewSummary}>{review.text}</p>
{!review.approved && (
<span className={styles.pendingBadge}>Pending approval</span>
)}
<div className={styles.itemActions}>
{review.approved ? (
<button
type="button"
className={styles.rejectBtn}
disabled={actionId === review.id}
onClick={() => void toggleReviewApproval(review)}
>
<XCircle size={14} />
Reject
</button>
) : (
<button
type="button"
className={styles.approveBtn}
disabled={actionId === review.id}
onClick={() => void toggleReviewApproval(review)}
>
<Check size={14} />
Approve
</button>
)}
<button
type="button"
className={styles.removeBtn}
disabled={actionId === review.id}
onClick={() => void removeReview(review.id)}
>
<Trash2 size={14} />
Remove
</button>
</div>
</article>
))}
</div>
)}
</>
)}
</div>
</section>
)
}
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import styles from './SearchableSelect.module.css'
export interface ProductSearchOption {
id: string
title: string
nameFa: string
}
interface ProductSearchSelectProps {
options: ProductSearchOption[]
value: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
id?: string
minSearchLength?: number
}
export function ProductSearchSelect({
options,
value,
onChange,
placeholder = 'Search products...',
disabled = false,
id,
minSearchLength = 3,
}: ProductSearchSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const selected = options.find((option) => option.id === value)
const filtered = useMemo(() => {
const trimmed = query.trim()
if (trimmed.length < minSearchLength) return []
const q = trimmed.toLowerCase()
return options.filter(
(option) =>
option.title.toLowerCase().includes(q) ||
option.nameFa.includes(trimmed),
)
}, [options, query, minSearchLength])
useEffect(() => {
if (!open) return
const onClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
function selectOption(id: string) {
onChange(id)
setOpen(false)
setQuery('')
}
function handleFocus() {
if (!disabled) setOpen(true)
}
const showDropdown = open && query.trim().length >= minSearchLength
return (
<div className={styles.wrapper} ref={containerRef}>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} />
<input
ref={inputRef}
id={id}
type="text"
className={styles.input}
placeholder={
selected
? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}`
: placeholder
}
value={open ? query : selected ? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}` : ''}
onChange={(e) => {
setQuery(e.target.value)
if (!open) setOpen(true)
if (value) onChange('')
}}
onFocus={handleFocus}
disabled={disabled}
/>
{value && !open && (
<button
type="button"
className={styles.clearBtn}
onClick={() => onChange('')}
aria-label="Clear selection"
>
<X size={14} />
</button>
)}
<ChevronDown size={16} className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} />
</div>
{showDropdown && (
<ul className={styles.dropdown} role="listbox">
{filtered.length === 0 ? (
<li className={styles.noResults}>
{options.length === 0 ? 'No products available.' : 'No matching products.'}
</li>
) : (
filtered.map((option) => (
<li key={option.id}>
<button
type="button"
role="option"
aria-selected={option.id === value}
className={`${styles.option} ${option.id === value ? styles.optionSelected : ''}`}
onClick={() => selectOption(option.id)}
>
<span className={styles.optionEn}>{option.title}</span>
{option.nameFa && (
<>
<span className={styles.optionSep}>/</span>
<span className={styles.optionFa}>{option.nameFa}</span>
</>
)}
</button>
</li>
))
)}
</ul>
)}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={styles.dropdown}>
<li className={styles.noResults}>Type at least {minSearchLength} characters to search</li>
</ul>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More