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>
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.certs/
|
||||||
|
*.log
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.cursor/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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 |
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||