mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getProductTechnicalInfo,
|
||||
saveProductTechnicalInfo,
|
||||
valuesToFormState,
|
||||
} from '../services/productTechnicalInfoService'
|
||||
import type {
|
||||
CategoryTechnicalForm,
|
||||
ProductTechnicalFormValues,
|
||||
TechnicalFieldType,
|
||||
} from '../types/technicalForm'
|
||||
import styles from './VariationsModal.module.css'
|
||||
import fieldStyles from './TechnicalFormModal.module.css'
|
||||
|
||||
interface ProductTechnicalInfoModalProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
productName: string
|
||||
categoryId: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export function ProductTechnicalInfoModal({
|
||||
open,
|
||||
productId,
|
||||
productName,
|
||||
categoryId,
|
||||
onClose,
|
||||
}: ProductTechnicalInfoModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [form, setForm] = useState<CategoryTechnicalForm | null>(null)
|
||||
const [values, setValues] = useState<ProductTechnicalFormValues>({})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [infoMessage, setInfoMessage] = useState('')
|
||||
|
||||
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 (!open || !productId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
void loadData(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [open, productId])
|
||||
|
||||
async function loadData(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setInfoMessage('')
|
||||
|
||||
try {
|
||||
const data = await getProductTechnicalInfo(productId, signal)
|
||||
setForm(data.form)
|
||||
setValues(valuesToFormState(data.form, data.values))
|
||||
if (data.message) {
|
||||
setInfoMessage(data.message)
|
||||
} else if (!categoryId) {
|
||||
setInfoMessage('Assign a category to this product before adding technical data.')
|
||||
} else if (!data.form) {
|
||||
setInfoMessage('No technical form is defined for this product category.')
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load technical data.')
|
||||
}
|
||||
} 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) return null
|
||||
|
||||
function setFieldValue(fieldKey: string, value: string | string[]) {
|
||||
setValues((prev) => ({ ...prev, [fieldKey]: value }))
|
||||
}
|
||||
|
||||
function toggleMultiOption(fieldKey: string, optionValue: string) {
|
||||
setValues((prev) => {
|
||||
const current = prev[fieldKey]
|
||||
const selected = Array.isArray(current) ? current : []
|
||||
const next = selected.includes(optionValue)
|
||||
? selected.filter((item) => item !== optionValue)
|
||||
: [...selected, optionValue]
|
||||
return { ...prev, [fieldKey]: next }
|
||||
})
|
||||
}
|
||||
|
||||
function isFormValid() {
|
||||
if (!form) return false
|
||||
|
||||
return form.fields.every((field) => {
|
||||
if (!field.isRequired) return true
|
||||
const value = values[field.key]
|
||||
if (field.type === 'multi_select') {
|
||||
return Array.isArray(value) && value.length > 0
|
||||
}
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await saveProductTechnicalInfo(productId, values)
|
||||
setForm(data.form)
|
||||
setValues(valuesToFormState(data.form, data.values))
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save technical data.')
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(field: CategoryTechnicalForm['fields'][number]) {
|
||||
const value = values[field.key]
|
||||
const type = field.type as TechnicalFieldType
|
||||
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
id={`tech-${field.key}`}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setFieldValue(field.key, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<select
|
||||
id={`tech-${field.key}`}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setFieldValue(field.key, e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.id} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'multi_select') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div className={styles.chipGrid}>
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`${styles.chip} ${selected.includes(option.value) ? styles.chipSelected : ''}`}
|
||||
onClick={() => toggleMultiOption(field.key, option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
id={`tech-${field.key}`}
|
||||
type="text"
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setFieldValue(field.key, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`${styles.modal} ${fieldStyles.modalWide} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="product-technical-title"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 id="product-technical-title" className={styles.title}>
|
||||
Technical Data
|
||||
</h3>
|
||||
{productName && <p className={styles.subtitle}>{productName}</p>}
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{isLoading ? (
|
||||
<p className={styles.emptyText}>Loading technical data...</p>
|
||||
) : infoMessage && !form ? (
|
||||
<p className={styles.emptyText}>{infoMessage}</p>
|
||||
) : form && form.fields.length === 0 ? (
|
||||
<p className={styles.emptyText}>This category form has no fields yet.</p>
|
||||
) : (
|
||||
form?.fields.map((field) => (
|
||||
<div key={field.id} className={styles.field}>
|
||||
<label htmlFor={`tech-${field.key}`}>
|
||||
{field.label}
|
||||
{field.isRequired && ' *'}
|
||||
</label>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!form || !isFormValid() || isSaving || isLoading}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
countSelectedOptions,
|
||||
getProductVariationValues,
|
||||
saveProductVariationValues,
|
||||
type ProductVariationSelection,
|
||||
} from '../services/productVariationService'
|
||||
import { MultiSelectDropdown } from './MultiSelectDropdown'
|
||||
import styles from './VariationsModal.module.css'
|
||||
|
||||
interface ProductVariantsModalProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
categoryId: string
|
||||
productName: string
|
||||
onClose: () => void
|
||||
onVariantsChange?: (count: number) => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export function ProductVariantsModal({
|
||||
open,
|
||||
productId,
|
||||
categoryId,
|
||||
productName,
|
||||
onClose,
|
||||
onVariantsChange,
|
||||
}: ProductVariantsModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
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 (!open || !productId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
void loadData(controller.signal)
|
||||
return () => controller.abort()
|
||||
}, [open, productId, categoryId])
|
||||
|
||||
async function loadData(signal?: AbortSignal) {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await getProductVariationValues(productId, signal)
|
||||
setVariations(data.variations)
|
||||
onVariantsChange?.(countSelectedOptions(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) return null
|
||||
|
||||
function handleSelectionChange(variationId: string, optionIds: string[]) {
|
||||
setVariations((prev) =>
|
||||
prev.map((variation) =>
|
||||
variation.id === variationId
|
||||
? { ...variation, selectedOptionIds: optionIds }
|
||||
: variation,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const data = await saveProductVariationValues(productId, variations)
|
||||
setVariations(data.variations)
|
||||
onVariantsChange?.(countSelectedOptions(data.variations))
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save product variations.')
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
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-variants-title"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 id="product-variants-title" className={styles.title}>
|
||||
Product Variations
|
||||
</h3>
|
||||
{productName && <p className={styles.subtitle}>{productName}</p>}
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{!categoryId && (
|
||||
<p className={styles.errorText}>
|
||||
Assign a category to this product before managing variations.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.errorText}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.emptyText}>Loading variations...</p>
|
||||
) : variations.length === 0 ? (
|
||||
<p className={styles.emptyText}>
|
||||
{categoryId
|
||||
? 'This category has no variations. Add variations on the category first.'
|
||||
: 'No variations available.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className={styles.variationFields}>
|
||||
{variations.map((variation) => (
|
||||
<div key={variation.id} className={styles.field}>
|
||||
<label htmlFor={`variation-${variation.id}`}>{variation.name}</label>
|
||||
<MultiSelectDropdown
|
||||
id={`variation-${variation.id}`}
|
||||
options={variation.options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
}))}
|
||||
value={variation.selectedOptionIds}
|
||||
onChange={(optionIds) => handleSelectionChange(variation.id, optionIds)}
|
||||
placeholder={`Select ${variation.name.toLowerCase()} values`}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.submitBtn}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!categoryId || isLoading || isSaving || variations.length === 0}
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save variations'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 ProtectedRoute() {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loaderWrap}>
|
||||
<div className={styles.loader} aria-label="Loading" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.wrapper {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.editorShell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 140px;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor ul,
|
||||
.editor ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.editor p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0.5em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor img.richTextImageSelected {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid white;
|
||||
border-radius: 3px;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.uploadError {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { Bold, Italic, Underline, List, ListOrdered, ImagePlus } from 'lucide-react'
|
||||
import { uploadMediaFiles } from '../services/mediaService'
|
||||
import styles from './RichTextEditor.module.css'
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
allowImages?: boolean
|
||||
editorMinHeight?: number
|
||||
}
|
||||
|
||||
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowImages = false,
|
||||
editorMinHeight = 140,
|
||||
}: RichTextEditorProps) {
|
||||
const editorShellRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
|
||||
null,
|
||||
)
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const [selectedImageEl, setSelectedImageEl] = useState<HTMLImageElement | null>(null)
|
||||
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
const syncChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
onChange(editorRef.current.innerHTML)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
|
||||
if (!img || !editorShellRef.current) {
|
||||
setHandlePos(null)
|
||||
return
|
||||
}
|
||||
|
||||
const shellRect = editorShellRef.current.getBoundingClientRect()
|
||||
const imgRect = img.getBoundingClientRect()
|
||||
setHandlePos({
|
||||
top: imgRect.bottom - shellRect.top - 6,
|
||||
left: imgRect.right - shellRect.left - 6,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearImageSelection = useCallback(() => {
|
||||
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
|
||||
img.classList.remove(SELECTED_IMAGE_CLASS)
|
||||
})
|
||||
setSelectedImageEl(null)
|
||||
setHandlePos(null)
|
||||
}, [])
|
||||
|
||||
const selectImage = useCallback(
|
||||
(img: HTMLImageElement) => {
|
||||
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
|
||||
node.classList.remove(SELECTED_IMAGE_CLASS)
|
||||
})
|
||||
img.classList.add(SELECTED_IMAGE_CLASS)
|
||||
setSelectedImageEl(img)
|
||||
updateHandlePosition(img)
|
||||
},
|
||||
[updateHandlePosition],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== value) {
|
||||
editorRef.current.innerHTML = value
|
||||
clearImageSelection()
|
||||
}
|
||||
}, [value, clearImageSelection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowImages) return
|
||||
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
|
||||
function onEditorClick(e: MouseEvent) {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
|
||||
|
||||
e.preventDefault()
|
||||
selectImage(target)
|
||||
}
|
||||
|
||||
function onDocumentClick(e: MouseEvent) {
|
||||
const target = e.target as Node
|
||||
if (editor!.contains(target)) return
|
||||
if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
|
||||
clearImageSelection()
|
||||
}
|
||||
|
||||
editor.addEventListener('click', onEditorClick)
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
return () => {
|
||||
editor.removeEventListener('click', onEditorClick)
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
}
|
||||
}, [allowImages, selectImage, clearImageSelection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImageEl) return
|
||||
|
||||
function refreshHandle() {
|
||||
if (selectedImageEl?.isConnected) {
|
||||
updateHandlePosition(selectedImageEl)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', refreshHandle)
|
||||
editorRef.current?.addEventListener('scroll', refreshHandle)
|
||||
return () => {
|
||||
window.removeEventListener('resize', refreshHandle)
|
||||
editorRef.current?.removeEventListener('scroll', refreshHandle)
|
||||
}
|
||||
}, [selectedImageEl, updateHandlePosition])
|
||||
|
||||
function exec(cmd: string, arg?: string) {
|
||||
document.execCommand(cmd, false, arg)
|
||||
editorRef.current?.focus()
|
||||
syncChange()
|
||||
}
|
||||
|
||||
function styleInsertedImage(img: HTMLImageElement) {
|
||||
img.style.width = '100%'
|
||||
img.style.maxWidth = '100%'
|
||||
img.style.height = 'auto'
|
||||
img.draggable = false
|
||||
}
|
||||
|
||||
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !editorRef.current) return
|
||||
|
||||
setUploadingImage(true)
|
||||
setUploadError('')
|
||||
|
||||
try {
|
||||
const uploaded = await uploadMediaFiles([file])
|
||||
const url = uploaded[0]?.publicUrl
|
||||
if (!url) throw new Error('Image upload failed.')
|
||||
|
||||
editorRef.current.focus()
|
||||
document.execCommand('insertImage', false, url)
|
||||
|
||||
const imgs = editorRef.current.querySelectorAll('img')
|
||||
const lastImg = imgs[imgs.length - 1]
|
||||
if (lastImg instanceof HTMLImageElement) {
|
||||
styleInsertedImage(lastImg)
|
||||
selectImage(lastImg)
|
||||
}
|
||||
|
||||
syncChange()
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
|
||||
} finally {
|
||||
setUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onResizeStart(e: React.MouseEvent) {
|
||||
if (!selectedImageEl || !editorRef.current) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startWidth: selectedImageEl.offsetWidth,
|
||||
img: selectedImageEl,
|
||||
}
|
||||
|
||||
function onMove(ev: MouseEvent) {
|
||||
if (!dragRef.current || !editorRef.current) return
|
||||
const delta = ev.clientX - dragRef.current.startX
|
||||
const maxWidth = editorRef.current.clientWidth - 16
|
||||
const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
|
||||
dragRef.current.img.style.width = `${nextWidth}px`
|
||||
dragRef.current.img.style.height = 'auto'
|
||||
dragRef.current.img.style.maxWidth = '100%'
|
||||
updateHandlePosition(dragRef.current.img)
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
dragRef.current = null
|
||||
syncChange()
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.toolbar}>
|
||||
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
|
||||
<Bold size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
|
||||
<Italic size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
|
||||
<Underline size={16} />
|
||||
</button>
|
||||
<span className={styles.divider} />
|
||||
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
|
||||
<ListOrdered size={16} />
|
||||
</button>
|
||||
{allowImages && (
|
||||
<>
|
||||
<span className={styles.divider} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
title="Insert image"
|
||||
aria-label="Insert image"
|
||||
>
|
||||
<ImagePlus size={16} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className={styles.uploadError} role="alert">
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
<div ref={editorShellRef} className={styles.editorShell}>
|
||||
<div
|
||||
ref={editorRef}
|
||||
className={styles.editor}
|
||||
style={{ minHeight: editorMinHeight }}
|
||||
contentEditable
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
data-placeholder={placeholder}
|
||||
onInput={syncChange}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{allowImages && selectedImageEl && handlePos && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.resizeHandle}
|
||||
style={{ top: handlePos.top, left: handlePos.left }}
|
||||
onMouseDown={onResizeStart}
|
||||
aria-label="Resize image"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.loaderWrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(var(--primary-dark-rgb) / 0.15);
|
||||
border-top-color: var(--primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 var(--field-padding-x);
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrapOpen,
|
||||
.inputWrap:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: var(--field-padding-y) 0;
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
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: 0 8px 24px rgba(31, 38, 135, 0.12);
|
||||
z-index: 10;
|
||||
list-style: none;
|
||||
padding: 6px;
|
||||
animation: dropdownIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--field-font-size);
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optionEn {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.optionSep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.optionFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
import type { FlatCategory } from '../types/category'
|
||||
import styles from './SearchableSelect.module.css'
|
||||
|
||||
interface SearchableSelectProps {
|
||||
options: FlatCategory[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search categories...',
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selected = options.find((o) => o.id === value)
|
||||
|
||||
const filtered = options.filter((cat) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return (
|
||||
cat.nameEn.toLowerCase().includes(q) ||
|
||||
cat.nameFa.includes(query.trim())
|
||||
)
|
||||
})
|
||||
|
||||
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() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef}>
|
||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder={selected ? `${selected.nameEn} / ${selected.nameFa}` : placeholder}
|
||||
value={open ? query : selected ? `${selected.nameEn} / ${selected.nameFa}` : ''}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{open && (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption('')}
|
||||
>
|
||||
— None (root category) —
|
||||
</button>
|
||||
</li>
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>No categories found</li>
|
||||
) : (
|
||||
filtered.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === cat.id ? styles.optionSelected : ''}`}
|
||||
style={{ paddingLeft: `${14 + cat.depth * 16}px` }}
|
||||
onClick={() => selectOption(cat.id)}
|
||||
>
|
||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
||||
<span className={styles.optionSep}>·</span>
|
||||
<span className={styles.optionFa}>{cat.nameFa}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px;
|
||||
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.25s ease, box-shadow 0.25s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.12);
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, rgba(219, 234, 254, 0.5) 100%);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
margin-bottom: 24px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.arrowBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover .arrowBtn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ArrowRight, type LucideIcon } from 'lucide-react'
|
||||
import styles from './SectionCard.module.css'
|
||||
|
||||
interface SectionCardProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
linkText: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export function SectionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
linkText,
|
||||
href,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Link to={href} className={styles.card}>
|
||||
<div className={styles.iconWrap}>
|
||||
<Icon size={24} strokeWidth={1.75} />
|
||||
</div>
|
||||
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<p className={styles.description}>{description}</p>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.link}>{linkText}</span>
|
||||
<span className={styles.arrowBtn} aria-hidden="true">
|
||||
<ArrowRight size={18} />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
.modalWide {
|
||||
max-width: 920px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.itemList {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
display: grid;
|
||||
grid-template-columns: 60px minmax(0, 1fr) 120px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemThumb {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.itemThumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.itemThumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.itemFa {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.itemVariant {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.itemPriceCol {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.itemPrice {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qtyControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.qtyBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.qtyBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.qtyBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.qtyValue {
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemsTotal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 2px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.itemsTotalLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.itemsTotalPrice {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
height: var(--field-height);
|
||||
padding: var(--field-padding-y) 12px var(--field-padding-y) 36px;
|
||||
font-size: var(--field-font-size);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
outline: none;
|
||||
border-color: rgba(var(--primary-rgb) / 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.selectedCustomer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(var(--primary-rgb) / 0.35);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selectedCustomer span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clearCustomerBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clearCustomerBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.customerList {
|
||||
list-style: none;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.customerEmpty {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.customerOption {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.customerOption:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.customerOption:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 13px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.success {
|
||||
font-size: 13px;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.cartActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding-top: 16px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-dark-rgb) / 0.06);
|
||||
border: 1px solid rgba(var(--primary-dark-rgb) / 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-dark-rgb) / 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.secondaryBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.backBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.backBtn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.paymentSummary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.paymentSummary strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.paymentSummaryBalanced {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.paymentSummaryUnbalanced {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.paymentDuplicator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.paymentRowGroup {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paymentGridHeader,
|
||||
.paymentGridRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(128px, 0.85fr) minmax(0, 2fr) minmax(112px, 0.8fr) 32px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paymentGridHeader span:nth-child(2) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.paymentDetailsCell {
|
||||
min-width: 0;
|
||||
min-height: var(--field-height);
|
||||
}
|
||||
|
||||
.removeCell {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.transferFieldsInline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paymentAmountCell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paymentGridHeader {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.paymentGridHeader span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.paymentGridRow {
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.paymentGridRow select:focus,
|
||||
.paymentGridRow input:focus,
|
||||
.transferFieldsInline input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-dark-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.paymentGridRow select,
|
||||
.paymentGridRow input,
|
||||
.transferFieldsInline input {
|
||||
width: 100%;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
color: var(--text-primary);
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
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);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.paymentGridRow 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;
|
||||
}
|
||||
|
||||
.addPaymentBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
margin-top: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.addPaymentBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.addPaymentBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.paymentGridHeader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.paymentGridRow {
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
grid-template-areas:
|
||||
'method remove'
|
||||
'details details'
|
||||
'amount amount';
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.paymentGridRow select {
|
||||
grid-area: method;
|
||||
}
|
||||
|
||||
.paymentDetailsCell {
|
||||
grid-area: details;
|
||||
}
|
||||
|
||||
.paymentAmountCell {
|
||||
grid-area: amount;
|
||||
}
|
||||
|
||||
.paymentGridRow .removeCell {
|
||||
grid-area: remove;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.transferFieldsInline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
'thumb info'
|
||||
'price actions';
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.itemThumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
grid-area: thumb;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
grid-area: info;
|
||||
}
|
||||
|
||||
.itemPriceCol {
|
||||
grid-area: price;
|
||||
text-align: left;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.itemActions {
|
||||
grid-area: actions;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowLeft, Minus, Plus, Search, Trash2, User, X } from 'lucide-react'
|
||||
import { useDraftCart } from '../context/DraftCartContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
searchCustomers,
|
||||
type BusinessCustomer,
|
||||
} from '../services/customerService'
|
||||
import { createAdminOrder } from '../services/orderService'
|
||||
import { createShoppingCard, removeShoppingCard } from '../services/shoppingCardService'
|
||||
import { createId } from '../utils/id'
|
||||
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import rowStyles from './CreateStoreItemsModal.module.css'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
import removeStyles from './VariationsModal.module.css'
|
||||
import styles from './ShoppingCartModal.module.css'
|
||||
|
||||
interface ShoppingCartModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOrderCreated?: () => void
|
||||
}
|
||||
|
||||
type ModalStep = 'cart' | 'payment'
|
||||
|
||||
export type OrderPaymentMethod = 'pos' | 'cash' | 'transfer'
|
||||
|
||||
interface PaymentRow {
|
||||
id: string
|
||||
method: OrderPaymentMethod
|
||||
amountInput: string
|
||||
accountNumber: string
|
||||
refCode: string
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const PAYMENT_METHOD_OPTIONS: { value: OrderPaymentMethod; label: string }[] = [
|
||||
{ value: 'pos', label: 'POS' },
|
||||
{ value: 'cash', label: 'Cash' },
|
||||
{ value: 'transfer', label: 'Transfer' },
|
||||
]
|
||||
|
||||
function resetCustomerSelection(
|
||||
setQuery: (value: string) => void,
|
||||
setCustomers: (value: BusinessCustomer[]) => void,
|
||||
setSelectedCustomer: (value: BusinessCustomer | null) => void,
|
||||
) {
|
||||
setQuery('')
|
||||
setCustomers([])
|
||||
setSelectedCustomer(null)
|
||||
}
|
||||
|
||||
function createDefaultPayments(orderTotal: number): PaymentRow[] {
|
||||
return [
|
||||
{
|
||||
id: createId(),
|
||||
method: 'pos',
|
||||
amountInput: formatIrtInput(String(Math.round(orderTotal))),
|
||||
accountNumber: '',
|
||||
refCode: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function paymentRowsTotal(rows: PaymentRow[]) {
|
||||
return rows.reduce((sum, row) => sum + (parseIrtInput(row.amountInput) ?? 0), 0)
|
||||
}
|
||||
|
||||
function paymentsAreValid(rows: PaymentRow[], orderTotal: number) {
|
||||
if (paymentRowsTotal(rows) !== orderTotal) return false
|
||||
return rows.every((row) => {
|
||||
if (row.method !== 'transfer') return true
|
||||
return Boolean(row.accountNumber.trim() && row.refCode.trim())
|
||||
})
|
||||
}
|
||||
|
||||
function buildOrderPayments(rows: PaymentRow[]) {
|
||||
return rows.map((row) => {
|
||||
const amount = parseIrtInput(row.amountInput) ?? 0
|
||||
if (row.method === 'transfer') {
|
||||
return {
|
||||
type: 'transfer' as const,
|
||||
amount,
|
||||
transferAccount: row.accountNumber.trim(),
|
||||
transferRefNumber: row.refCode.trim(),
|
||||
}
|
||||
}
|
||||
if (row.method === 'pos') {
|
||||
return {
|
||||
type: 'pos' as const,
|
||||
amount,
|
||||
posType: 'operator',
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: row.method,
|
||||
amount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCartModalProps) {
|
||||
const { showToast } = useToast()
|
||||
const {
|
||||
items,
|
||||
itemCount,
|
||||
subtotal,
|
||||
customer,
|
||||
setCustomer,
|
||||
resumedShoppingCardId,
|
||||
updateQuantity,
|
||||
removeItem,
|
||||
clearAll,
|
||||
} = useDraftCart()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [step, setStep] = useState<ModalStep>('cart')
|
||||
const [query, setQuery] = useState('')
|
||||
const [customers, setCustomers] = useState<BusinessCustomer[]>([])
|
||||
const [payments, setPayments] = useState<PaymentRow[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const searchSeq = useRef(0)
|
||||
|
||||
const canProceed = Boolean(customer && items.length > 0)
|
||||
const paymentAllocated = useMemo(() => paymentRowsTotal(payments), [payments])
|
||||
const paymentRemaining = subtotal - paymentAllocated
|
||||
const paymentsBalanced = payments.length > 0 && paymentRemaining === 0
|
||||
const paymentsValid = paymentsAreValid(payments, subtotal)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setStep('cart')
|
||||
setPayments([])
|
||||
setError('')
|
||||
setQuery(customer?.label ?? '')
|
||||
setCustomers([])
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
setStep('cart')
|
||||
setPayments([])
|
||||
setQuery(customer?.label ?? '')
|
||||
setCustomers([])
|
||||
setError('')
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted, customer?.label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || isSubmitting) return
|
||||
if (step === 'payment') {
|
||||
setStep('cart')
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, onClose, step, isSubmitting])
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < 2) {
|
||||
setCustomers([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++searchSeq.current
|
||||
setIsSearching(true)
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await searchCustomers(trimmed)
|
||||
if (seq !== searchSeq.current) return
|
||||
setCustomers(result.items)
|
||||
} catch (err) {
|
||||
if (seq !== searchSeq.current) return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
}
|
||||
} finally {
|
||||
if (seq === searchSeq.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
async function handleSaveAndStartNew() {
|
||||
if (!customer || !items.length) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await createShoppingCard({
|
||||
customerUserId: customer.id,
|
||||
items: items.map((item) => ({
|
||||
storeItemVariantId: item.storeItemVariantId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
})
|
||||
clearAll()
|
||||
resetCustomerSelection(setQuery, setCustomers, setCustomer)
|
||||
setStep('cart')
|
||||
setPayments([])
|
||||
onClose()
|
||||
showToast('Shopping card saved.', 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save shopping card.')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleProceedWithPayment() {
|
||||
if (!canProceed) return
|
||||
setError('')
|
||||
setPayments(createDefaultPayments(subtotal))
|
||||
setStep('payment')
|
||||
}
|
||||
|
||||
function handleBackToCart() {
|
||||
setStep('cart')
|
||||
setError('')
|
||||
}
|
||||
|
||||
function updatePaymentRow(id: string, patch: Partial<PaymentRow>) {
|
||||
setPayments((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
function addPaymentRow() {
|
||||
setPayments((prev) => {
|
||||
const used = new Set(prev.map((row) => row.method))
|
||||
const nextMethod = PAYMENT_METHOD_OPTIONS.find((option) => !used.has(option.value))?.value
|
||||
if (!nextMethod) return prev
|
||||
|
||||
const allocated = paymentRowsTotal(prev)
|
||||
const remaining = Math.max(0, subtotal - allocated)
|
||||
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: createId(),
|
||||
method: nextMethod,
|
||||
amountInput: remaining > 0 ? formatIrtInput(String(remaining)) : '',
|
||||
accountNumber: '',
|
||||
refCode: '',
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function removePaymentRow(id: string) {
|
||||
setPayments((prev) => {
|
||||
if (prev.length <= 1) return prev
|
||||
return prev.filter((row) => row.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleConfirmPayment() {
|
||||
if (!customer) return
|
||||
|
||||
if (!paymentsBalanced) {
|
||||
setError('Payment amounts must equal the order total.')
|
||||
return
|
||||
}
|
||||
|
||||
const missingTransferDetails = payments.some(
|
||||
(row) =>
|
||||
row.method === 'transfer' && (!row.accountNumber.trim() || !row.refCode.trim()),
|
||||
)
|
||||
if (missingTransferDetails) {
|
||||
setError('Account number and ref code are required for transfer payments.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createAdminOrder({
|
||||
customerUserId: customer.id,
|
||||
items: items.map((item) => ({
|
||||
storeItemVariantId: item.storeItemVariantId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
status: 'pending',
|
||||
payments: buildOrderPayments(payments),
|
||||
})
|
||||
|
||||
if (resumedShoppingCardId) {
|
||||
try {
|
||||
await removeShoppingCard(resumedShoppingCardId)
|
||||
} catch {
|
||||
// Order was created; card removal is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`Order ${result.order.orderNumber} saved successfully.`, 'success')
|
||||
clearAll()
|
||||
resetCustomerSelection(setQuery, setCustomers, setCustomer)
|
||||
setStep('cart')
|
||||
setPayments([])
|
||||
onOrderCreated?.()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save order.')
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const canAddPayment = payments.length < PAYMENT_METHOD_OPTIONS.length
|
||||
|
||||
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="shopping-cart-title"
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
{step === 'payment' && (
|
||||
<button type="button" className={styles.backBtn} onClick={handleBackToCart}>
|
||||
<ArrowLeft size={14} />
|
||||
Back to cart
|
||||
</button>
|
||||
)}
|
||||
<h2 id="shopping-cart-title" className={modalStyles.title}>
|
||||
{step === 'payment' ? 'Payment' : 'Shopping cart'}
|
||||
</h2>
|
||||
{step === 'payment' && customer && (
|
||||
<p className={modalStyles.subtitle}>{customer.label}</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.body} ${styles.body}`}>
|
||||
{step === 'cart' ? (
|
||||
<>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Items</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className={styles.empty}>Your cart is empty.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className={styles.itemList}>
|
||||
{items.map((item) => (
|
||||
<li key={item.storeItemVariantId} className={styles.itemRow}>
|
||||
<div className={styles.itemThumb}>
|
||||
{item.productImage ? (
|
||||
<img src={item.productImage} alt="" />
|
||||
) : (
|
||||
<div className={styles.itemThumbPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.itemInfo}>
|
||||
<div className={styles.itemTitle}>{item.productTitle}</div>
|
||||
{item.productNameFa && (
|
||||
<div className={`${styles.itemFa} faText`}>{item.productNameFa}</div>
|
||||
)}
|
||||
<div className={styles.itemVariant}>{item.label}</div>
|
||||
</div>
|
||||
<div className={styles.itemPriceCol}>
|
||||
<span className={styles.itemPrice}>
|
||||
{formatIrtPrice(item.unitPrice * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.itemActions}>
|
||||
<div className={styles.qtyControls}>
|
||||
<Tooltip label="Decrease quantity">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() =>
|
||||
updateQuantity(item.storeItemVariantId, item.quantity - 1)
|
||||
}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className={styles.qtyValue}>{item.quantity}</span>
|
||||
<Tooltip label="Increase quantity">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.qtyBtn}
|
||||
onClick={() =>
|
||||
updateQuantity(item.storeItemVariantId, item.quantity + 1)
|
||||
}
|
||||
disabled={
|
||||
item.stockQuantity !== null &&
|
||||
item.quantity >= item.stockQuantity
|
||||
}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip label="Remove item">
|
||||
<button
|
||||
type="button"
|
||||
className={removeStyles.removeRowBtn}
|
||||
onClick={() => removeItem(item.storeItemVariantId)}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.itemsTotal}>
|
||||
<span className={styles.itemsTotalLabel}>
|
||||
Total · {itemCount} {itemCount === 1 ? 'item' : 'items'}
|
||||
</span>
|
||||
<span className={styles.itemsTotalPrice}>{formatIrtPrice(subtotal)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Assign customer</h3>
|
||||
<p className={styles.sectionHint}>
|
||||
Search by name, phone, or email. A customer must be selected before you can save
|
||||
or proceed with payment.
|
||||
</p>
|
||||
<div className={styles.searchWrap}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
placeholder="Search customers..."
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setCustomer(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{customer && (
|
||||
<div className={styles.selectedCustomer}>
|
||||
<User size={16} />
|
||||
<span>{customer.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearCustomerBtn}
|
||||
onClick={() => {
|
||||
setCustomer(null)
|
||||
setQuery('')
|
||||
}}
|
||||
aria-label="Clear customer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!customer && query.trim().length >= 2 && (
|
||||
<ul className={styles.customerList}>
|
||||
{isSearching ? (
|
||||
<li className={styles.customerEmpty}>Searching...</li>
|
||||
) : customers.length === 0 ? (
|
||||
<li className={styles.customerEmpty}>No customers found.</li>
|
||||
) : (
|
||||
customers.map((option) => (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.customerOption}
|
||||
onClick={() => {
|
||||
setCustomer(option)
|
||||
setQuery(option.label)
|
||||
setCustomers([])
|
||||
}}
|
||||
>
|
||||
<User size={16} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className={styles.cartActions}>
|
||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={() => void handleSaveAndStartNew()}
|
||||
disabled={!canProceed || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save and start new one'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.submitBtn}
|
||||
onClick={handleProceedWithPayment}
|
||||
disabled={!canProceed}
|
||||
>
|
||||
Proceed with payment
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>Payment methods</h3>
|
||||
<p className={styles.sectionHint}>
|
||||
Split the order total across one or more payment methods. The allocated amount must
|
||||
match the order total exactly.
|
||||
</p>
|
||||
|
||||
<div className={styles.paymentSummary}>
|
||||
<span>
|
||||
Order total: <strong>{formatIrtPrice(subtotal)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Allocated: <strong>{formatIrtPrice(paymentAllocated)}</strong>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
paymentsBalanced ? styles.paymentSummaryBalanced : styles.paymentSummaryUnbalanced
|
||||
}
|
||||
>
|
||||
Remaining: <strong>{formatIrtPrice(paymentRemaining)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.paymentDuplicator}>
|
||||
<div className={styles.paymentGridHeader}>
|
||||
<span>Payment method</span>
|
||||
<span>Account number / Ref code</span>
|
||||
<span>Amount (IRT)</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{payments.map((row) => {
|
||||
const usedMethods = new Set(
|
||||
payments.filter((payment) => payment.id !== row.id).map((payment) => payment.method),
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={row.id} className={styles.paymentGridRow}>
|
||||
<select
|
||||
value={row.method}
|
||||
onChange={(e) => {
|
||||
const method = e.target.value as OrderPaymentMethod
|
||||
updatePaymentRow(row.id, {
|
||||
method,
|
||||
accountNumber: method === 'transfer' ? row.accountNumber : '',
|
||||
refCode: method === 'transfer' ? row.refCode : '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{PAYMENT_METHOD_OPTIONS.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={usedMethods.has(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className={styles.paymentDetailsCell}>
|
||||
{row.method === 'transfer' && (
|
||||
<div className={styles.transferFieldsInline}>
|
||||
<input
|
||||
type="text"
|
||||
value={row.accountNumber}
|
||||
onChange={(e) =>
|
||||
updatePaymentRow(row.id, { accountNumber: e.target.value })
|
||||
}
|
||||
placeholder="Account number"
|
||||
autoComplete="off"
|
||||
aria-label="Account number"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={row.refCode}
|
||||
onChange={(e) =>
|
||||
updatePaymentRow(row.id, { refCode: e.target.value })
|
||||
}
|
||||
placeholder="Ref code"
|
||||
autoComplete="off"
|
||||
aria-label="Ref code"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.paymentAmountCell}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={row.amountInput}
|
||||
onChange={(e) =>
|
||||
updatePaymentRow(row.id, { amountInput: formatIrtInput(e.target.value) })
|
||||
}
|
||||
placeholder="0"
|
||||
autoComplete="off"
|
||||
aria-label="Amount in IRT"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.removeCell}>
|
||||
<Tooltip label="Remove payment method">
|
||||
<button
|
||||
type="button"
|
||||
className={removeStyles.removeRowBtn}
|
||||
onClick={() => removePaymentRow(row.id)}
|
||||
disabled={payments.length <= 1}
|
||||
aria-label="Remove payment method"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={rowStyles.addRowBtn}
|
||||
onClick={addPaymentRow}
|
||||
disabled={!canAddPayment}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add payment method
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className={styles.cartActions}>
|
||||
<button type="button" className={modalStyles.cancelBtn} onClick={handleBackToCart}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.submitBtn}
|
||||
onClick={() => void handleConfirmPayment()}
|
||||
disabled={!paymentsValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving order...' : 'Confirm payment'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid var(--glass-border);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
padding: 4px 8px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.brandLogoUploaded {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandDomain {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navItem.active {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navGroupBtn {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.navGroupLabel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.subNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 2px 0 4px 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.subNavItem {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.subNavItem:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.06);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.subNavActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
ShoppingBag,
|
||||
Store,
|
||||
Users,
|
||||
Settings,
|
||||
FileText,
|
||||
Briefcase,
|
||||
Globe,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import {
|
||||
BUSINESS_PROFILE_UPDATED_EVENT,
|
||||
getActiveBusinessDomain,
|
||||
} from '../lib/businessContext'
|
||||
import { isAbortError } from '../lib/api'
|
||||
import { getBusinessProfile } from '../services/businessProfileService'
|
||||
import meshkeeLogo from '../assets/meshkee-logo.png'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
interface NavChild {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
type: 'group'
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
basePath: string
|
||||
children: NavChild[]
|
||||
}
|
||||
|
||||
interface NavLinkItem {
|
||||
type: 'link'
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
type NavItem = NavLinkItem | NavGroup
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ type: 'link', icon: Home, label: 'Home', to: '/' },
|
||||
{ type: 'link', icon: Building2, label: 'Business Profile', to: '/business-profile' },
|
||||
{
|
||||
type: 'group',
|
||||
icon: ShoppingBag,
|
||||
label: 'Products',
|
||||
basePath: '/products',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/products' },
|
||||
{ label: 'My Products', to: '/products/list' },
|
||||
{ label: 'Add New Product', to: '/products/new' },
|
||||
{ label: 'Categories', to: '/products/categories' },
|
||||
{ label: 'Brands', to: '/products/brands' },
|
||||
{ label: 'Settings', to: '/products/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Store,
|
||||
label: 'Store',
|
||||
basePath: '/store',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/store' },
|
||||
{ label: 'My Store Items', to: '/store/items' },
|
||||
{ label: 'My Orders', to: '/store/orders' },
|
||||
{ label: 'Shipping Fees', to: '/store/shipping' },
|
||||
{ label: 'Shopping Cards', to: '/store/cards' },
|
||||
{ label: 'Settings', to: '/store/settings' },
|
||||
],
|
||||
},
|
||||
{ type: 'link', icon: Users, label: 'Customers', to: '/customers' },
|
||||
{ type: 'link', icon: Settings, label: 'Settings', to: '/settings' },
|
||||
{
|
||||
type: 'group',
|
||||
icon: FileText,
|
||||
label: 'Blog',
|
||||
basePath: '/blog',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/blog' },
|
||||
{ label: 'My Blogs', to: '/blog/list' },
|
||||
{ label: 'Add New Blog', to: '/blog/new' },
|
||||
{ label: 'Categories', to: '/blog/categories' },
|
||||
{ label: 'Settings', to: '/blog/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Briefcase,
|
||||
label: 'Portfolios',
|
||||
basePath: '/portfolios',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/portfolios' },
|
||||
{ label: 'My Portfolios', to: '/portfolios/list' },
|
||||
{ label: 'Add New Portfolio', to: '/portfolios/new' },
|
||||
{ label: 'Categories', to: '/portfolios/categories' },
|
||||
{ label: 'Settings', to: '/portfolios/settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
icon: Globe,
|
||||
label: 'Website',
|
||||
basePath: '/website',
|
||||
children: [
|
||||
{ label: 'Overview', to: '/website' },
|
||||
{ label: 'Sliders', to: '/website/sliders' },
|
||||
{ label: 'Special Categories', to: '/website/special-categories' },
|
||||
{ label: 'Special Brands', to: '/website/special-brands' },
|
||||
{ label: 'Special Items', to: '/website/special-items' },
|
||||
{ label: 'Contact Us Form', to: '/website/contact' },
|
||||
{ label: 'Subscriptions', to: '/website/subscriptions' },
|
||||
{ label: 'FAQ', to: '/website/faq' },
|
||||
{ label: 'Badges', to: '/website/badges' },
|
||||
{ label: 'E-Payment', to: '/website/e-payment' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const footerItems = [
|
||||
{ icon: HelpCircle, label: 'Help Center' },
|
||||
{ icon: LogOut, label: 'Logout' },
|
||||
]
|
||||
|
||||
function isGroupActive(basePath: string, pathname: string) {
|
||||
return pathname === basePath || pathname.startsWith(`${basePath}/`)
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({})
|
||||
const [brandLogoUrl, setBrandLogoUrl] = useState<string | null>(null)
|
||||
const [brandName, setBrandName] = useState('')
|
||||
|
||||
const businessDomain = getActiveBusinessDomain()
|
||||
const fallbackBusinessName = user?.businesses[0]?.name ?? 'Business'
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function loadBranding() {
|
||||
try {
|
||||
const data = await getBusinessProfile(controller.signal)
|
||||
setBrandLogoUrl(data.profile.logoUrl)
|
||||
setBrandName(data.profile.nameEn.trim() || data.profile.nameFa.trim() || fallbackBusinessName)
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) return
|
||||
setBrandLogoUrl(null)
|
||||
setBrandName(fallbackBusinessName)
|
||||
}
|
||||
}
|
||||
|
||||
void loadBranding()
|
||||
|
||||
function handleProfileUpdated() {
|
||||
void loadBranding()
|
||||
}
|
||||
|
||||
window.addEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.removeEventListener(BUSINESS_PROFILE_UPDATED_EVENT, handleProfileUpdated)
|
||||
}
|
||||
}, [fallbackBusinessName])
|
||||
|
||||
useEffect(() => {
|
||||
navItems.forEach((item) => {
|
||||
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) {
|
||||
setOpenGroups((prev) => ({ ...prev, [item.label]: true }))
|
||||
}
|
||||
})
|
||||
}, [pathname])
|
||||
|
||||
function toggleGroup(label: string) {
|
||||
setOpenGroups((prev) => ({ ...prev, [label]: !prev[label] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
<img
|
||||
src={brandLogoUrl ?? meshkeeLogo}
|
||||
alt={brandName || 'Business logo'}
|
||||
className={`${styles.brandLogo} ${brandLogoUrl ? styles.brandLogoUploaded : ''}`}
|
||||
/>
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.brandDomain}>{businessDomain}</span>
|
||||
<span className={styles.brandName}>{brandName || fallbackBusinessName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
{navItems.map((item) => {
|
||||
if (item.type === 'link') {
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`${styles.navItem} ${isActive ? styles.active : ''}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
const isOpen = openGroups[item.label] ?? false
|
||||
const groupActive = isGroupActive(item.basePath, pathname)
|
||||
|
||||
return (
|
||||
<div key={item.label} className={styles.navGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navItem} ${styles.navGroupBtn} ${groupActive ? styles.active : ''}`}
|
||||
onClick={() => toggleGroup(item.label)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span className={styles.navGroupLabel}>{item.label}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.chevron} ${isOpen ? styles.chevronOpen : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={styles.subNav}>
|
||||
{item.children.map((child) => (
|
||||
<NavLink
|
||||
key={child.to}
|
||||
to={child.to}
|
||||
end={child.to === item.basePath}
|
||||
className={({ isActive }) =>
|
||||
`${styles.subNavItem} ${isActive ? styles.subNavActive : ''}`
|
||||
}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
{footerItems.map(({ icon: Icon, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={styles.navItem}
|
||||
onClick={() => {
|
||||
if (label === 'Logout') {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.root {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.12),
|
||||
0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
transform: scale(1.04);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.16),
|
||||
0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: 220px;
|
||||
padding: 10px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
animation: panelIn 0.15s ease;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.1);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.swatch:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.swatchSelected {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.16),
|
||||
0 0 0 2px var(--primary);
|
||||
}
|
||||
|
||||
@keyframes panelIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.panel {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import {
|
||||
STEP_COLOR_PRESETS,
|
||||
stepColorLabel,
|
||||
type StepColorPreset,
|
||||
} from '../utils/stepColors'
|
||||
import styles from './StepColorPicker.module.css'
|
||||
|
||||
interface StepColorPickerProps {
|
||||
value: StepColorPreset
|
||||
onChange: (color: StepColorPreset) => void
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function StepColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
ariaLabel = 'Pick step color',
|
||||
}: StepColorPickerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function selectColor(color: StepColorPreset) {
|
||||
onChange(color)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<Tooltip label="Pick step color">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
style={{ backgroundColor: value }}
|
||||
onClick={() => !disabled && setOpen((current) => !current)}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{open && (
|
||||
<div className={styles.panel} role="listbox" aria-label="Step colors">
|
||||
<div className={styles.grid}>
|
||||
{STEP_COLOR_PRESETS.map((color) => {
|
||||
const selected = color === value
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
aria-label={stepColorLabel(color)}
|
||||
className={`${styles.swatch} ${selected ? styles.swatchSelected : ''}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => selectColor(color)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
.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;
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
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;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.festivalBadge,
|
||||
.stockBadge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--glass-border);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.festivalBadge {
|
||||
left: 8px;
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stockBadge {
|
||||
right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nameEn {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 3px;
|
||||
text-align: left;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.variantLabel {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 6px 10px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.controlsLeft {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.controlsLeft button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.controlsLeft button:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.controlsLeft button.festivalActive {
|
||||
color: #7c3aed;
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
|
||||
.controlsLeft button.festivalActive:hover {
|
||||
background: rgba(167, 139, 250, 0.22);
|
||||
color: #6d28d9;
|
||||
}
|
||||
|
||||
.controlsLeft button.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
|
||||
.addToCartIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addToCartBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.addToCartBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.addToCartBtn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useId } from 'react'
|
||||
import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
formatVariantCount,
|
||||
type StoreProductListing,
|
||||
} from '../utils/storeProductGroups'
|
||||
import { StoreItemPrice } from './StoreItemPrice'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './StoreItemCard.module.css'
|
||||
|
||||
interface StoreItemCardProps {
|
||||
listing: StoreProductListing
|
||||
onOpen: (listing: StoreProductListing) => void
|
||||
onEdit: (listing: StoreProductListing) => void
|
||||
onDiscount: (listing: StoreProductListing) => void
|
||||
onFestival: (listing: StoreProductListing) => void
|
||||
onRemove: (listing: StoreProductListing) => void
|
||||
onAddToCart: (listing: StoreProductListing) => void
|
||||
removeTooltip?: string
|
||||
}
|
||||
|
||||
function GradientPlusIcon({ gradientId }: { gradientId: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
className={styles.addToCartIcon}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="4"
|
||||
y1="4"
|
||||
x2="20"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#22c55e" />
|
||||
<stop offset="0.5" stopColor="var(--primary)" />
|
||||
<stop offset="1" stopColor="#a855f7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StoreItemCard({
|
||||
listing,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDiscount,
|
||||
onFestival,
|
||||
onRemove,
|
||||
onAddToCart,
|
||||
removeTooltip = 'Remove from store',
|
||||
}: StoreItemCardProps) {
|
||||
const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}`
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<button type="button" className={styles.clickable} onClick={() => onOpen(listing)}>
|
||||
<div className={styles.imageWrap}>
|
||||
{listing.productImage ? (
|
||||
<img
|
||||
src={listing.productImage}
|
||||
alt={listing.productTitle}
|
||||
className={styles.image}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.imagePlaceholder} />
|
||||
)}
|
||||
{listing.showFestival && <span className={styles.festivalBadge}>Festival</span>}
|
||||
{listing.productTotalStock > 0 && (
|
||||
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<h3 className={styles.nameEn}>{listing.productTitle}</h3>
|
||||
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>}
|
||||
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p>
|
||||
<StoreItemPrice
|
||||
price={listing.displayPrice}
|
||||
discountedPrice={listing.displayDiscountedPrice}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={styles.controls} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.controlsLeft}>
|
||||
<Tooltip label="Edit store items">
|
||||
<button type="button" onClick={() => onEdit(listing)} aria-label="Edit store items">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Set discounts">
|
||||
<button type="button" onClick={() => onDiscount(listing)} aria-label="Set discounts">
|
||||
<Percent size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Festival reward points">
|
||||
<button
|
||||
type="button"
|
||||
className={listing.showFestival ? styles.festivalActive : undefined}
|
||||
onClick={() => onFestival(listing)}
|
||||
aria-label="Festival reward points"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeTooltip}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(listing)}
|
||||
aria-label={removeTooltip}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip label="Add to shopping cart">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addToCartBtn}
|
||||
onClick={() => onAddToCart(listing)}
|
||||
aria-label="Add to shopping cart"
|
||||
>
|
||||
<GradientPlusIcon gradientId={plusGradientId} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user