Initial commit: Meshkee dashboards monorepo.

Includes business, customer, and super-admin apps with shared packages and production deploy scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-22 13:48:53 +03:30
co-authored by Cursor
commit f566387c61
509 changed files with 62690 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
listBlogCategories,
mapBlogCategoryToUi,
} from '../services/blogCategoryService'
import {
resolveDataUrlToMediaId,
resolveHtmlEmbeddedDataUrls,
} from '../services/mediaService'
import {
createBlog,
getBlog,
mapBlogApiToFormState,
updateBlog,
} from '../services/blogService'
import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewBlogPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [type, setType] = useState<BlogPostType>('blog')
const [categoryId, setCategoryId] = useState('')
const [title, setTitle] = useState('')
const [abstract, setAbstract] = useState('')
const [mainTextHtml, setMainTextHtml] = useState('')
const [titleImage, setTitleImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadCategories() {
try {
const items = await listBlogCategories(controller.signal)
setCategories(items.map(mapBlogCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load categories.')
}
}
}
void loadCategories()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadBlog() {
setError('')
try {
const blog = await getBlog(id!, controller.signal)
const form = mapBlogApiToFormState(blog)
setType(form.type)
setCategoryId(form.categoryId)
setTitle(form.title)
setAbstract(form.abstract)
setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage)
setTags(form.tags)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/blog/list', { replace: true })
}
}
void loadBlog()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
titleImage,
'blog-title-image.jpg',
featuredMediaId,
)
const resolvedMainTextHtml = await resolveHtmlEmbeddedDataUrls(mainTextHtml)
const payload = {
title: title.trim(),
type,
abstract: abstract.trim(),
mainTextHtml: resolvedMainTextHtml,
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updateBlog(id, {
...payload,
categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId,
})
showToast('Blog post updated.', 'success')
} else {
await createBlog(payload)
showToast('Blog post created.', 'success')
}
navigate('/blog/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to save blog post.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'My Blogs', href: '/blog/list' },
{ label: 'Edit Blog' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'Add New Blog' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Blog' : 'Add New Blog'}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit
? 'Update blog post details and save changes.'
: 'Create and publish a new blog post.'}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
<label>Title Image</label>
<ImageCropper
value={titleImage}
onChange={setTitleImage}
aspect={3 / 2}
uploadLabel="Upload title image"
hint="Click to select, then crop"
changeLabel="Change title image"
/>
</div>
<div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="blog-type">Type</label>
<select
id="blog-type"
value={type}
onChange={(e) => setType(e.target.value as BlogPostType)}
disabled={isSubmitting}
>
{BLOG_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.col6Span}`}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="blog-title">Title</label>
<input
id="blog-title"
name="title"
type="text"
placeholder="Blog post title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="blog-abstract">Abstract</label>
<textarea
id="blog-abstract"
name="abstract"
rows={4}
placeholder="Short summary shown in blog listings"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Main Text</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder="Full blog content with formatting and images..."
allowImages
editorMinHeight={320}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/blog/list" className={styles.cancelBtn}>
Cancel
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : isEdit ? 'Update Blog' : 'Save Blog'}
</button>
</div>
</form>
</main>
)
}
@@ -0,0 +1,285 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
listPortfolioCategories,
mapPortfolioCategoryToUi,
} from '../services/portfolioCategoryService'
import {
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
resolveHtmlEmbeddedDataUrls,
} from '../services/mediaService'
import {
createPortfolio,
getPortfolio,
mapPortfolioApiToFormState,
updatePortfolio,
} from '../services/portfolioService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewPortfolioPage() {
const { id } = useParams()
const navigate = useNavigate()
const { showToast } = useToast()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [title, setTitle] = useState('')
const [abstract, setAbstract] = useState('')
const [mainTextHtml, setMainTextHtml] = useState('')
const [titleImage, setTitleImage] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadCategories() {
try {
const items = await listPortfolioCategories(controller.signal)
setCategories(items.map(mapPortfolioCategoryToUi))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load categories.')
}
}
}
void loadCategories()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadPortfolio() {
setError('')
try {
const portfolio = await getPortfolio(id!, controller.signal)
const form = mapPortfolioApiToFormState(portfolio)
setCategoryId(form.categoryId)
setTitle(form.title)
setAbstract(form.abstract)
setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage)
setImages(form.images)
setTags(form.tags)
setFeaturedMediaId(form.featuredMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/portfolios/list', { replace: true })
}
}
void loadPortfolio()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
titleImage,
'portfolio-title-image.jpg',
featuredMediaId,
)
const resolvedMainTextHtml = await resolveHtmlEmbeddedDataUrls(mainTextHtml)
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(images, galleryMediaIds)
if (titleImage?.startsWith('data:') && !nextFeaturedMediaId) {
setError('Unable to upload title image. Please try again.')
setIsSubmitting(false)
return
}
const payload = {
title: title.trim(),
abstract: abstract.trim(),
mainTextHtml: resolvedMainTextHtml,
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updatePortfolio(id, {
...payload,
categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId,
})
showToast('Portfolio updated.', 'success')
} else {
await createPortfolio(payload)
showToast('Portfolio created.', 'success')
}
navigate('/portfolios/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to save portfolio.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'My Portfolios', href: '/portfolios/list' },
{ label: 'Edit Portfolio' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'Add New Portfolio' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Portfolio' : 'Add New Portfolio'}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit
? 'Update portfolio details and save changes.'
: 'Create and publish a new portfolio project.'}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
<label>Title Image</label>
<ImageCropper
value={titleImage}
onChange={setTitleImage}
aspect={3 / 2}
uploadLabel="Upload title image"
hint="Click to select, then crop"
changeLabel="Change title image"
/>
</div>
<div className={`${styles.field} ${styles.col6Span}`}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="portfolio-title">Title</label>
<input
id="portfolio-title"
name="title"
type="text"
placeholder="Portfolio title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="portfolio-abstract">Abstract</label>
<textarea
id="portfolio-abstract"
name="abstract"
rows={4}
placeholder="Short summary shown in portfolio listings"
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Main Text</label>
<RichTextEditor
value={mainTextHtml}
onChange={setMainTextHtml}
placeholder="Full portfolio content with formatting and images..."
allowImages
editorMinHeight={320}
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Image Gallery</label>
<ImageUploader images={images} onChange={setImages} />
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/portfolios/list" className={styles.cancelBtn}>
Cancel
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : isEdit ? 'Update Portfolio' : 'Save Portfolio'}
</button>
</div>
</form>
</main>
)
}
@@ -0,0 +1,214 @@
.form {
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;
}
.formGrid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 12px 16px;
}
.col2 { grid-column: span 2; }
.col3 { grid-column: span 3; }
.col4 { grid-column: span 4; }
.col6 { grid-column: 1 / span 6; }
.col8 { grid-column: span 8; }
.col7 { grid-column: span 7; }
.col6Span { grid-column: span 6; }
.col10 { grid-column: span 10; }
.col4Start { grid-column: 1 / span 4; }
.col9 { grid-column: span 9; }
.col12 { grid-column: span 12; }
.thumbnailField {
align-self: start;
}
.topFields {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 12px 16px;
height: 100%;
}
.fieldFull {
grid-column: span 12;
}
.fieldHalf {
grid-column: span 6;
}
.fieldCategory {
grid-column: span 7;
}
.fieldBrand {
grid-column: span 5;
}
.optional {
font-weight: 400;
color: var(--text-muted);
}
.summaryField textarea {
min-height: calc((var(--field-height) + 8px) * 2);
}
.rowSpan3 { grid-row: span 3; }
.rowSpan4 { grid-row: span 4; }
.field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.field input,
.field textarea,
.field select {
width: 100%;
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
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);
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
resize: vertical;
box-sizing: border-box;
}
.field input,
.field select {
min-height: var(--field-height);
}
.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;
}
.field textarea {
min-height: calc(var(--field-height) + 8px);
}
.field input:focus,
.field textarea:focus,
.field select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.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);
}
.submitBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.error {
margin-bottom: 16px;
padding: 12px 16px;
font-size: 14px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
@media (max-width: 768px) {
.form {
padding: 20px 16px;
}
.col2,
.col3,
.col4,
.col6,
.col6Span,
.col7,
.col8,
.col9,
.col10,
.col4Start,
.col9,
.col12 {
grid-column: span 12;
}
.topFields {
grid-template-columns: 1fr;
}
.fieldFull,
.fieldHalf,
.fieldCategory,
.fieldBrand {
grid-column: span 1;
}
.rowSpan3,
.rowSpan4 {
grid-row: auto;
}
}
@@ -0,0 +1,311 @@
import { useEffect, useState } from 'react'
import { Link, useParams, useNavigate } from 'react-router-dom'
import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
import {
resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds,
} from '../services/mediaService'
import {
createProduct,
getProduct,
mapProductApiToFormState,
updateProduct,
} from '../services/productService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
export function AddNewProductPage() {
const { id } = useParams()
const navigate = useNavigate()
const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([])
const [brandOptions, setBrandOptions] = useState<FlatCategory[]>([])
const [categoryId, setCategoryId] = useState('')
const [brandId, setBrandId] = useState('')
const [nameFa, setNameFa] = useState('')
const [nameEn, setNameEn] = useState('')
const [summary, setSummary] = useState('')
const [description, setDescription] = useState('')
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const categoryOptions = flattenCategories(categories)
useEffect(() => {
const controller = new AbortController()
async function loadFormOptions() {
try {
const [categoryItems, brandItems] = await Promise.all([
listProductCategories(controller.signal),
listAllBrands(controller.signal),
])
setCategories(categoryItems.map(mapProductCategoryToUi))
setBrandOptions(brandItems.map(mapBrandToSelectOption))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load form options.')
}
}
}
void loadFormOptions()
return () => controller.abort()
}, [])
useEffect(() => {
if (!isEdit || !id) return
const controller = new AbortController()
async function loadProduct() {
setError('')
try {
const product = await getProduct(id!, controller.signal)
const form = mapProductApiToFormState(product)
setCategoryId(form.categoryId)
setBrandId(form.brandId)
setNameFa(form.nameFa)
setNameEn(form.nameEn)
setSummary(form.summary)
setDescription(form.description)
setThumbnail(form.thumbnail)
setImages(form.images)
setTags(form.tags)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
navigate('/products/list', { replace: true })
}
}
void loadProduct()
return () => controller.abort()
}, [isEdit, id, navigate])
if (!loaded) return null
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)
setError('')
try {
const featuredMediaId = await resolveDataUrlToMediaId(
thumbnail,
'product-thumbnail.jpg',
thumbnailMediaId,
)
const nextGalleryMediaIds = await resolveDataUrlsToMediaIds(
images,
galleryMediaIds,
)
const payload = {
title: nameEn.trim(),
nameFa: nameFa.trim(),
summary: summary.trim(),
descriptionHtml: description,
categoryId: categoryId || undefined,
brandId: brandId || undefined,
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
status: 'published' as const,
}
if (isEdit && id) {
await updateProduct(id, {
...payload,
categoryId: categoryId || null,
brandId: brandId || null,
featuredMediaId,
})
} else {
await createProduct(payload)
}
navigate('/products/list')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to save product.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={
isEdit
? [
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' },
{ label: 'Edit Product' },
]
: [
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Add a New Product' },
]
}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Product' : 'Add a New Product'}
</h2>
<p className={pageStyles.pageSubtitle}>
{isEdit
? 'Update product details and save changes.'
: 'Create and publish a new product to your store.'}
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
<label>Thumbnail Image</label>
<ImageCropper value={thumbnail} onChange={setThumbnail} />
</div>
<div className={styles.col10}>
<div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}>
<label>Category</label>
<SearchableSelect
options={categoryOptions}
value={categoryId}
onChange={setCategoryId}
placeholder="Search and select category..."
/>
</div>
<div className={`${styles.field} ${styles.fieldBrand}`}>
<label>
Brand <span className={styles.optional}>(optional)</span>
</label>
<SearchableSelect
options={brandOptions}
value={brandId}
onChange={setBrandId}
placeholder="Search and select brand..."
/>
</div>
<div className={`${styles.field} ${styles.fieldHalf}`}>
<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} ${styles.fieldHalf}`}>
<label htmlFor="nameEn">Name (EN)</label>
<input
id="nameEn"
name="nameEn"
type="text"
dir="ltr"
placeholder="Product name"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
<label htmlFor="summary">Summary</label>
<textarea
id="summary"
name="summary"
rows={4}
placeholder="Short summary shown in product listings"
value={summary}
onChange={(e) => setSummary(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Description</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Full product description with formatting..."
/>
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Product Images</label>
<ImageUploader images={images} onChange={setImages} />
</div>
<div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label>
<TagInput tags={tags} onChange={setTags} />
</div>
</div>
<div className={styles.actions}>
<Link to="/products/list" className={styles.cancelBtn}>
Cancel
</Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : isEdit ? 'Update Product' : 'Save Product'}
</button>
</div>
</form>
</main>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { CategoryModal } from '../components/CategoryModal'
import { BlogCategoryTree } from '../components/BlogCategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
import {
createBlogCategory,
deleteBlogCategory,
listBlogCategories,
mapBlogCategoryToUi,
toBlogCategoryFormPayload,
updateBlogCategory,
} from '../services/blogCategoryService'
import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function BlogCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState('Add Category')
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listBlogCategories(signal)
setCategories(items.map(mapBlogCategoryToUi))
} 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 openCreateModal(parentId = '', title = 'Add Category') {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title)
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toBlogCategoryFormPayload(data)
if (editingCategory) {
const result = await updateBlogCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapBlogCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createBlogCategory(payload)
setCategories((prev) => [...prev, mapBlogCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save category.')
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deleteBlogCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete category.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Blog Categories</h2>
<p className={pageStyles.pageSubtitle}>
Organize your blog posts into categories and subcategories.
</p>
</div>
<button
className={styles.addBtn}
onClick={() => openCreateModal()}
aria-label="Add category"
>
<Plus size={22} strokeWidth={2.5} />
</button>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>Loading categories...</p>
) : categories.length === 0 ? (
<p className={styles.empty}>No categories yet. Click + to add one.</p>
) : (
<BlogCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
)}
</div>
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
title={modalTitle}
defaultParentId={defaultParentId}
categories={categories}
editingCategory={editingCategory}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Category"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
</main>
)
}
@@ -0,0 +1,169 @@
.blogDetail {
max-width: 820px;
margin: 0 auto;
}
.heroImageWrap {
width: 100%;
max-width: 640px;
margin: 0 auto 20px;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.heroImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.heroPlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
rgba(148, 163, 184, 0.04) 100%
);
}
.metaRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 16px;
margin-bottom: 28px;
font-size: 14px;
color: var(--text-secondary);
}
.metaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.metaDot {
color: var(--text-muted);
}
.metaChips {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.typeChip {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border-radius: 999px;
}
.categoryChip {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
}
.contentPanel {
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;
margin-bottom: 28px;
}
.content {
font-size: 15px;
line-height: 1.7;
color: var(--text-primary);
}
.content p {
margin: 0 0 1em;
}
.content p:last-child {
margin-bottom: 0;
}
.content ul,
.content ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
margin: 1em 0;
}
.content h1,
.content h2,
.content h3 {
margin: 1.25em 0 0.5em;
line-height: 1.35;
}
.authorRow {
display: flex;
align-items: center;
gap: 8px;
padding-top: 20px;
margin-top: 20px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
font-size: 14px;
color: var(--text-secondary);
}
.authorLabel {
font-weight: 500;
color: var(--text-muted);
}
.authorName {
font-weight: 600;
color: var(--text-primary);
}
.status,
.error {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #b91c1c;
}
.backLink {
display: inline-block;
margin-top: 12px;
font-size: 14px;
color: var(--primary);
}
.backLink:hover {
text-decoration: underline;
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays, User } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { BlogCommentsSection } from '../components/BlogCommentsSection'
import { ApiError } from '../lib/api'
import {
formatBlogAuthor,
formatBlogDate,
getBlogDetail,
} from '../services/blogService'
import type { BlogDetail } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css'
export function BlogDetailsPage() {
const { id } = useParams()
const [blog, setBlog] = useState<BlogDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadBlog() {
setIsLoading(true)
setError('')
try {
const data = await getBlogDetail(id!, controller.signal)
setBlog(data)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load blog post.')
}
setBlog(null)
} finally {
setIsLoading(false)
}
}
void loadBlog()
return () => controller.abort()
}, [id])
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>Loading blog post...</p>
</main>
)
}
if (error || !blog || !id) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || 'Blog post not found.'}</p>
<Link to="/blog/list" className={styles.backLink}>
Back to My Blogs
</Link>
</main>
)
}
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
const typeLabel =
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'My Blogs', href: '/blog/list' },
{ label: blog.title },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{blog.title}</h2>
{blog.abstract && <p className={pageStyles.pageSubtitle}>{blog.abstract}</p>}
</div>
</div>
<article className={styles.blogDetail}>
<div className={styles.heroImageWrap}>
{blog.titleImageUrl ? (
<img
src={blog.titleImageUrl}
alt={blog.title}
className={styles.heroImage}
/>
) : (
<div className={styles.heroPlaceholder} aria-hidden="true" />
)}
</div>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
<CalendarDays size={15} />
{displayDate}
</span>
{(typeLabel || blog.categoryName) && (
<>
<span className={styles.metaDot}>·</span>
<div className={styles.metaChips}>
{typeLabel && <span className={styles.typeChip}>{typeLabel}</span>}
{blog.categoryName && (
<span className={styles.categoryChip}>{blog.categoryName}</span>
)}
</div>
</>
)}
</div>
<div className={styles.contentPanel}>
{blog.mainTextHtml ? (
<div
className={styles.content}
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }}
/>
) : (
<p className={styles.content}>No content yet.</p>
)}
<div className={styles.authorRow}>
<User size={16} />
<span className={styles.authorLabel}>Author</span>
<span className={styles.authorName}>{formatBlogAuthor(blog.author)}</span>
</div>
</div>
<BlogCommentsSection blogId={id} />
</article>
</main>
)
}
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { BlogCard } from '../components/BlogCard'
import { BlogCommentsModal } from '../components/BlogCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
BLOGS_PER_PAGE,
deleteBlog,
listBlogs,
mapBlogApiToUi,
setBlogVerified,
isBlogVerified,
} from '../services/blogService'
import type { Blog } from '../types/blog'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogPage.module.css'
export function BlogListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const [blogs, setBlogs] = useState<Blog[]>([])
const [totalBlogs, setTotalBlogs] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(null)
const [verifyingId, setVerifyingId] = useState<string | null>(null)
const totalPages = Math.max(1, Math.ceil(totalBlogs / BLOGS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadBlogs(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage])
async function loadBlogs(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listBlogs(page, BLOGS_PER_PAGE, signal)
setBlogs(data.items)
setTotalBlogs(data.total)
setCommentCounts(
Object.fromEntries(data.items.map((blog) => [blog.id, blog.commentCount ?? 0])),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load blog posts.')
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/blog/edit/${id}`)
}
function handleComments(id: string) {
const blog = blogs.find((item) => item.id === id)
if (blog) {
setCommentsTarget({ id, title: blog.title })
}
}
function handleRemoveRequest(id: string) {
const blog = blogs.find((item) => item.id === id)
if (blog) {
setDeleteTarget({ id, title: blog.title })
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteBlog(deleteTarget.id)
showToast('Blog post removed.', 'success')
const nextTotal = totalBlogs - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / BLOGS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadBlogs(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete blog post.')
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
async function handleToggleVerify(id: string) {
const blog = blogs.find((item) => item.id === id)
if (!blog) return
const nextVerified = !isBlogVerified(blog)
setVerifyingId(id)
setError('')
try {
const result = await setBlogVerified(id, nextVerified)
setBlogs((prev) =>
prev.map((item) => (item.id === id ? mapBlogApiToUi(result.blog) : item)),
)
showToast(nextVerified ? 'Blog verified.' : 'Blog unverified.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update blog verification.')
}
} finally {
setVerifyingId(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'My Blogs' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Blogs</h2>
<p className={pageStyles.pageSubtitle}>
{totalBlogs} posts · View, edit and manage your blog content.
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>Loading blog posts...</p>
) : blogs.length === 0 ? (
<p className={styles.empty}>No blog posts found.</p>
) : (
<>
<div className={pageStyles.grid}>
{blogs.map((blog) => (
<BlogCard
key={blog.id}
blog={blog}
commentCount={commentCounts[blog.id] ?? blog.commentCount}
onEdit={handleEdit}
onComments={handleComments}
onToggleVerify={handleToggleVerify}
onRemove={handleRemoveRequest}
isVerifying={verifyingId === blog.id}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
<button
type="button"
className={styles.addFab}
onClick={() => navigate('/blog/new')}
aria-label="Add new blog"
>
<Plus size={26} strokeWidth={2.5} />
</button>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Blog Post"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{commentsTarget && (
<BlogCommentsModal
open={!!commentsTarget}
blogId={commentsTarget.id}
blogTitle={commentsTarget.title}
onClose={() => setCommentsTarget(null)}
onCountChange={handleCommentCountChange}
/>
)}
</main>
)
}
@@ -0,0 +1,50 @@
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
.addFab {
position: fixed;
right: 32px;
bottom: 32px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
z-index: 50;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (max-width: 768px) {
.addFab {
right: 20px;
bottom: 20px;
width: 52px;
height: 52px;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { FileText, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import styles from '../components/PageContent.module.css'
const blogSections = [
{
icon: FileText,
title: 'My Blogs',
description: 'View, edit and manage all your blog posts.',
linkText: 'View blogs',
href: '/blog/list',
},
{
icon: PlusCircle,
title: 'Add New Blog',
description: 'Create and publish a new blog post.',
linkText: 'Add blog',
href: '/blog/new',
},
{
icon: FolderTree,
title: 'Blog Categories',
description: 'Organize your blog posts into categories and subcategories.',
linkText: 'View categories',
href: '/blog/categories',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure blog comment moderation and display options.',
linkText: 'View settings',
href: '/blog/settings',
},
]
export function BlogPage() {
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Blog' },
]}
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Blog</h2>
<p className={styles.pageSubtitle}>
Create and manage blog posts and categories.
</p>
</div>
</div>
<div className={styles.gridHome}>
{blogSections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
getSettings,
updateDashboardSettings,
type DashboardSettings,
} from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductSettingsPage.module.css'
const DEFAULT_SETTINGS: DashboardSettings = {
comments: { autoApprove: false },
expertReviews: { autoApprove: false },
}
export function BlogSettingsPage() {
const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.dashboard)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load settings.')
}
} finally {
setIsLoading(false)
}
}
async function handleCommentsAutoApprove(checked: boolean) {
setSavingKey('comments')
setError('')
try {
const data = await updateDashboardSettings({
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save settings.')
}
} finally {
setSavingKey(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Blog', href: '/blog' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Blog settings</h2>
<p className={pageStyles.pageSubtitle}>
Configure how blog comments are moderated.
</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3>
{isLoading ? (
<p className={styles.status}>Loading settings...</p>
) : (
<div className={styles.list}>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="blog-comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments
</label>
<p className={styles.rowDescription}>
New blog comments are published immediately when submitted. You can still
reject them later if needed.
</p>
</div>
<Switch
id="blog-comments-auto-approve"
checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'}
aria-label="Auto-approve blog comments"
onChange={(checked) => void handleCommentsAutoApprove(checked)}
/>
</div>
</div>
)}
</section>
</main>
)
}
@@ -0,0 +1,58 @@
.list {
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;
}
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
.fabDock {
position: fixed;
right: 32px;
bottom: 32px;
z-index: 50;
}
.addFab {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (max-width: 768px) {
.fabDock {
right: 20px;
bottom: 20px;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { BrandModal } from '../components/BrandModal'
import { BrandRow } from '../components/BrandRow'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Brand, BrandFormData } from '../types/brand'
import { ApiError } from '../lib/api'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import {
createBrand,
deleteBrand,
listAllBrands,
mapBrandApiToUi,
toBrandFormPayload,
updateBrand,
} from '../services/brandService'
import pageStyles from '../components/PageContent.module.css'
import styles from './BrandsPage.module.css'
export function BrandsPage() {
const { showToast } = useToast()
const [brands, setBrands] = useState<Brand[]>([])
const [modalOpen, setModalOpen] = useState(false)
const [modalTitle, setModalTitle] = useState('Add Brand')
const [editingBrand, setEditingBrand] = useState<Brand | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Brand | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadBrands(controller.signal)
return () => controller.abort()
}, [])
async function loadBrands(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listAllBrands(signal)
setBrands(items.map(mapBrandApiToUi))
} 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 openCreateModal() {
setEditingBrand(null)
setModalTitle('Add Brand')
setModalOpen(true)
}
function openEditModal(brand: Brand) {
setEditingBrand(brand)
setModalTitle('Edit Brand')
setModalOpen(true)
}
async function handleSubmit(data: BrandFormData) {
setIsSubmitting(true)
setError('')
try {
const resolvedImageMediaId = await resolveDataUrlToMediaId(
data.image,
'brand-logo.png',
data.imageMediaId,
)
if (editingBrand) {
const result = await updateBrand(editingBrand.id, {
...toBrandFormPayload(data),
imageMediaId: resolvedImageMediaId,
})
setBrands((prev) =>
prev.map((brand) =>
brand.id === editingBrand.id ? mapBrandApiToUi(result.brand) : brand,
),
)
showToast('Brand updated.', 'success')
} else {
const result = await createBrand(toBrandFormPayload(data, resolvedImageMediaId))
setBrands((prev) => [...prev, mapBrandApiToUi(result.brand)])
showToast('Brand created.', 'success')
}
setModalOpen(false)
setEditingBrand(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save brand.')
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
await deleteBrand(deleteTarget.id)
setBrands((prev) => prev.filter((brand) => brand.id !== deleteTarget.id))
setDeleteTarget(null)
showToast('Brand deleted.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete brand.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Brands' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Brands</h2>
<p className={pageStyles.pageSubtitle}>
Manage product brands for your store catalog.
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>Loading brands...</p>
) : brands.length === 0 ? (
<p className={styles.empty}>No brands yet. Click + to add one.</p>
) : (
brands.map((brand) => (
<BrandRow
key={brand.id}
brand={brand}
onEdit={(id) => {
const item = brands.find((entry) => entry.id === id)
if (item) openEditModal(item)
}}
onRemove={(id) => {
const item = brands.find((entry) => entry.id === id)
if (item) setDeleteTarget(item)
}}
/>
))
)}
</div>
<BrandModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingBrand(null)
}
}}
onSubmit={handleSubmit}
editingBrand={editingBrand}
title={modalTitle}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Brand"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Products linked to this brand will have their brand cleared.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={styles.addFab}
onClick={openCreateModal}
aria-label="Add brand"
>
<Plus size={24} />
</button>
</div>
</main>
)
}
@@ -0,0 +1,240 @@
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.section {
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 24px;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
}
.subTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sectionHeader .sectionTitle {
margin-bottom: 0;
}
.logoCol {
grid-column: span 12;
}
.basicAside {
grid-column: span 12;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 12px 16px;
align-content: start;
}
.fullRow {
grid-column: span 12;
}
.halfRow {
grid-column: span 12;
}
.duplicatorBlock {
margin-bottom: 24px;
}
.duplicatorBlock:last-child {
margin-bottom: 0;
}
.duplicatorGrid,
.phoneGrid {
display: flex;
flex-direction: column;
}
.duplicatorGrid .gridHeader,
.duplicatorGrid .gridRow {
grid-template-columns: 2fr 2fr 5fr 2fr 2fr 1fr;
}
.phoneGrid .gridHeader,
.phoneGrid .gridRow {
grid-template-columns: 2fr 11fr 1fr;
}
.gridHeader {
display: grid;
gap: 8px 10px;
padding-bottom: 4px;
}
.gridHeader > span {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.gridRow {
display: grid;
gap: 8px 10px;
align-items: center;
padding: 12px 0;
border-top: 1px solid rgba(148, 163, 184, 0.2);
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.textField,
.selectField {
width: 100%;
min-width: 0;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
background-color: rgba(255, 255, 255, 0.7);
}
.selectField {
appearance: none;
-webkit-appearance: none;
-moz-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;
}
.selectFieldFa {
font-family: var(--font-fa), var(--font-en);
direction: rtl;
text-align: right;
}
.selectField:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.addBtn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border-radius: var(--radius-sm);
}
.removeBtn {
width: 34px;
height: 34px;
justify-self: end;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
}
.removeBtn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.actions {
display: flex;
justify-content: flex-end;
}
.saveBtn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: var(--primary);
border-radius: var(--radius-sm);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.status {
font-size: 14px;
color: var(--text-muted);
}
.alertError,
.alertSuccess {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
}
.alertError {
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.alertSuccess {
color: #166534;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
}
@media (min-width: 1280px) {
.logoCol {
grid-column: span 2;
}
.basicAside {
grid-column: span 10;
}
.halfRow {
grid-column: span 6;
}
}
@media (max-width: 900px) {
.gridHeader {
display: none;
}
.duplicatorGrid .gridRow,
.phoneGrid .gridRow {
grid-template-columns: 1fr;
gap: 8px;
padding: 16px 0;
}
}
@@ -0,0 +1,481 @@
import { useEffect, useMemo, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import {
AddressListEditor,
createEmptyAddressItem,
matchCityByName,
matchProvinceByName,
type AddressListItem,
} from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageCropper } from '../components/ImageCropper'
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
import { RichTextEditor } from '../components/RichTextEditor'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import { dispatchBusinessProfileUpdated } from '../lib/businessContext'
import { listBusinessActivityCategories } from '../services/businessActivityCategoryService'
import type { BusinessActivityCategory } from '../services/businessActivityCategoryService'
import {
getBusinessProfile,
updateBusinessProfile,
type BusinessPhoneNumber,
type BusinessSocialMedia,
} from '../services/businessProfileService'
import {
listCitiesByProvinceSlug,
listIranProvinces,
type CityOption,
} from '../services/citiesService'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import { flattenBusinessActivityCategories } from '../utils/businessCategories'
import pageStyles from '../components/PageContent.module.css'
import formStyles from './AddNewProductPage.module.css'
import styles from './BusinessProfilePage.module.css'
type AddressDraft = AddressListItem
const EMPTY_SOCIAL: BusinessSocialMedia = {
whatsapp: '',
telegram: '',
instagram: '',
linkedin: '',
youtube: '',
aparat: '',
}
function createEmptyPhone(): BusinessPhoneNumber {
return { type: 'cell', number: '' }
}
export function BusinessProfilePage() {
const { showToast } = useToast()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [provinces, setProvinces] = useState<CityOption[]>([])
const [citiesByProvince, setCitiesByProvince] = useState<Record<string, CityOption[]>>({})
const [activityCategories, setActivityCategories] = useState<BusinessActivityCategory[]>([])
const [categoryIds, setCategoryIds] = useState<string[]>([])
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [email, setEmail] = useState('')
const [about, setAbout] = useState('')
const [vision, setVision] = useState('')
const [logo, setLogo] = useState<string | null>(null)
const [logoMediaId, setLogoMediaId] = useState<string | null>(null)
const [addresses, setAddresses] = useState<AddressDraft[]>([createEmptyAddressItem()])
const [phoneNumbers, setPhoneNumbers] = useState<BusinessPhoneNumber[]>([
createEmptyPhone(),
])
const [socialMedia, setSocialMedia] = useState<BusinessSocialMedia>(EMPTY_SOCIAL)
const categoryOptions = useMemo(
() => flattenBusinessActivityCategories(activityCategories),
[activityCategories],
)
useEffect(() => {
const controller = new AbortController()
void loadData(controller.signal)
return () => controller.abort()
}, [])
async function loadData(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const [categories, profileData, provinceItems] = await Promise.all([
listBusinessActivityCategories(signal),
getBusinessProfile(signal),
listIranProvinces(signal),
])
setProvinces(provinceItems)
setActivityCategories(categories)
setCategoryIds(profileData.profile.categoryIds)
setNameEn(profileData.profile.nameEn)
setNameFa(profileData.profile.nameFa)
setEmail(profileData.profile.emails[0] ?? '')
setAbout(profileData.profile.about)
setVision(profileData.profile.vision)
setLogo(profileData.profile.logoUrl)
setLogoMediaId(profileData.profile.logoMediaId)
const nextAddresses =
profileData.addresses.length > 0
? profileData.addresses.map((item) => {
const province = matchProvinceByName(item.province, provinceItems)
return {
id: item.id,
provinceSlug: province?.slug ?? '',
province: province?.nameEn ?? item.province,
city: item.city,
address: item.address,
postalCode: item.postalCode,
landline: item.landline ?? '',
}
})
: [createEmptyAddressItem()]
const slugs = [...new Set(nextAddresses.map((item) => item.provinceSlug).filter(Boolean))]
const cityGroups = await Promise.all(
slugs.map(async (slug) => ({
slug,
cities: await listCitiesByProvinceSlug(slug, signal),
})),
)
const citiesMap = Object.fromEntries(cityGroups.map((group) => [group.slug, group.cities]))
setAddresses(
nextAddresses.map((item) => {
if (!item.provinceSlug) return item
const cities = citiesMap[item.provinceSlug] ?? []
const city = matchCityByName(item.city, cities)
return {
...item,
city: city?.nameEn ?? item.city,
}
}),
)
setCitiesByProvince(citiesMap)
setPhoneNumbers(
profileData.profile.phoneNumbers.length > 0
? profileData.profile.phoneNumbers
: [createEmptyPhone()],
)
setSocialMedia({ ...EMPTY_SOCIAL, ...profileData.profile.socialMedia })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load business profile.')
}
} finally {
setIsLoading(false)
}
}
function updateAddress(index: number, patch: Partial<AddressDraft>) {
setAddresses((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
)
}
async function handleProvinceChange(index: number, provinceSlug: string) {
const province = provinces.find((item) => item.slug === provinceSlug)
updateAddress(index, {
provinceSlug,
province: province?.nameEn ?? '',
city: '',
})
if (provinceSlug && !citiesByProvince[provinceSlug]) {
const cities = await listCitiesByProvinceSlug(provinceSlug)
setCitiesByProvince((prev) => ({ ...prev, [provinceSlug]: cities }))
}
}
function addAddress() {
setAddresses((prev) => [...prev, createEmptyAddressItem()])
}
function removeAddress(index: number) {
setAddresses((prev) =>
prev.length === 1 ? [createEmptyAddressItem()] : prev.filter((_, i) => i !== index),
)
}
function updatePhone(index: number, patch: Partial<BusinessPhoneNumber>) {
setPhoneNumbers((prev) =>
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
)
}
function addPhone() {
setPhoneNumbers((prev) => [...prev, createEmptyPhone()])
}
function removePhone(index: number) {
setPhoneNumbers((prev) =>
prev.length === 1 ? [createEmptyPhone()] : prev.filter((_, i) => i !== index),
)
}
function updateSocial(field: keyof BusinessSocialMedia, value: string) {
setSocialMedia((prev) => ({ ...prev, [field]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSaving(true)
setError('')
try {
const nextLogoMediaId = await resolveDataUrlToMediaId(
logo,
'business-logo.png',
logoMediaId,
)
const payloadAddresses = addresses
.filter(
(item) =>
item.province.trim() ||
item.city.trim() ||
item.address.trim() ||
item.postalCode.trim(),
)
.map((item) => ({
id: item.id,
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
postalCode: item.postalCode.trim(),
landline: item.landline?.trim() || null,
}))
const payloadPhones = phoneNumbers
.map((item) => ({
type: item.type,
number: item.number.trim(),
}))
.filter((item) => item.number)
const trimmedEmail = email.trim()
await updateBusinessProfile({
nameEn: nameEn.trim(),
nameFa: nameFa.trim(),
about,
vision,
emails: trimmedEmail ? [trimmedEmail] : [],
phoneNumbers: payloadPhones,
socialMedia,
logoMediaId: nextLogoMediaId,
categoryIds,
addresses: payloadAddresses,
})
showToast('Business profile saved.', 'success')
dispatchBusinessProfileUpdated()
await loadData()
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save business profile.')
}
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>Loading business profile...</p>
</main>
)
}
return (
<main className={pageStyles.content}>
<Breadcrumbs items={[{ label: 'Dashboard', href: '/' }, { label: 'Business Profile' }]} />
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Business profile</h2>
<p className={pageStyles.pageSubtitle}>
Manage your storefront identity, contact details, and social links.
</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<form className={styles.form} onSubmit={(e) => void handleSubmit(e)}>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Basic info</h3>
<div className={formStyles.formGrid}>
<div className={`${formStyles.field} ${styles.logoCol}`}>
<label>Logo</label>
<ImageCropper
value={logo}
onChange={setLogo}
outputFormat="png"
accept="image/png"
uploadLabel="Upload logo"
hint="Transparent PNG recommended"
changeLabel="Change logo"
/>
</div>
<div className={styles.basicAside}>
<div className={`${formStyles.field} ${styles.fullRow}`}>
<label htmlFor="activity-categories">Activity categories</label>
<MultiSelectDropdown
id="activity-categories"
options={categoryOptions}
value={categoryIds}
onChange={setCategoryIds}
placeholder="Select activity categories"
searchable
disabled={categoryOptions.length === 0}
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="name-fa">Name (FA)</label>
<input
id="name-fa"
value={nameFa}
onChange={(e) => setNameFa(e.target.value)}
dir="rtl"
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="name-en">Name (EN)</label>
<input
id="name-en"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
/>
</div>
<div className={`${formStyles.field} ${styles.halfRow}`}>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="info@example.com"
/>
</div>
</div>
<div className={`${formStyles.field} ${formStyles.col12}`}>
<label>About us</label>
<RichTextEditor value={about} onChange={setAbout} />
</div>
<div className={`${formStyles.field} ${formStyles.col12}`}>
<label>Our vision</label>
<RichTextEditor value={vision} onChange={setVision} />
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Contact</h3>
<div className={styles.duplicatorBlock}>
<AddressListEditor
addresses={addresses}
provinces={provinces}
citiesByProvince={citiesByProvince}
onAddressChange={updateAddress}
onProvinceChange={handleProvinceChange}
onAdd={addAddress}
onRemove={removeAddress}
title="Addresses"
/>
</div>
<div className={styles.duplicatorBlock}>
<div className={styles.sectionHeader}>
<h4 className={styles.subTitle}>Phone numbers</h4>
<button type="button" className={styles.addBtn} onClick={addPhone}>
<Plus size={16} />
Add number
</button>
</div>
<div className={styles.phoneGrid}>
<div className={styles.gridHeader}>
<span>Type</span>
<span>Phone number</span>
<span />
</div>
{phoneNumbers.map((item, index) => (
<div key={`phone-${index}`} className={styles.gridRow}>
<select
className={styles.selectField}
value={item.type}
onChange={(e) =>
updatePhone(index, {
type: e.target.value as BusinessPhoneNumber['type'],
})
}
>
<option value="cell">Cell number</option>
<option value="landline">Landline</option>
</select>
<input
className={styles.textField}
value={item.number}
onChange={(e) => updatePhone(index, { number: e.target.value })}
placeholder="Phone number"
/>
<button
type="button"
className={styles.removeBtn}
onClick={() => removePhone(index)}
aria-label="Remove phone number"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
</div>
</section>
<section className={styles.section}>
<h3 className={styles.sectionTitle}>Social media</h3>
<div className={formStyles.formGrid}>
{(
[
['whatsapp', 'WhatsApp'],
['telegram', 'Telegram'],
['instagram', 'Instagram'],
['linkedin', 'LinkedIn'],
['youtube', 'YouTube'],
['aparat', 'Aparat'],
] as const
).map(([key, label]) => (
<div key={key} className={`${formStyles.field} ${formStyles.col4}`}>
<label htmlFor={`social-${key}`}>{label}</label>
<input
id={`social-${key}`}
value={socialMedia[key]}
onChange={(e) => updateSocial(key, e.target.value)}
placeholder={`${label} link or ID`}
/>
</div>
))}
</div>
</section>
<div className={styles.actions}>
<button type="submit" className={styles.saveBtn} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save profile'}
</button>
</div>
</form>
</main>
)
}
@@ -0,0 +1,26 @@
.list {
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;
}
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
+557
View File
@@ -0,0 +1,557 @@
import { useEffect, useState } from 'react'
import { Plus, Sparkles } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { CategoryModal } from '../components/CategoryModal'
import { CategoryAiPromptModal } from '../components/CategoryAiPromptModal'
import { CategoryTree } from '../components/CategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { VariationsModal } from '../components/VariationsModal'
import { TechnicalFormModal } from '../components/TechnicalFormModal'
import type { Category, CategoryFormData } from '../types/category'
import type { Variation } from '../types/variation'
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
import {
listCategoryVariations,
saveCategoryVariations,
} from '../services/categoryVariationsService'
import {
getCategoryTechnicalForm,
mapApiFieldToDraft,
saveCategoryTechnicalForm,
suggestCategoryTechnicalFormByAi,
} from '../services/categoryTechnicalFormService'
import { createId } from '../utils/id'
import { ApiError } from '../lib/api'
import {
createProductCategory,
deleteProductCategory,
generateProductCategoriesByAi,
listProductCategories,
mapProductCategoryToUi,
toCategoryFormPayload,
updateProductCategory,
} from '../services/productCategoryService'
import pageStyles from '../components/PageContent.module.css'
import fabStyles from './MyProductsPage.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoriesPage.module.css'
export function CategoriesPage() {
const { showToast } = useToast()
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState('Add Category')
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
const [variationsTarget, setVariationsTarget] = useState<{
categoryId: string
categoryName: string
} | null>(null)
const [categoryVariations, setCategoryVariations] = useState<Record<string, Variation[]>>({})
const [variationsLoading, setVariationsLoading] = useState(false)
const [variationsSaving, setVariationsSaving] = useState(false)
const [technicalTarget, setTechnicalTarget] = useState<{
categoryId: string
categoryName: string
} | null>(null)
const [categoryTechnicalFields, setCategoryTechnicalFields] = useState<
Record<string, TechnicalFormFieldDraft[]>
>({})
const [technicalLoading, setTechnicalLoading] = useState(false)
const [technicalSaving, setTechnicalSaving] = useState(false)
const [technicalGenerating, setTechnicalGenerating] = useState(false)
const [technicalError, setTechnicalError] = useState('')
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiGenerating, setAiGenerating] = useState(false)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(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 {
setIsLoading(false)
}
}
function openCreateModal(parentId = '', title = 'Add Category') {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title)
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category')
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toCategoryFormPayload(data)
if (editingCategory) {
const result = await updateProductCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapProductCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createProductCategory(payload)
setCategories((prev) => [...prev, mapProductCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save category.')
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deleteProductCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setCategoryVariations((prev) => {
const next = { ...prev }
removed.forEach((id) => delete next[id])
return next
})
setCategoryTechnicalFields((prev) => {
const next = { ...prev }
removed.forEach((id) => delete next[id])
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete category.')
}
} finally {
setIsSubmitting(false)
}
}
function getVariationCount(categoryId: string): number {
const cached = categoryVariations[categoryId]
if (cached) return cached.length
return categories.find((category) => category.id === categoryId)?.variationCount ?? 0
}
function variationCountsMap(): Record<string, number> {
return Object.fromEntries(
categories.map((category) => [category.id, getVariationCount(category.id)]),
)
}
function setVariationCount(categoryId: string, count: number) {
setCategories((prev) =>
prev.map((category) =>
category.id === categoryId ? { ...category, variationCount: count } : category,
),
)
}
async function openVariations(categoryId: string) {
const category = categories.find((c) => c.id === categoryId)
if (!category) return
setVariationsTarget({
categoryId,
categoryName: `${category.nameEn} · ${category.nameFa}`,
})
setVariationsLoading(true)
setError('')
try {
const items = await listCategoryVariations(categoryId)
setCategoryVariations((prev) => ({ ...prev, [categoryId]: items }))
setVariationCount(categoryId, items.length)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load category variations.')
}
} finally {
setVariationsLoading(false)
}
}
function getVariationsForCategory(categoryId: string): Variation[] {
return categoryVariations[categoryId] ?? []
}
function technicalFieldCountsMap(): Record<string, number> {
return Object.fromEntries(
categories.map((category) => [
category.id,
categoryTechnicalFields[category.id]?.length ?? 0,
]),
)
}
async function openTechnicalForm(categoryId: string) {
const category = categories.find((c) => c.id === categoryId)
if (!category) return
setTechnicalTarget({
categoryId,
categoryName: `${category.nameEn} · ${category.nameFa}`,
})
setTechnicalLoading(true)
setTechnicalError('')
try {
const form = await getCategoryTechnicalForm(categoryId)
setCategoryTechnicalFields((prev) => ({
...prev,
[categoryId]: form?.fields.map(mapApiFieldToDraft) ?? [],
}))
} catch (err) {
if (err instanceof ApiError) {
setTechnicalError(err.message)
} else {
setTechnicalError('Unable to load technical form.')
}
} finally {
setTechnicalLoading(false)
}
}
function getTechnicalFieldsForCategory(categoryId: string): TechnicalFormFieldDraft[] {
return categoryTechnicalFields[categoryId] ?? []
}
async function handleGenerateTechnicalForm() {
if (!technicalTarget) return
setTechnicalGenerating(true)
setTechnicalError('')
try {
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId)
const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({
id: createId(),
label: field.label,
type: field.type,
isRequired: field.isRequired,
options:
field.type === 'select' || field.type === 'multi_select'
? field.options?.length
? field.options
: ['']
: [],
}))
setCategoryTechnicalFields((prev) => ({
...prev,
[technicalTarget.categoryId]: drafts,
}))
showToast('Technical form draft generated. Review and save when ready.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setTechnicalError(err.message)
} else {
setTechnicalError('Unable to generate technical form with AI.')
}
} finally {
setTechnicalGenerating(false)
}
}
async function handleSaveTechnicalForm() {
if (!technicalTarget) return
setTechnicalSaving(true)
setTechnicalError('')
try {
const fields = getTechnicalFieldsForCategory(technicalTarget.categoryId)
const saved = await saveCategoryTechnicalForm(technicalTarget.categoryId, fields)
setCategoryTechnicalFields((prev) => ({
...prev,
[technicalTarget.categoryId]: saved?.fields.map(mapApiFieldToDraft) ?? [],
}))
setTechnicalTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setTechnicalError(err.message)
} else {
setTechnicalError('Unable to save technical form.')
}
} finally {
setTechnicalSaving(false)
}
}
async function handleGenerateCategoriesByAi(prompt: string) {
setAiGenerating(true)
setError('')
try {
const result = await generateProductCategoriesByAi(prompt)
const items = await listProductCategories()
const mapped = items.map(mapProductCategoryToUi)
setCategories(mapped)
setExpandedIds(
new Set(mapped.filter((category) => !category.parentId).map((category) => category.id)),
)
showToast(result.message, 'success')
setAiModalOpen(false)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to generate categories with AI.')
}
throw err
} finally {
setAiGenerating(false)
}
}
async function handleSaveVariations() {
if (!variationsTarget) return
setVariationsSaving(true)
setError('')
try {
const saved = await saveCategoryVariations(
variationsTarget.categoryId,
getVariationsForCategory(variationsTarget.categoryId),
)
setCategoryVariations((prev) => ({
...prev,
[variationsTarget.categoryId]: saved,
}))
setVariationCount(variationsTarget.categoryId, saved.length)
setVariationsTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save category variations.')
}
} finally {
setVariationsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Categories</h2>
<p className={pageStyles.pageSubtitle}>
Manage your digital product categories and subcategories.
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>Loading categories...</p>
) : categories.length === 0 ? (
<p className={styles.empty}>No categories yet. Use + or AI to add categories.</p>
) : (
<CategoryTree
categories={categories}
expandedIds={expandedIds}
variationCounts={variationCountsMap()}
technicalFieldCounts={technicalFieldCountsMap()}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onVariations={openVariations}
onTechnicalForm={openTechnicalForm}
onOptions={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
)}
</div>
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
onSubmit={handleSubmit}
categories={categories}
defaultParentId={defaultParentId}
editingCategory={editingCategory}
title={modalTitle}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Category"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
{variationsTarget && (
<VariationsModal
open={!!variationsTarget}
categoryName={variationsTarget.categoryName}
variations={
variationsLoading
? []
: getVariationsForCategory(variationsTarget.categoryId)
}
isSaving={variationsSaving || variationsLoading}
onClose={() => !variationsSaving && setVariationsTarget(null)}
onChange={(variations) => {
setCategoryVariations((prev) => ({
...prev,
[variationsTarget.categoryId]: variations,
}))
setVariationCount(variationsTarget.categoryId, variations.length)
}}
onSave={handleSaveVariations}
/>
)}
{technicalTarget && (
<TechnicalFormModal
open={!!technicalTarget}
categoryName={technicalTarget.categoryName}
fields={
technicalLoading ? [] : getTechnicalFieldsForCategory(technicalTarget.categoryId)
}
isLoading={technicalLoading}
isSaving={technicalSaving}
isGenerating={technicalGenerating}
error={technicalError}
onClose={() => !technicalSaving && !technicalGenerating && setTechnicalTarget(null)}
onChange={(fields) => {
setCategoryTechnicalFields((prev) => ({
...prev,
[technicalTarget.categoryId]: fields,
}))
}}
onSave={() => void handleSaveTechnicalForm()}
onGenerate={() => void handleGenerateTechnicalForm()}
/>
)}
<CategoryAiPromptModal
open={aiModalOpen}
onClose={() => !aiGenerating && setAiModalOpen(false)}
onRun={handleGenerateCategoriesByAi}
isRunning={aiGenerating}
/>
<div className={fabStyles.fabDock}>
<button
type="button"
className={aiStyles.aiFabStrip}
onClick={() => setAiModalOpen(true)}
>
<Sparkles size={18} />
<span>Fill categories by AI</span>
</button>
<button
type="button"
className={fabStyles.addFab}
onClick={() => openCreateModal()}
aria-label="Add category"
>
<Plus size={24} />
</button>
</div>
</main>
)
}
@@ -0,0 +1,260 @@
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.tableHeaderTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.colName {
width: 16%;
}
.colCell {
width: 11%;
}
.colEmail {
width: 14%;
}
.colOrders {
width: 9%;
}
.colTransactions {
width: 11%;
}
.colDate {
width: 10%;
}
.colActions {
width: 206px;
}
.th,
.td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.subText {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
}
.customerName {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.emailCell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
max-width: 0;
}
.dateCell {
white-space: nowrap;
font-size: 12px;
}
.filterCellNarrow {
grid-column: span 2;
max-width: 168px;
}
.thActions {
text-align: center;
white-space: nowrap;
}
.tdActions {
text-align: right;
white-space: nowrap;
padding-right: 10px;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
}
.toggleInActions {
margin-right: 0;
flex-shrink: 0;
display: flex;
align-items: center;
height: 28px;
}
.actionBtn {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.actionBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.actionBtnDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.inactiveRow {
opacity: 0.55;
}
.statusDisabled {
color: #b45309;
font-size: 11px;
font-weight: 600;
}
.pagination {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagerBtns {
display: flex;
gap: 8px;
align-items: center;
}
.pageBtn {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.35);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.pageBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageBtnActive {
border-color: rgba(var(--primary-dark-rgb) / 0.35);
color: var(--primary);
background: rgba(var(--primary-dark-rgb) / 0.08);
}
.errorBanner {
padding: 16px;
color: #b91c1c;
font-size: 13px;
font-weight: 500;
}
.fab {
position: fixed;
right: 32px;
bottom: 32px;
z-index: 50;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
}
.fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (max-width: 768px) {
.fab {
right: 20px;
bottom: 20px;
width: 52px;
height: 52px;
}
}
+515
View File
@@ -0,0 +1,515 @@
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listCustomers,
removeCustomer,
updateCustomerEnabled,
type BusinessCustomerListItem,
type CustomersListResponse,
} from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 7
function formatDate(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(customer: BusinessCustomerListItem) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
return name || '—'
}
function formatOrderCount(count: number | null | undefined) {
if (count == null || count === 0) {
return <span className={styles.subText}>No order yet</span>
}
return count
}
function formatTransactionTotal(total: number | null | undefined) {
if (total == null || total === 0) {
return <span className={styles.subText}></span>
}
return formatIrtPrice(total)
}
export function CustomersPage() {
const { showToast } = useToast()
const [data, setData] = useState<CustomersListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<{ name?: string; cellNumber?: string }>({})
const [page, setPage] = useState(1)
const [draftName, setDraftName] = useState('')
const [draftCell, setDraftCell] = useState('')
const [togglingId, setTogglingId] = useState<string | null>(null)
const [editTarget, setEditTarget] = useState<BusinessCustomerListItem | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<BusinessCustomerListItem | null>(null)
const [removing, setRemoving] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listCustomers(
{
page,
pageSize: PAGE_SIZE,
...appliedFilters,
},
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load customers.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [page, appliedFilters.name, appliedFilters.cellNumber])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
setPage(1)
setAppliedFilters({
...(draftName.trim() ? { name: draftName.trim() } : {}),
...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}),
})
}
function clearFilters() {
setDraftName('')
setDraftCell('')
setPage(1)
setAppliedFilters({})
}
async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) {
setTogglingId(customer.id)
setError('')
try {
await updateCustomerEnabled(customer.id, isEnabled)
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === customer.id ? { ...item, isEnabled } : item,
),
}
})
showToast(
isEnabled
? `"${displayName(customer)}" has been enabled.`
: `"${displayName(customer)}" has been disabled.`,
'success',
)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
} finally {
setTogglingId(null)
}
}
async function confirmRemove() {
if (!removeTarget) return
setRemoving(true)
setError('')
try {
await removeCustomer(removeTarget.id)
setData((prev) => {
if (!prev) return prev
return {
...prev,
total: Math.max(0, prev.total - 1),
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
showToast(`"${displayName(removeTarget)}" has been removed.`, 'success')
setRemoveTarget(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove customer.')
} finally {
setRemoving(false)
}
}
function handleSendSms(customer: BusinessCustomerListItem) {
showToast(`SMS to ${formatCellForDisplay(customer.cellNumber)} is not available yet.`, 'info')
}
function handleTickets(customer: BusinessCustomerListItem) {
showToast(`Tickets for "${displayName(customer)}" are not available yet.`, 'info')
}
function handleCustomerSaved(updated: BusinessCustomerListItem) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
}
})
showToast(`"${displayName(updated)}" has been updated.`, 'success')
}
function handleCustomerCreated(customer: BusinessCustomerListItem) {
setPage(1)
setData((prev) => {
if (!prev) {
return {
items: [customer],
total: 1,
page: 1,
pageSize: PAGE_SIZE,
}
}
return {
...prev,
total: prev.total + 1,
page: 1,
items: [customer, ...prev.items.filter((item) => item.id !== customer.id)].slice(
0,
PAGE_SIZE,
),
}
})
showToast(`"${displayName(customer)}" has been added.`, 'success')
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Customers' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Customers</h2>
<p className={pageStyles.pageSubtitle}>
View customers who have registered or ordered from your business.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<label htmlFor="filter-customer-name">Name</label>
<input
id="filter-customer-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by name"
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCellNarrow}`}>
<label htmlFor="filter-customer-cell">Cell number</label>
<input
id="filter-customer-cell"
value={draftCell}
onChange={(e) => setDraftCell(e.target.value)}
placeholder="0912..."
autoComplete="off"
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Customer list</div>
<div className={styles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
'No customers'
)
) : (
' '
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}>
<colgroup>
<col className={styles.colName} />
<col className={styles.colCell} />
<col className={styles.colEmail} />
<col className={styles.colOrders} />
<col className={styles.colTransactions} />
<col className={styles.colDate} />
<col className={styles.colActions} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>Name</th>
<th className={styles.th}>Cell number</th>
<th className={styles.th}>Email</th>
<th className={styles.th}>Orders</th>
<th className={styles.th}>Transactions</th>
<th className={styles.th}>Date joined</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((customer) => (
<tr
key={customer.id}
className={!customer.isEnabled ? styles.inactiveRow : undefined}
>
<td className={styles.td}>
<div className={styles.customerName}>{displayName(customer)}</div>
{!customer.isEnabled && (
<div className={styles.statusDisabled}>Disabled</div>
)}
</td>
<td className={styles.td}>{formatCellForDisplay(customer.cellNumber)}</td>
<td className={`${styles.td} ${styles.emailCell}`}>
{customer.email ?? <span className={styles.subText}></span>}
</td>
<td className={styles.td}>{formatOrderCount(customer.orderCount)}</td>
<td className={styles.td}>
{formatTransactionTotal(customer.totalTransactionsIrt)}
</td>
<td className={`${styles.td} ${styles.dateCell}`}>
{formatDate(customer.createdAt)}
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<span className={styles.toggleInActions}>
<ToggleSwitch
checked={customer.isEnabled}
disabled={togglingId === customer.id || removing}
size="compact"
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
/>
</span>
<Tooltip label="Edit customer">
<button
type="button"
className={styles.actionBtn}
onClick={() => setEditTarget(customer)}
aria-label="Edit customer"
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label="Send SMS">
<button
type="button"
className={styles.actionBtn}
onClick={() => handleSendSms(customer)}
aria-label="Send SMS"
>
<MessageSquare size={15} />
</button>
</Tooltip>
<Tooltip label="Tickets">
<button
type="button"
className={styles.actionBtn}
onClick={() => handleTickets(customer)}
aria-label="Tickets"
>
<Ticket size={15} />
</button>
</Tooltip>
<Tooltip label="Remove customer">
<button
type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(customer)}
aria-label="Remove customer"
disabled={removing}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<AddCustomerModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={handleCustomerCreated}
/>
<EditCustomerModal
open={editTarget !== null}
customer={editTarget}
onClose={() => setEditTarget(null)}
onSaved={handleCustomerSaved}
/>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove customer?"
message={
removeTarget
? `Remove "${displayName(removeTarget)}" from your business? Their account will not be deleted.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
<button
type="button"
className={styles.fab}
onClick={() => setCreateOpen(true)}
aria-label="Add customer"
>
<Plus size={24} />
</button>
</main>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { CalendarDays } from 'lucide-react'
import {
ShoppingBag,
Store,
Users,
Settings,
FileText,
Briefcase,
Globe,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import { SectionCard } from '../components/SectionCard'
import styles from '../components/PageContent.module.css'
const sections = [
{
icon: ShoppingBag,
title: 'Products',
description: 'Manage your products, inventory and categories.',
linkText: 'View products',
href: '/products',
},
{
icon: Store,
title: 'Store',
description: 'Manage your store settings, pages and themes.',
linkText: 'View store',
href: '/store',
},
{
icon: Users,
title: 'Customers',
description: 'View and manage your customers and their activity.',
linkText: 'View customers',
href: '/customers',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure your store preferences and system settings.',
linkText: 'View settings',
href: '/settings',
},
{
icon: FileText,
title: 'Blog',
description: 'Create and manage blog posts and categories.',
linkText: 'View blog',
href: '/blog',
},
{
icon: Briefcase,
title: 'Portfolios',
description: 'Manage your portfolio items and showcase projects.',
linkText: 'View portfolios',
href: '/portfolios',
},
{
icon: Globe,
title: 'Website',
description: 'Manage contact forms, FAQ, badges, subscriptions, and e-payment.',
linkText: 'View website',
href: '/website',
},
]
function getFormattedDate() {
return new Intl.DateTimeFormat('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
weekday: 'long',
}).format(new Date())
}
export function HomePage() {
const { user } = useAuth()
const firstName = user?.firstName || 'there'
return (
<main className={styles.content}>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>
Welcome back, {firstName}! <span aria-hidden="true">👋</span>
</h2>
<p className={styles.pageSubtitle}>
Here&apos;s what&apos;s happening with your store today.
</p>
</div>
<div className={styles.dateBadge}>
<CalendarDays size={16} />
<span>{getFormattedDate()}</span>
</div>
</div>
<div className={styles.gridHome}>
{sections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,305 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: relative;
}
.card {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 36px 32px 32px;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
}
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 28px;
}
.logo {
display: block;
width: 48px;
height: 48px;
object-fit: contain;
background: transparent;
}
.brandText {
display: flex;
flex-direction: column;
}
.domain {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.appName {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
}
.title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
text-align: center;
margin-bottom: 6px;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
margin-bottom: 28px;
}
.backBtn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 16px;
transition: color 0.2s;
}
.backBtn:hover {
color: var(--primary);
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.inputWrap {
position: relative;
display: flex;
align-items: center;
}
.inputIcon {
position: absolute;
left: 12px;
color: var(--text-muted);
pointer-events: none;
}
.inputWrap input {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) 40px var(--field-padding-y) 38px;
font-size: var(--field-font-size);
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.inputWrap input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.togglePassword {
position: absolute;
right: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
padding: 4px;
transition: color 0.2s;
}
.togglePassword:hover {
color: var(--primary);
}
.formActions {
display: flex;
justify-content: flex-end;
margin-top: -8px;
}
.linkBtn {
font-size: 13px;
font-weight: 600;
color: var(--primary);
transition: opacity 0.2s;
}
.linkBtn:hover {
opacity: 0.8;
}
.submitBtn {
width: 100%;
padding: 13px;
font-size: 15px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 14px rgba(var(--primary-rgb) / 0.35);
transition: transform 0.2s, box-shadow 0.2s;
margin-top: 4px;
}
.submitBtn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(var(--primary-rgb) / 0.4);
}
.secondaryBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
font-size: 14px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.25);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.secondaryBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
}
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--text-muted);
font-size: 13px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.3);
}
.footerText {
margin-top: 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.codeHint {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
padding: 10px 12px;
background: rgba(var(--primary-rgb) / 0.06);
border-radius: var(--radius-sm);
margin-bottom: 4px;
}
.codeHint strong {
color: var(--text-primary);
}
.resendRow {
display: flex;
justify-content: center;
margin-top: -4px;
}
.countdown {
font-size: 13px;
color: var(--text-muted);
}
.error {
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.info {
font-size: 13px;
color: var(--text-secondary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.2);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.domainHint {
text-align: center;
font-size: 12px;
color: var(--text-muted);
margin: -12px 0 20px;
}
.fieldRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.submitBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
@media (max-width: 480px) {
.card {
padding: 28px 20px 24px;
}
.title {
font-size: 22px;
}
}
+700
View File
@@ -0,0 +1,700 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
import { getBusinessDomain } from '../lib/config'
import { setActiveBusiness } from '../lib/businessContext'
import {
logout as logoutRequest,
register,
sendOtp,
verifyOtp,
} from '../services/authService'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './LoginPage.module.css'
type AuthView = 'login' | 'signup' | 'forgot' | 'otp'
type SmsStep = 'phone' | 'code'
export function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const businessDomain = getBusinessDomain()
const [view, setView] = useState<AuthView>('login')
const [smsStep, setSmsStep] = useState<SmsStep>('phone')
const [showPassword, setShowPassword] = useState(false)
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [smsCode, setSmsCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [codeSent, setCodeSent] = useState(false)
const [countdown, setCountdown] = useState(0)
const [error, setError] = useState('')
const [info, setInfo] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
function clearMessages() {
setError('')
setInfo('')
}
function resetForm() {
setPhone('')
setPassword('')
setConfirmPassword('')
setFirstName('')
setLastName('')
setSmsCode('')
setNewPassword('')
setSmsStep('phone')
setCodeSent(false)
setShowPassword(false)
clearMessages()
}
function switchView(next: AuthView) {
resetForm()
setView(next)
}
function startCountdown() {
setCountdown(60)
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
function handleApiError(err: unknown, fallback: string) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(fallback)
}
}
async function handleSendCode() {
clearMessages()
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
const result = await sendOtp(cellNumber)
if (!result.enabled) {
setInfo(result.message)
}
setCodeSent(true)
setSmsStep('code')
startCountdown()
} catch (err) {
handleApiError(err, 'Unable to send verification code.')
} finally {
setIsSubmitting(false)
}
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await login(cellNumber, password)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to sign in. Check your connection and try again.')
} finally {
setIsSubmitting(false)
}
}
async function handleSignup(e: React.FormEvent) {
e.preventDefault()
clearMessages()
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
const data = await register({
cellNumber,
password,
firstName: firstName.trim(),
lastName: lastName.trim(),
domain: businessDomain,
})
if (data.user.dashboard !== 'business' || data.user.businesses.length === 0) {
logoutRequest()
setError(
`${BUSINESS_ACCESS_MESSAGE} Customer registration on ${businessDomain} does not grant dashboard access.`,
)
return
}
setActiveBusiness(data.user)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to create account.')
} finally {
setIsSubmitting(false)
}
}
async function handleResetPassword(e: React.FormEvent) {
e.preventDefault()
clearMessages()
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
setInfo(
'Phone number verified. Full password reset via SMS is not available yet — please contact your administrator or sign in if you remember your password.',
)
setTimeout(() => switchView('login'), 2500)
} catch (err) {
handleApiError(err, 'Unable to verify code.')
} finally {
setIsSubmitting(false)
}
}
async function handleOtpLogin(e: React.FormEvent) {
e.preventDefault()
clearMessages()
setIsSubmitting(true)
try {
const cellNumber = toE164CellNumber(phone)
await verifyOtp(cellNumber, smsCode)
if (!password) {
setError('Enter your account password to complete sign-in after SMS verification.')
return
}
await login(cellNumber, password)
navigate('/')
} catch (err) {
handleApiError(err, 'Unable to sign in with SMS verification.')
} finally {
setIsSubmitting(false)
}
}
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.brand}>
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
<div className={styles.brandText}>
<span className={styles.domain}>Sanihome.ir</span>
<span className={styles.appName}>Meshkee.app</span>
</div>
</div>
<p className={styles.domainHint}>Business domain: {businessDomain}</p>
{view === 'login' && (
<>
<h1 className={styles.title}>Welcome back</h1>
<p className={styles.subtitle}>Sign in with your mobile number</p>
<form className={styles.form} onSubmit={handleLogin}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
<div className={styles.field}>
<label htmlFor="login-phone">Mobile number</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="login-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="login-password">Password</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="login-password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.formActions}>
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('forgot')}
disabled={isSubmitting}
>
Forgot password?
</button>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div className={styles.divider}>
<span>or</span>
</div>
<button
type="button"
className={styles.secondaryBtn}
onClick={() => switchView('otp')}
disabled={isSubmitting}
>
<KeyRound size={18} />
One-time login with SMS
</button>
<p className={styles.footerText}>
Don&apos;t have an account?{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('signup')}
disabled={isSubmitting}
>
Sign up
</button>
</p>
</>
)}
{view === 'signup' && (
<>
<h1 className={styles.title}>Create account</h1>
<p className={styles.subtitle}>Staff accounts are invited by the business owner</p>
<form className={styles.form} onSubmit={handleSignup}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.fieldRow}>
<div className={styles.field}>
<label htmlFor="signup-first">First name</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-first"
type="text"
placeholder="First name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-last">Last name</label>
<div className={styles.inputWrap}>
<User size={18} className={styles.inputIcon} />
<input
id="signup-last"
type="text"
placeholder="Last name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
minLength={2}
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-phone">Mobile number</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="signup-phone"
type="tel"
inputMode="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-password">Password</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-password"
type={showPassword ? 'text' : 'password'}
placeholder="Choose a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
<button
type="button"
className={styles.togglePassword}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
disabled={isSubmitting}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div className={styles.field}>
<label htmlFor="signup-confirm">Confirm password</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="signup-confirm"
type={showPassword ? 'text' : 'password'}
placeholder="Repeat your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Creating account...' : 'Create account'}
</button>
</form>
<p className={styles.footerText}>
Already have an account?{' '}
<button
type="button"
className={styles.linkBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
Sign in
</button>
</p>
</>
)}
{view === 'forgot' && (
<>
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
Back to sign in
</button>
<h1 className={styles.title}>Forgot password</h1>
<p className={styles.subtitle}>
{smsStep === 'phone'
? 'We will send a verification code via SMS'
: 'Enter the code and your new password'}
</p>
<form className={styles.form} onSubmit={handleResetPassword}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{info && <div className={styles.info}>{info}</div>}
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="forgot-phone">Mobile number</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="forgot-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send SMS code'}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong>
</p>
)}
<div className={styles.field}>
<label htmlFor="forgot-code">SMS verification code</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
id="forgot-code"
type="text"
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="forgot-new-password">New password</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter new password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span>
) : (
<button
type="button"
className={styles.linkBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
Resend SMS code
</button>
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Verifying...' : 'Reset password'}
</button>
</>
)}
</form>
</>
)}
{view === 'otp' && (
<>
<button
type="button"
className={styles.backBtn}
onClick={() => switchView('login')}
disabled={isSubmitting}
>
<ArrowLeft size={18} />
Back to sign in
</button>
<h1 className={styles.title}>One-time login</h1>
<p className={styles.subtitle}>
{smsStep === 'phone'
? 'Sign in with a one-time SMS code'
: 'Enter the SMS code and your password'}
</p>
<form className={styles.form} onSubmit={handleOtpLogin}>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{smsStep === 'phone' ? (
<>
<div className={styles.field}>
<label htmlFor="otp-phone">Mobile number</label>
<div className={styles.inputWrap}>
<Smartphone size={18} className={styles.inputIcon} />
<input
id="otp-phone"
type="tel"
inputMode="tel"
placeholder="09122222222"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
</div>
<button
type="button"
className={styles.submitBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send SMS code'}
</button>
</>
) : (
<>
{codeSent && (
<p className={styles.codeHint}>
Verification code sent to <strong>{phone}</strong>
</p>
)}
<div className={styles.field}>
<label htmlFor="otp-code">SMS verification code</label>
<div className={styles.inputWrap}>
<KeyRound size={18} className={styles.inputIcon} />
<input
id="otp-code"
type="text"
inputMode="numeric"
placeholder="123456"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ''))}
required
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="otp-password">Password</label>
<div className={styles.inputWrap}>
<Lock size={18} className={styles.inputIcon} />
<input
id="otp-password"
type={showPassword ? 'text' : 'password'}
placeholder="Your account password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.resendRow}>
{countdown > 0 ? (
<span className={styles.countdown}>Resend code in {countdown}s</span>
) : (
<button
type="button"
className={styles.linkBtn}
onClick={() => void handleSendCode()}
disabled={isSubmitting}
>
Resend SMS code
</button>
)}
</div>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</>
)}
</form>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,86 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
}
.addFab {
position: static;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
flex-shrink: 0;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
.fabDock {
position: fixed;
right: 32px;
bottom: 32px;
display: flex;
align-items: center;
gap: 10px;
z-index: 50;
}
@media (max-width: 768px) {
.fabDock {
right: 20px;
bottom: 20px;
gap: 8px;
}
.addFab {
width: 52px;
height: 52px;
}
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, RotateCcw, Search, Sparkles } from 'lucide-react'
import { ProductCard } from '../components/ProductCard'
import { AddProductByAiModal } from '../components/AddProductByAiModal'
import { Pagination } from '../components/Pagination'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ProductVariantsModal } from '../components/ProductVariantsModal'
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
import { ProductCommentsModal } from '../components/ProductCommentsModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ApiError } from '../lib/api'
import {
deleteProduct,
listProducts,
PRODUCTS_PER_PAGE,
} from '../services/productService'
import type { Product } from '../types/product'
import pageStyles from '../components/PageContent.module.css'
import filterStyles from '../components/ListFiltersPanel.module.css'
import styles from './MyProductsPage.module.css'
import aiStyles from '../styles/ai.module.css'
export function MyProductsPage() {
const navigate = useNavigate()
const [products, setProducts] = useState<Product[]>([])
const [totalProducts, setTotalProducts] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [variantCounts, setVariantCounts] = useState<Record<string, number>>({})
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [currentPage, setCurrentPage] = useState(1)
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
const [variantsTarget, setVariantsTarget] = useState<{
id: string
name: string
categoryId: string
} | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; name: string } | null>(null)
const [technicalTarget, setTechnicalTarget] = useState<{
id: string
name: string
categoryId: string
} | null>(null)
const [aiModalOpen, setAiModalOpen] = useState(false)
const [draftName, setDraftName] = useState('')
const [appliedName, setAppliedName] = useState('')
const totalPages = Math.max(1, Math.ceil(totalProducts / PRODUCTS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadProducts(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage, appliedName])
async function loadProducts(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listProducts(
page,
PRODUCTS_PER_PAGE,
signal,
appliedName ? { name: appliedName } : undefined,
)
setProducts(data.items)
setTotalProducts(data.total)
setVariantCounts(
Object.fromEntries(data.items.map((product) => [product.id, product.variantCount ?? 0])),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load products.')
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/products/edit/${id}`)
}
function handleComments(id: string) {
const product = products.find((p) => p.id === id)
if (product) {
setCommentsTarget({ id, name: product.nameEn })
}
}
function handleVariations(id: string) {
const product = products.find((p) => p.id === id)
if (product) {
setVariantsTarget({
id,
name: product.nameEn,
categoryId: product.categoryId,
})
}
}
function handleTechnicalInfo(id: string) {
const product = products.find((p) => p.id === id)
if (product) {
setTechnicalTarget({
id,
name: product.nameEn,
categoryId: product.categoryId,
})
}
}
function handleRemoveRequest(id: string) {
const product = products.find((p) => p.id === id)
if (product) {
setDeleteTarget({ id, name: product.nameEn })
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteProduct(deleteTarget.id)
const nextTotal = totalProducts - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PRODUCTS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadProducts(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete product.')
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function applyFilters() {
setAppliedName(draftName.trim())
setCurrentPage(1)
}
function clearFilters() {
setDraftName('')
setAppliedName('')
setCurrentPage(1)
}
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Products</h2>
<p className={pageStyles.pageSubtitle}>
{totalProducts} products · View, edit and manage your catalog.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
<label htmlFor="filter-product-name">Name</label>
<input
id="filter-product-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by product name"
disabled={isLoading}
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={isLoading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>Loading products...</p>
) : products.length === 0 ? (
<p className={styles.empty}>
{appliedName ? 'No products match your filters.' : 'No products found.'}
</p>
) : (
<>
<div className={styles.grid}>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
commentCount={commentCounts[product.id] ?? product.commentCount}
variantCount={variantCounts[product.id] ?? product.variantCount ?? 0}
onEdit={handleEdit}
onQuickInfo={() => {}}
onComments={handleComments}
onTechnicalInfo={handleTechnicalInfo}
onVariations={handleVariations}
onRemove={handleRemoveRequest}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Product"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.name}"? This action cannot be undone.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{variantsTarget && (
<ProductVariantsModal
open={!!variantsTarget}
productId={variantsTarget.id}
categoryId={variantsTarget.categoryId}
productName={variantsTarget.name}
onClose={() => setVariantsTarget(null)}
onVariantsChange={(count) => {
setVariantCounts((prev) => ({
...prev,
[variantsTarget.id]: count,
}))
}}
/>
)}
{commentsTarget && (
<ProductCommentsModal
open={!!commentsTarget}
productId={commentsTarget.id}
productName={commentsTarget.name}
onClose={() => setCommentsTarget(null)}
onCountChange={handleCommentCountChange}
/>
)}
{technicalTarget && (
<ProductTechnicalInfoModal
open={!!technicalTarget}
productId={technicalTarget.id}
productName={technicalTarget.name}
categoryId={technicalTarget.categoryId}
onClose={() => setTechnicalTarget(null)}
/>
)}
<AddProductByAiModal
open={aiModalOpen}
onClose={() => setAiModalOpen(false)}
onCreated={(productId) => navigate(`/products/edit/${productId}`)}
/>
<div className={styles.fabDock}>
<button
type="button"
className={aiStyles.aiFabStrip}
onClick={() => setAiModalOpen(true)}
>
<Sparkles size={18} />
<span>Add product by AI</span>
</button>
<Link to="/products/new" className={styles.addFab} aria-label="Add new product">
<Plus size={24} />
</Link>
</div>
</main>
)
}
@@ -0,0 +1,311 @@
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.tableHeaderTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.colOrderId {
width: 14%;
}
.colCustomer {
width: 16%;
}
.colItems {
width: 8%;
}
.colTotal {
width: 11%;
}
.colDate {
width: 12%;
}
.colStep {
width: 14%;
}
.colSource {
width: 10%;
}
.colActions {
width: 128px;
}
.th,
.td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.orderNumber {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.35;
}
.customerName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.35;
}
.customerCell {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.subText {
color: var(--text-secondary);
font-weight: 500;
font-size: 12px;
}
.dateCell {
white-space: nowrap;
font-size: 12px;
line-height: 1.4;
}
.dateTime {
color: var(--text-primary);
}
.dateTimeSub {
color: var(--text-secondary);
font-size: 11px;
}
.sourceBadge {
display: inline-flex;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.sourceOperator {
color: var(--primary-dark, var(--primary));
background: rgba(var(--primary-rgb) / 0.12);
}
.sourceWebsite {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.sourceApplication {
color: #7c3aed;
background: rgba(139, 92, 246, 0.12);
}
.stepBadge {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: filter 0.2s;
}
.stepBadge:hover {
filter: brightness(0.95);
}
.ordersFiltersRow {
grid-column: span 10;
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
gap: 10px;
min-width: 0;
}
.filterOrderId {
flex: 0 1 156px;
min-width: 120px;
}
.filterCustomer {
flex: 0 1 168px;
min-width: 132px;
}
.filterDate {
flex: 0 1 148px;
min-width: 132px;
}
.filterCost {
flex: 0 1 124px;
min-width: 108px;
}
@media (max-width: 1280px) {
.ordersFiltersRow {
flex-wrap: wrap;
}
.filterOrderId,
.filterCustomer,
.filterDate,
.filterCost {
flex: 1 1 140px;
max-width: none;
}
}
.thActions {
text-align: center;
white-space: nowrap;
}
.tdActions {
text-align: right;
white-space: nowrap;
padding-right: 10px;
}
.rowActions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
}
.actionBtn {
width: 28px;
height: 28px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.actionBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.actionBtnDanger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagerBtns {
display: flex;
gap: 8px;
align-items: center;
}
.pageBtn {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.35);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.pageBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageBtnActive {
border-color: rgba(var(--primary-dark-rgb) / 0.35);
color: var(--primary);
background: rgba(var(--primary-dark-rgb) / 0.08);
}
.errorBanner {
padding: 16px;
color: #b91c1c;
font-size: 13px;
font-weight: 500;
}
+618
View File
@@ -0,0 +1,618 @@
import { useEffect, useMemo, useState } from 'react'
import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderStepModal } from '../components/OrderStepModal'
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listOrders,
removeOrder,
type Order,
type OrdersListResponse,
type OrderSource,
} from '../services/orderService'
import {
getSettings,
DEFAULT_ORDER_PROCESS_STEPS,
type OrderProcessStep,
} from '../services/settingsService'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import { stepBadgeStyle } from '../utils/stepColors'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './OrdersPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 8
interface AppliedFilters {
orderNumber?: string
customerQuery?: string
dateFrom?: string
dateTo?: string
minTotal?: number
maxTotal?: number
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(order: Order) {
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
return name || '—'
}
function formatDateTime(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
}
}
function totalItemQuantity(order: Order) {
return order.items.reduce((sum, item) => sum + item.quantity, 0)
}
function sourceLabel(source: OrderSource) {
switch (source) {
case 'admin':
return 'Operator'
case 'app':
return 'Application'
case 'website':
default:
return 'Website'
}
}
function sourceClass(source: OrderSource) {
switch (source) {
case 'admin':
return styles.sourceOperator
case 'app':
return styles.sourceApplication
case 'website':
default:
return styles.sourceWebsite
}
}
function stepLabel(
steps: OrderProcessStep[],
processStepId: string,
processStepLabel?: string | null,
) {
if (processStepLabel?.trim()) return processStepLabel.trim()
return steps.find((step) => step.id === processStepId)?.label ?? processStepId
}
function stepColor(
steps: OrderProcessStep[],
processStepId: string,
processStepColor?: string | null,
) {
if (processStepColor?.trim()) return processStepColor.trim()
const index = steps.findIndex((step) => step.id === processStepId)
const step = index >= 0 ? steps[index] : steps[0]
if (step?.color) return step.color
if (typeof document !== 'undefined') {
const themed = getComputedStyle(document.documentElement)
.getPropertyValue('--primary')
.trim()
if (themed) return themed
}
return '#3b82f6'
}
export function OrdersPage() {
const { showToast } = useToast()
const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({})
const [page, setPage] = useState(1)
const [draftOrderId, setDraftOrderId] = useState('')
const [draftCustomer, setDraftCustomer] = useState('')
const [draftDateFrom, setDraftDateFrom] = useState('')
const [draftDateTo, setDraftDateTo] = useState('')
const [draftMinCost, setDraftMinCost] = useState('')
const [draftMaxCost, setDraftMaxCost] = useState('')
const [viewOrder, setViewOrder] = useState<Order | null>(null)
const [transactionsOrder, setTransactionsOrder] = useState<Order | null>(null)
const [stepOrder, setStepOrder] = useState<Order | null>(null)
const [processSteps, setProcessSteps] = useState<OrderProcessStep[]>(DEFAULT_ORDER_PROCESS_STEPS)
const [removeTarget, setRemoveTarget] = useState<Order | null>(null)
const [removing, setRemoving] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function loadSteps() {
try {
const data = await getSettings(controller.signal)
setProcessSteps(data.settings.store.orderProcessSteps)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
}
}
void loadSteps()
return () => controller.abort()
}, [])
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listOrders(
{
page,
pageSize: PAGE_SIZE,
...appliedFilters,
},
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load orders.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [
page,
appliedFilters.orderNumber,
appliedFilters.customerQuery,
appliedFilters.dateFrom,
appliedFilters.dateTo,
appliedFilters.minTotal,
appliedFilters.maxTotal,
])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
const minTotal = parseIrtInput(draftMinCost)
const maxTotal = parseIrtInput(draftMaxCost)
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
setError('Minimum cost cannot be greater than maximum cost.')
return
}
setPage(1)
setAppliedFilters({
...(draftOrderId.trim() ? { orderNumber: draftOrderId.trim() } : {}),
...(draftCustomer.trim() ? { customerQuery: draftCustomer.trim() } : {}),
...(draftDateFrom ? { dateFrom: draftDateFrom } : {}),
...(draftDateTo ? { dateTo: draftDateTo } : {}),
...(minTotal !== null ? { minTotal } : {}),
...(maxTotal !== null ? { maxTotal } : {}),
})
}
function clearFilters() {
setDraftOrderId('')
setDraftCustomer('')
setDraftDateFrom('')
setDraftDateTo('')
setDraftMinCost('')
setDraftMaxCost('')
setPage(1)
setAppliedFilters({})
setError('')
}
function handleStepSaved(updated: Order) {
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
}
})
}
async function confirmRemove() {
if (!removeTarget) return
setRemoving(true)
setError('')
try {
await removeOrder(removeTarget.id)
setData((prev) => {
if (!prev) return prev
return {
...prev,
total: Math.max(0, prev.total - 1),
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
showToast(`Order ${removeTarget.orderNumber} has been removed.`, 'success')
setRemoveTarget(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove order.')
} finally {
setRemoving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'Orders' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Orders</h2>
<p className={pageStyles.pageSubtitle}>
Track customer orders placed through your website, application, or by operators.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={styles.ordersFiltersRow}>
<div className={`${filterStyles.field} ${styles.filterOrderId}`}>
<label htmlFor="filter-order-id">Order ID</label>
<input
id="filter-order-id"
value={draftOrderId}
onChange={(e) => setDraftOrderId(e.target.value)}
placeholder="ORD-..."
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
<label htmlFor="filter-order-customer">Customer name or number</label>
<input
id="filter-order-customer"
value={draftCustomer}
onChange={(e) => setDraftCustomer(e.target.value)}
placeholder="Name or phone"
autoComplete="off"
/>
</div>
<div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-order-date-from">Date from</label>
<input
id="filter-order-date-from"
type="date"
value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)}
/>
</div>
<div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-order-date-to">Date to</label>
<input
id="filter-order-date-to"
type="date"
value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)}
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-order-min-cost">Min cost (IRT)</label>
<input
id="filter-order-min-cost"
type="text"
inputMode="numeric"
value={draftMinCost}
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
placeholder="0"
autoComplete="off"
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-order-max-cost">Max cost (IRT)</label>
<input
id="filter-order-max-cost"
type="text"
inputMode="numeric"
value={draftMaxCost}
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
placeholder="0"
autoComplete="off"
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Order list</div>
<div className={styles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
'No orders'
)
) : (
' '
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}>
<colgroup>
<col className={styles.colOrderId} />
<col className={styles.colCustomer} />
<col className={styles.colItems} />
<col className={styles.colTotal} />
<col className={styles.colDate} />
<col className={styles.colStep} />
<col className={styles.colSource} />
<col className={styles.colActions} />
</colgroup>
<thead>
<tr>
<th className={styles.th}>Order ID</th>
<th className={styles.th}>Customer</th>
<th className={styles.th}>Items</th>
<th className={styles.th}>Total cost</th>
<th className={styles.th}>Date & time</th>
<th className={styles.th}>Step</th>
<th className={styles.th}>Registered by</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((order) => {
const { date, time } = formatDateTime(order.createdAt)
const itemQty = totalItemQuantity(order)
return (
<tr key={order.id}>
<td className={styles.td}>
<div className={styles.orderNumber}>{order.orderNumber}</div>
</td>
<td className={styles.td}>
<div className={styles.customerName}>{displayName(order)}</div>
<div className={styles.customerCell}>
{formatCellForDisplay(order.customer.cellNumber)}
</div>
</td>
<td className={styles.td}>
{itemQty > 0 ? itemQty : <span className={styles.subText}>0</span>}
</td>
<td className={styles.td}>{formatIrtPrice(order.total)}</td>
<td className={`${styles.td} ${styles.dateCell}`}>
<div className={styles.dateTime}>{date}</div>
{time && <div className={styles.dateTimeSub}>{time}</div>}
</td>
<td className={styles.td}>
<button
type="button"
className={styles.stepBadge}
style={stepBadgeStyle(
stepColor(
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepColor,
),
)}
onClick={() => setStepOrder(order)}
aria-label={`Change step: ${stepLabel(
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepLabel,
)}`}
>
{stepLabel(
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepLabel,
)}
</button>
</td>
<td className={styles.td}>
<span className={`${styles.sourceBadge} ${sourceClass(order.source)}`}>
{sourceLabel(order.source)}
</span>
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<Tooltip label="View items">
<button
type="button"
className={styles.actionBtn}
onClick={() => setViewOrder(order)}
aria-label="View items"
>
<Eye size={15} />
</button>
</Tooltip>
<Tooltip label="Transaction details">
<button
type="button"
className={styles.actionBtn}
onClick={() => setTransactionsOrder(order)}
aria-label="Transaction details"
>
<CreditCard size={15} />
</button>
</Tooltip>
<Tooltip label="Remove order">
<button
type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(order)}
aria-label="Remove order"
disabled={removing}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<OrderItemsModal
open={viewOrder !== null}
order={viewOrder}
onClose={() => setViewOrder(null)}
/>
<OrderTransactionsModal
open={transactionsOrder !== null}
order={transactionsOrder}
onClose={() => setTransactionsOrder(null)}
/>
<OrderStepModal
open={stepOrder !== null}
order={stepOrder}
steps={processSteps}
onClose={() => setStepOrder(null)}
onSaved={handleStepSaved}
/>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove order?"
message={
removeTarget
? `Remove order ${removeTarget.orderNumber}? This cannot be undone.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
</main>
)
}
@@ -0,0 +1,222 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { CategoryModal } from '../components/CategoryModal'
import { BlogCategoryTree } from '../components/BlogCategoryTree'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api'
import {
createPortfolioCategory,
deletePortfolioCategory,
listPortfolioCategories,
mapPortfolioCategoryToUi,
toPortfolioCategoryFormPayload,
updatePortfolioCategory,
} from '../services/portfolioCategoryService'
import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css'
export function PortfolioCategoriesPage() {
const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('')
const [modalTitle, setModalTitle] = useState('Add Category')
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [deleteTarget, setDeleteTarget] = useState<Category | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadCategories(controller.signal)
return () => controller.abort()
}, [])
async function loadCategories(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const items = await listPortfolioCategories(signal)
setCategories(items.map(mapPortfolioCategoryToUi))
} 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 openCreateModal(parentId = '', title = 'Add Category') {
setEditingCategory(null)
setDefaultParentId(parentId)
setModalTitle(title)
setModalOpen(true)
if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId))
}
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(data: CategoryFormData) {
setIsSubmitting(true)
setError('')
try {
const payload = toPortfolioCategoryFormPayload(data)
if (editingCategory) {
const result = await updatePortfolioCategory(editingCategory.id, {
...payload,
parentId: data.parentId || null,
})
setCategories((prev) =>
prev.map((category) =>
category.id === editingCategory.id
? mapPortfolioCategoryToUi(result.category)
: category,
),
)
} else {
const result = await createPortfolioCategory(payload)
setCategories((prev) => [...prev, mapPortfolioCategoryToUi(result.category)])
if (data.parentId) {
setExpandedIds((prev) => new Set(prev).add(data.parentId))
}
}
setModalOpen(false)
setEditingCategory(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save category.')
}
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsSubmitting(true)
setError('')
try {
const result = await deletePortfolioCategory(deleteTarget.id)
const removed = new Set(result.deletedIds)
setCategories((prev) => prev.filter((category) => !removed.has(category.id)))
setExpandedIds((prev) => {
const next = new Set(prev)
removed.forEach((id) => next.delete(id))
return next
})
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete category.')
}
} finally {
setIsSubmitting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Portfolio Categories</h2>
<p className={pageStyles.pageSubtitle}>
Organize your portfolio items into categories and subcategories.
</p>
</div>
<button
className={styles.addBtn}
onClick={() => openCreateModal()}
aria-label="Add category"
>
<Plus size={22} strokeWidth={2.5} />
</button>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.list}>
{isLoading ? (
<p className={styles.empty}>Loading categories...</p>
) : categories.length === 0 ? (
<p className={styles.empty}>No categories yet. Click + to add one.</p>
) : (
<BlogCategoryTree
categories={categories}
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
}}
/>
)}
</div>
<CategoryModal
open={modalOpen}
onClose={() => {
if (!isSubmitting) {
setModalOpen(false)
setEditingCategory(null)
}
}}
title={modalTitle}
defaultParentId={defaultParentId}
categories={categories}
editingCategory={editingCategory}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Category"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isSubmitting && setDeleteTarget(null)}
/>
</main>
)
}
@@ -0,0 +1,217 @@
.portfolioDetail {
max-width: 820px;
margin: 0 auto;
}
.heroImageWrap {
width: 100%;
max-width: 640px;
margin: 0 auto 20px;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: var(--radius);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.12);
}
.heroImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.heroPlaceholder {
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
rgba(148, 163, 184, 0.04) 100%
);
}
.metaRow {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 8px 16px;
margin-bottom: 28px;
font-size: 14px;
color: var(--text-secondary);
}
.metaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.metaDot {
color: var(--text-muted);
}
.metaChips {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.categoryChip {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 999px;
}
.tagChip {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border-radius: 999px;
}
.contentPanel {
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;
}
.content {
font-size: 15px;
line-height: 1.7;
color: var(--text-primary);
}
.content p {
margin: 0 0 1em;
}
.content p:last-child {
margin-bottom: 0;
}
.content ul,
.content ol {
padding-left: 1.5em;
margin: 0.75em 0;
}
.content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm);
margin: 1em 0;
}
.content h1,
.content h2,
.content h3 {
margin: 1.25em 0 0.5em;
line-height: 1.35;
}
.galleryBand {
width: 100%;
margin: 32px 0;
padding: 48px 32px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--glass-border);
border-bottom: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.galleryInner {
width: 100%;
margin: 0 auto;
}
.galleryTitle {
margin: 0 0 20px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
}
.galleryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.galleryItem {
aspect-ratio: 4 / 3;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: var(--radius);
overflow: hidden;
background: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.galleryItem:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
.galleryItem img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.commentsWrap {
max-width: 820px;
margin: 0 auto;
}
.status,
.error {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #b91c1c;
}
.backLink {
display: inline-block;
margin-top: 12px;
font-size: 14px;
color: var(--primary);
}
.backLink:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.galleryBand {
margin: 24px 0;
padding: 32px 16px;
}
.galleryGrid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { CalendarDays } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection'
import { ApiError } from '../lib/api'
import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService'
import type { PortfolioDetail } from '../types/portfolio'
import pageStyles from '../components/PageContent.module.css'
import styles from './PortfolioDetailsPage.module.css'
export function PortfolioDetailsPage() {
const { id } = useParams()
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadPortfolio() {
setIsLoading(true)
setError('')
try {
const data = await getPortfolioDetail(id!, controller.signal)
setPortfolio(data)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load portfolio.')
}
setPortfolio(null)
} finally {
setIsLoading(false)
}
}
void loadPortfolio()
return () => controller.abort()
}, [id])
const galleryImages = useMemo(
() => portfolio?.gallery.map((item) => item.url).filter(Boolean) ?? [],
[portfolio],
)
function openLightbox(index: number) {
setLightboxIndex(index)
setLightboxOpen(true)
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>Loading portfolio...</p>
</main>
)
}
if (error || !portfolio || !id) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || 'Portfolio not found.'}</p>
<Link to="/portfolios/list" className={styles.backLink}>
Back to My Portfolios
</Link>
</main>
)
}
const displayDate = formatPortfolioDate(portfolio.publishedAt ?? portfolio.createdAt)
return (
<>
<div className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'My Portfolios', href: '/portfolios/list' },
{ label: portfolio.title },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{portfolio.title}</h2>
{portfolio.abstract && (
<p className={pageStyles.pageSubtitle}>{portfolio.abstract}</p>
)}
</div>
</div>
<article className={styles.portfolioDetail}>
<div className={styles.heroImageWrap}>
{portfolio.titleImageUrl ? (
<img
src={portfolio.titleImageUrl}
alt={portfolio.title}
className={styles.heroImage}
/>
) : (
<div className={styles.heroPlaceholder} aria-hidden="true" />
)}
</div>
<div className={styles.metaRow}>
<span className={styles.metaItem}>
<CalendarDays size={15} />
{displayDate}
</span>
{(portfolio.categoryName || portfolio.tags.length > 0) && (
<>
<span className={styles.metaDot}>·</span>
<div className={styles.metaChips}>
{portfolio.categoryName && (
<span className={styles.categoryChip}>{portfolio.categoryName}</span>
)}
{portfolio.tags.map((tag) => (
<span key={tag} className={styles.tagChip}>
{tag}
</span>
))}
</div>
</>
)}
</div>
<div className={styles.contentPanel}>
{portfolio.mainTextHtml ? (
<div
className={styles.content}
dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }}
/>
) : (
<p className={styles.content}>No content yet.</p>
)}
</div>
</article>
</div>
{galleryImages.length > 0 && (
<section className={styles.galleryBand} aria-label="Portfolio gallery">
<div className={styles.galleryInner}>
<h3 className={styles.galleryTitle}>Gallery</h3>
<div className={styles.galleryGrid}>
{galleryImages.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={styles.galleryItem}
onClick={() => openLightbox(index)}
aria-label={`View gallery image ${index + 1}`}
>
<img src={src} alt={`${portfolio.title} gallery ${index + 1}`} loading="lazy" />
</button>
))}
</div>
</div>
</section>
)}
<div className={pageStyles.content}>
<div className={styles.commentsWrap}>
<PortfolioCommentsSection portfolioId={id} />
</div>
</div>
{galleryImages.length > 0 && (
<ImageLightbox
open={lightboxOpen}
images={galleryImages}
initialIndex={lightboxIndex}
alt={portfolio.title}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
)
}
@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PortfolioCard } from '../components/PortfolioCard'
import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
PORTFOLIOS_PER_PAGE,
deletePortfolio,
listPortfolios,
} from '../services/portfolioService'
import type { Portfolio } from '../types/portfolio'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogPage.module.css'
export function PortfolioListPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
const [totalPortfolios, setTotalPortfolios] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({})
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>(
null,
)
const totalPages = Math.max(1, Math.ceil(totalPortfolios / PORTFOLIOS_PER_PAGE))
useEffect(() => {
const controller = new AbortController()
void loadPortfolios(currentPage, controller.signal)
return () => controller.abort()
}, [currentPage])
async function loadPortfolios(page: number, signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listPortfolios(page, PORTFOLIOS_PER_PAGE, signal)
setPortfolios(data.items)
setTotalPortfolios(data.total)
setCommentCounts(
Object.fromEntries(data.items.map((item) => [item.id, item.commentCount ?? 0])),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load portfolios.')
}
} finally {
setIsLoading(false)
}
}
function handleEdit(id: string) {
navigate(`/portfolios/edit/${id}`)
}
function handleComments(id: string) {
const portfolio = portfolios.find((item) => item.id === id)
if (portfolio) {
setCommentsTarget({ id, title: portfolio.title })
}
}
function handleRemoveRequest(id: string) {
const portfolio = portfolios.find((item) => item.id === id)
if (portfolio) {
setDeleteTarget({ id, title: portfolio.title })
}
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deletePortfolio(deleteTarget.id)
showToast('Portfolio removed.', 'success')
const nextTotal = totalPortfolios - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
setDeleteTarget(null)
setCurrentPage(nextPage)
await loadPortfolios(nextPage)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete portfolio.')
}
} finally {
setIsDeleting(false)
}
}
function handlePageChange(page: number) {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function handleCommentCountChange(count: number) {
if (!commentsTarget) return
setCommentCounts((prev) => ({
...prev,
[commentsTarget.id]: count,
}))
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'My Portfolios' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Portfolios</h2>
<p className={pageStyles.pageSubtitle}>
{totalPortfolios} items · View, edit and manage your portfolio content.
</p>
</div>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
{isLoading ? (
<p className={styles.empty}>Loading portfolios...</p>
) : portfolios.length === 0 ? (
<p className={styles.empty}>No portfolio items found.</p>
) : (
<>
<div className={pageStyles.grid}>
{portfolios.map((portfolio) => (
<PortfolioCard
key={portfolio.id}
portfolio={portfolio}
commentCount={commentCounts[portfolio.id] ?? portfolio.commentCount}
onEdit={handleEdit}
onComments={handleComments}
onRemove={handleRemoveRequest}
/>
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
</>
)}
<button
type="button"
className={styles.addFab}
onClick={() => navigate('/portfolios/new')}
aria-label="Add new portfolio"
>
<Plus size={26} strokeWidth={2.5} />
</button>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Delete Portfolio"
message={
deleteTarget
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.`
: ''
}
onConfirm={confirmDelete}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
{commentsTarget && (
<PortfolioCommentsModal
open={!!commentsTarget}
portfolioId={commentsTarget.id}
portfolioTitle={commentsTarget.title}
onClose={() => setCommentsTarget(null)}
onCountChange={handleCommentCountChange}
/>
)}
</main>
)
}
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
getSettings,
updateDashboardSettings,
type DashboardSettings,
} from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductSettingsPage.module.css'
const DEFAULT_SETTINGS: DashboardSettings = {
comments: { autoApprove: false },
expertReviews: { autoApprove: false },
}
export function PortfolioSettingsPage() {
const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.dashboard)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load settings.')
}
} finally {
setIsLoading(false)
}
}
async function handleCommentsAutoApprove(checked: boolean) {
setSavingKey('comments')
setError('')
try {
const data = await updateDashboardSettings({
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save settings.')
}
} finally {
setSavingKey(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios', href: '/portfolios' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Portfolio settings</h2>
<p className={pageStyles.pageSubtitle}>
Configure how portfolio comments are moderated.
</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3>
{isLoading ? (
<p className={styles.status}>Loading settings...</p>
) : (
<div className={styles.list}>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="portfolio-comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments
</label>
<p className={styles.rowDescription}>
New portfolio comments are published immediately when submitted. You can still
reject them later if needed.
</p>
</div>
<Switch
id="portfolio-comments-auto-approve"
checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'}
aria-label="Auto-approve portfolio comments"
onChange={(checked) => void handleCommentsAutoApprove(checked)}
/>
</div>
</div>
)}
</section>
</main>
)
}
@@ -0,0 +1,62 @@
import { Briefcase, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import styles from '../components/PageContent.module.css'
const portfolioSections = [
{
icon: Briefcase,
title: 'My Portfolios',
description: 'View, edit and manage all your portfolio items.',
linkText: 'View portfolios',
href: '/portfolios/list',
},
{
icon: PlusCircle,
title: 'Add New Portfolio',
description: 'Create and publish a new portfolio project.',
linkText: 'Add portfolio',
href: '/portfolios/new',
},
{
icon: FolderTree,
title: 'Portfolio Categories',
description: 'Organize portfolio items into categories and subcategories.',
linkText: 'View categories',
href: '/portfolios/categories',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure portfolio comment moderation and display options.',
linkText: 'View settings',
href: '/portfolios/settings',
},
]
export function PortfoliosPage() {
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Portfolios' },
]}
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Portfolios</h2>
<p className={styles.pageSubtitle}>
Manage your portfolio items and showcase projects.
</p>
</div>
</div>
<div className={styles.gridHome}>
{portfolioSections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,213 @@
.layout {
display: grid;
grid-template-columns: minmax(0, 320px) 1fr;
gap: 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);
padding: 24px;
}
.gallery {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 320px;
}
.mainImage {
position: relative;
width: 100%;
max-width: 320px;
aspect-ratio: 1 / 1;
background: #ffffff;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.15);
overflow: hidden;
padding: 0;
cursor: zoom-in;
transition: box-shadow 0.2s, border-color 0.2s;
}
.mainImage:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb) / 0.35);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.12);
}
.mainImage:disabled {
cursor: default;
}
.mainImage img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 14px;
}
.draftBadge {
position: absolute;
top: 12px;
left: 12px;
padding: 4px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: #b45309;
background: rgba(251, 191, 36, 0.9);
border-radius: 50px;
}
.thumbnails {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.thumb {
width: 52px;
height: 52px;
background: #fff;
border: 2px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
overflow: hidden;
padding: 0;
cursor: pointer;
transition: border-color 0.2s, transform 0.2s;
}
.thumb:hover {
border-color: rgba(var(--primary-rgb) / 0.35);
}
.thumbActive {
border-color: var(--primary);
transform: translateY(-1px);
}
.thumb img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
.details {
display: flex;
flex-direction: column;
gap: 16px;
}
.categoryChip {
align-self: flex-start;
padding: 5px 14px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
}
.nameEn {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.25;
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 18px;
color: var(--text-secondary);
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.summary {
font-size: 15px;
line-height: 1.6;
color: var(--text-secondary);
padding-bottom: 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.description {
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
}
.description p {
margin-bottom: 0.75em;
}
.description p:last-child {
margin-bottom: 0;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border-radius: 50px;
}
.status,
.error {
padding: 16px 0;
font-size: 14px;
color: var(--text-secondary);
}
.error {
color: #b91c1c;
}
.backLink {
display: inline-block;
margin-top: 8px;
font-size: 14px;
font-weight: 600;
color: var(--primary);
}
.imagePlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-muted);
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
padding: 20px;
}
.gallery {
max-width: none;
}
.mainImage {
max-width: none;
}
.nameEn {
font-size: 22px;
}
}
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox'
import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { ApiError } from '../lib/api'
import {
getProduct,
mapProductApiToUi,
} from '../services/productService'
import type { Product } from '../types/product'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.module.css'
export function ProductDetailsPage() {
const { id } = useParams()
const [product, setProduct] = useState<Product | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
useEffect(() => {
if (!id) return
const controller = new AbortController()
async function loadProduct() {
setIsLoading(true)
setError('')
try {
const data = await getProduct(id!, controller.signal)
setProduct(mapProductApiToUi(data))
setActiveIndex(0)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load product.')
}
setProduct(null)
} finally {
setIsLoading(false)
}
}
void loadProduct()
return () => controller.abort()
}, [id])
const galleryImages = useMemo(() => product?.images ?? [], [product])
const currentImage = galleryImages[activeIndex] ?? galleryImages[0] ?? ''
function openLightbox(index: number) {
setLightboxIndex(index)
setLightboxOpen(true)
}
if (isLoading) {
return (
<main className={pageStyles.content}>
<p className={styles.status}>Loading product...</p>
</main>
)
}
if (error || !product) {
return (
<main className={pageStyles.content}>
<p className={styles.error}>{error || 'Product not found.'}</p>
<Link to="/products/list" className={styles.backLink}>
Back to My Products
</Link>
</main>
)
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'My Products', href: '/products/list' },
{ label: product.nameEn },
]}
/>
<div className={styles.layout}>
<div className={styles.gallery}>
<button
type="button"
className={styles.mainImage}
onClick={() => currentImage && openLightbox(activeIndex)}
aria-label="Open image gallery"
disabled={!currentImage}
>
{currentImage ? (
<img src={currentImage} alt={product.nameEn} />
) : (
<div className={styles.imagePlaceholder}>No image</div>
)}
{product.status === 'draft' && <span className={styles.draftBadge}>Draft</span>}
</button>
{galleryImages.length > 1 && (
<div className={styles.thumbnails}>
{galleryImages.map((src, index) => (
<button
key={`${src}-${index}`}
type="button"
className={`${styles.thumb} ${index === activeIndex ? styles.thumbActive : ''}`}
onClick={() => {
setActiveIndex(index)
openLightbox(index)
}}
aria-label={`View image ${index + 1}`}
>
<img src={src} alt="" />
</button>
))}
</div>
)}
</div>
<div className={styles.details}>
{product.category && <span className={styles.categoryChip}>{product.category}</span>}
<h1 className={styles.nameEn}>{product.nameEn}</h1>
{product.nameFa && <p className={styles.nameFa}>{product.nameFa}</p>}
{product.summary && <p className={styles.summary}>{product.summary}</p>}
{product.description && (
<div
className={styles.description}
dangerouslySetInnerHTML={{ __html: product.description }}
/>
)}
{product.tags.length > 0 && (
<div className={styles.tags}>
{product.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
</div>
)}
</div>
</div>
<ProductDetailsTabs productId={product.id} commentCount={product.commentCount} />
<ImageLightbox
open={lightboxOpen}
images={galleryImages}
initialIndex={lightboxIndex}
alt={product.nameEn}
onClose={() => setLightboxOpen(false)}
/>
</main>
)
}
@@ -0,0 +1,89 @@
.panel {
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 24px;
}
.sectionTitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
}
.list {
display: flex;
flex-direction: column;
gap: 20px;
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.row:last-child {
padding-bottom: 0;
border-bottom: none;
}
.rowText {
flex: 1;
min-width: 0;
}
.rowLabel {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
cursor: pointer;
}
.rowDescription {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.status {
font-size: 14px;
color: var(--text-muted);
}
.alertError,
.alertSuccess {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
}
.alertError {
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.alertSuccess {
color: #166534;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
}
@media (max-width: 768px) {
.row {
flex-direction: column;
gap: 12px;
}
}
@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
getSettings,
updateDashboardSettings,
type DashboardSettings,
} from '../services/settingsService'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductSettingsPage.module.css'
const DEFAULT_SETTINGS: DashboardSettings = {
comments: { autoApprove: false },
expertReviews: { autoApprove: false },
}
export function ProductSettingsPage() {
const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.dashboard)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load settings.')
}
} finally {
setIsLoading(false)
}
}
async function handleCommentsAutoApprove(checked: boolean) {
setSavingKey('comments')
setError('')
try {
const data = await updateDashboardSettings({
comments: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save settings.')
}
} finally {
setSavingKey(null)
}
}
async function handleExpertReviewsAutoApprove(checked: boolean) {
setSavingKey('expertReviews')
setError('')
try {
const data = await updateDashboardSettings({
expertReviews: { autoApprove: checked },
})
setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save settings.')
}
} finally {
setSavingKey(null)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products', href: '/products' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Product settings</h2>
<p className={pageStyles.pageSubtitle}>
Configure how comments and expert reviews are moderated.
</p>
</div>
</div>
{error && (
<div className={styles.alertError} role="alert">
{error}
</div>
)}
<section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3>
{isLoading ? (
<p className={styles.status}>Loading settings...</p>
) : (
<div className={styles.list}>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments
</label>
<p className={styles.rowDescription}>
New comments are published immediately when submitted. You can still reject
them later if needed.
</p>
</div>
<Switch
id="comments-auto-approve"
checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'}
aria-label="Auto-approve comments"
onChange={(checked) => void handleCommentsAutoApprove(checked)}
/>
</div>
<div className={styles.row}>
<div className={styles.rowText}>
<label htmlFor="expert-reviews-auto-approve" className={styles.rowLabel}>
Auto-approve expert reviews
</label>
<p className={styles.rowDescription}>
New expert reviews are published immediately when submitted. You can still
reject them later if needed.
</p>
</div>
<Switch
id="expert-reviews-auto-approve"
checked={settings.expertReviews.autoApprove}
disabled={savingKey === 'expertReviews'}
aria-label="Auto-approve expert reviews"
onChange={(checked) => void handleExpertReviewsAutoApprove(checked)}
/>
</div>
</div>
)}
</section>
</main>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { FolderTree, PlusCircle, Package, Settings, Tag } from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { ProductActivityChart } from '../components/ProductActivityChart'
import { Breadcrumbs } from '../components/Breadcrumbs'
import styles from '../components/PageContent.module.css'
const productSections = [
{
icon: Package,
title: 'My Products',
description: 'View, edit and manage all your existing products.',
linkText: 'View products',
href: '/products/list',
},
{
icon: PlusCircle,
title: 'Add a New Product',
description: 'Create and publish a new product to your store.',
linkText: 'Add product',
href: '/products/new',
},
{
icon: FolderTree,
title: 'Categories',
description: 'Organize your products into categories and subcategories.',
linkText: 'View categories',
href: '/products/categories',
},
{
icon: Tag,
title: 'Brands',
description: 'Manage product brands and assign them when creating products.',
linkText: 'View brands',
href: '/products/brands',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure product defaults, variants and display options.',
linkText: 'View settings',
href: '/products/settings',
},
]
export function ProductsPage() {
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Products' },
]}
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Products</h2>
<p className={styles.pageSubtitle}>
Manage your products, inventory and categories.
</p>
</div>
</div>
<div className={styles.gridHome}>
{productSections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
<div className={styles.grid12}>
<div className={styles.col6}>
<ProductActivityChart />
</div>
</div>
</main>
)
}
@@ -0,0 +1,56 @@
.filtersRow {
grid-column: span 10;
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
gap: 10px;
min-width: 0;
}
.filterCustomer {
flex: 0 1 220px;
min-width: 160px;
}
.filterDate {
flex: 0 1 148px;
min-width: 132px;
}
.filterCost {
flex: 0 1 124px;
min-width: 108px;
}
.colCustomer {
width: 24%;
}
.colItems {
width: 10%;
}
.colTotal {
width: 14%;
}
.colDate {
width: 16%;
}
.colActions {
width: 112px;
}
@media (max-width: 1280px) {
.filtersRow {
flex-wrap: wrap;
}
.filterCustomer,
.filterDate,
.filterCost {
flex: 1 1 140px;
max-width: none;
}
}
@@ -0,0 +1,454 @@
import { useEffect, useMemo, useState } from 'react'
import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listShoppingCards,
removeShoppingCard,
type ShoppingCard,
type ShoppingCardsListResponse,
} from '../services/shoppingCardService'
import { RESUME_SHOPPING_CARD_KEY } from '../types/draftCart'
import { formatIrtInput, formatIrtPrice, parseIrtInput } from '../utils/irtPrice'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './OrdersPage.module.css'
import styles from './ShoppingCardsPage.module.css'
const PAGE_SIZE = 24
const COLUMN_COUNT = 5
interface AppliedFilters {
customerQuery?: string
dateFrom?: string
dateTo?: string
minTotal?: number
maxTotal?: number
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(card: ShoppingCard) {
const name = [card.customer.firstName, card.customer.lastName]
.filter(Boolean)
.join(' ')
.trim()
return name || '—'
}
function formatDateTime(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
}
}
function totalItemQuantity(card: ShoppingCard) {
return card.items.reduce((sum, item) => sum + item.quantity, 0)
}
export function ShoppingCardsPage() {
const navigate = useNavigate()
const { showToast } = useToast()
const [data, setData] = useState<ShoppingCardsListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedFilters, setAppliedFilters] = useState<AppliedFilters>({})
const [page, setPage] = useState(1)
const [draftCustomer, setDraftCustomer] = useState('')
const [draftDateFrom, setDraftDateFrom] = useState('')
const [draftDateTo, setDraftDateTo] = useState('')
const [draftMinCost, setDraftMinCost] = useState('')
const [draftMaxCost, setDraftMaxCost] = useState('')
const [removeTarget, setRemoveTarget] = useState<ShoppingCard | null>(null)
const [removing, setRemoving] = useState(false)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listShoppingCards(
{
page,
pageSize: PAGE_SIZE,
...appliedFilters,
},
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load shopping cards.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [
page,
appliedFilters.customerQuery,
appliedFilters.dateFrom,
appliedFilters.dateTo,
appliedFilters.minTotal,
appliedFilters.maxTotal,
])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
const minTotal = parseIrtInput(draftMinCost)
const maxTotal = parseIrtInput(draftMaxCost)
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
setError('Minimum cost cannot be greater than maximum cost.')
return
}
setPage(1)
setAppliedFilters({
...(draftCustomer.trim() ? { customerQuery: draftCustomer.trim() } : {}),
...(draftDateFrom ? { dateFrom: draftDateFrom } : {}),
...(draftDateTo ? { dateTo: draftDateTo } : {}),
...(minTotal !== null ? { minTotal } : {}),
...(maxTotal !== null ? { maxTotal } : {}),
})
}
function clearFilters() {
setDraftCustomer('')
setDraftDateFrom('')
setDraftDateTo('')
setDraftMinCost('')
setDraftMaxCost('')
setPage(1)
setAppliedFilters({})
setError('')
}
function handleContinue(card: ShoppingCard) {
sessionStorage.setItem(RESUME_SHOPPING_CARD_KEY, JSON.stringify(card))
navigate('/store/items')
}
async function confirmRemove() {
if (!removeTarget) return
setRemoving(true)
setError('')
try {
await removeShoppingCard(removeTarget.id)
setData((prev) => {
if (!prev) return prev
return {
...prev,
total: Math.max(0, prev.total - 1),
items: prev.items.filter((item) => item.id !== removeTarget.id),
}
})
showToast('Shopping card removed.', 'success')
setRemoveTarget(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove shopping card.')
} finally {
setRemoving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'Shopping Cards' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Shopping cards</h2>
<p className={pageStyles.pageSubtitle}>
Saved operator carts waiting to be completed as orders.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={styles.filtersRow}>
<div className={`${filterStyles.field} ${styles.filterCustomer}`}>
<label htmlFor="filter-card-customer">Customer name or number</label>
<input
id="filter-card-customer"
value={draftCustomer}
onChange={(e) => setDraftCustomer(e.target.value)}
placeholder="Name or phone"
autoComplete="off"
/>
</div>
<div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-card-date-from">Date from</label>
<input
id="filter-card-date-from"
type="date"
value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)}
/>
</div>
<div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-card-date-to">Date to</label>
<input
id="filter-card-date-to"
type="date"
value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)}
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-card-min-cost">Min cost (IRT)</label>
<input
id="filter-card-min-cost"
type="text"
inputMode="numeric"
value={draftMinCost}
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
placeholder="0"
autoComplete="off"
/>
</div>
<div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-card-max-cost">Max cost (IRT)</label>
<input
id="filter-card-max-cost"
type="text"
inputMode="numeric"
value={draftMaxCost}
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
placeholder="0"
autoComplete="off"
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={tableStyles.tablePanel}>
<div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Shopping card list</div>
<div className={tableStyles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
'No shopping cards'
)
) : (
' '
)}
</div>
</div>
{error && <div className={tableStyles.errorBanner}>{error}</div>}
<table className={tableStyles.table}>
<colgroup>
<col className={styles.colCustomer} />
<col className={styles.colItems} />
<col className={styles.colTotal} />
<col className={styles.colDate} />
<col className={styles.colActions} />
</colgroup>
<thead>
<tr>
<th className={tableStyles.th}>Customer</th>
<th className={tableStyles.th}>Items</th>
<th className={tableStyles.th}>Total cost</th>
<th className={tableStyles.th}>Date & time</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={tableStyles.td} colSpan={COLUMN_COUNT}>
Loading...
</td>
</tr>
)}
{!loading && data?.items?.length === 0 && (
<tr>
<td className={tableStyles.td} colSpan={COLUMN_COUNT}>
No results found.
</td>
</tr>
)}
{!loading &&
data?.items?.map((card) => {
const { date, time } = formatDateTime(card.createdAt)
const itemQty = totalItemQuantity(card)
return (
<tr key={card.id}>
<td className={tableStyles.td}>
<div className={tableStyles.customerName}>{displayName(card)}</div>
<div className={tableStyles.customerCell}>
{formatCellForDisplay(card.customer.cellNumber)}
</div>
</td>
<td className={tableStyles.td}>
{itemQty > 0 ? itemQty : <span className={tableStyles.subText}>0</span>}
</td>
<td className={tableStyles.td}>{formatIrtPrice(card.total)}</td>
<td className={`${tableStyles.td} ${tableStyles.dateCell}`}>
<div className={tableStyles.dateTime}>{date}</div>
{time && <div className={tableStyles.dateTimeSub}>{time}</div>}
</td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}>
<Tooltip label="Continue">
<button
type="button"
className={tableStyles.actionBtn}
onClick={() => handleContinue(card)}
aria-label="Continue shopping card"
>
<Play size={15} />
</button>
</Tooltip>
<Tooltip label="Remove card">
<button
type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveTarget(card)}
aria-label="Remove shopping card"
disabled={removing}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<ConfirmDeleteModal
open={removeTarget !== null}
title="Remove shopping card?"
message={
removeTarget
? `Remove this shopping card for ${displayName(removeTarget)}? This cannot be undone.`
: ''
}
onCancel={() => setRemoveTarget(null)}
onConfirm={() => void confirmRemove()}
/>
</main>
)
}
@@ -0,0 +1,140 @@
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
.fabDock {
position: fixed;
right: 32px;
bottom: 32px;
display: flex;
align-items: center;
gap: 10px;
z-index: 50;
}
.cartFabStrip {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
min-width: 280px;
height: 56px;
padding: 0 36px;
border-radius: 50px;
border: 1px solid rgba(var(--primary-rgb) / 0.3);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 8px 28px rgba(31, 38, 135, 0.12);
transition: background 0.2s, border-color 0.2s, transform 0.2s;
white-space: nowrap;
}
.cartFabStrip:hover {
background: rgba(var(--primary-rgb) / 0.12);
border-color: rgba(var(--primary-rgb) / 0.45);
transform: translateY(-2px);
}
.cartFabCount {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 8px;
border-radius: 50px;
font-size: 12px;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary-dark) 100%);
}
.cartFabLabel {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.addFab {
position: static;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s;
flex-shrink: 0;
}
.addFab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1280px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 768px) {
.fabDock {
right: 20px;
bottom: 20px;
gap: 8px;
}
.cartFabStrip {
min-width: 220px;
height: 52px;
padding: 0 28px;
}
.cartFabLabel {
font-size: 13px;
}
.addFab {
width: 52px;
height: 52px;
}
}
+437
View File
@@ -0,0 +1,437 @@
import { useEffect, useMemo, useState } from 'react'
import { Plus, RotateCcw, Search } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { CreateStoreItemsModal } from '../components/CreateStoreItemsModal'
import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
import { ShoppingCartModal } from '../components/ShoppingCartModal'
import { StoreItemCard } from '../components/StoreItemCard'
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
deleteStoreItemsByProduct,
listStoreItems,
type StoreItem,
} from '../services/storeItemService'
import type { ShoppingCard } from '../services/shoppingCardService'
import { RESUME_SHOPPING_CARD_KEY, shoppingCardToDraftItems } from '../types/draftCart'
import {
EMPTY_STORE_LISTING_FILTERS,
filterStoreListings,
type StoreListingFilters,
} from '../utils/filterStoreListings'
import {
formatVariantCount,
groupStoreItemsByProduct,
type StoreProductListing,
} from '../utils/storeProductGroups'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './StoreItemsPage.module.css'
export function StoreItemsPage() {
return <StoreItemsPageContent />
}
function StoreItemsPageContent() {
const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart()
const { showToast } = useToast()
const [items, setItems] = useState<StoreItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<StoreProductListing | null>(null)
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
const [festivalTarget, setFestivalTarget] = useState<StoreProductListing | null>(null)
const [deleteTarget, setDeleteTarget] = useState<StoreProductListing | null>(null)
const [pickVariantTarget, setPickVariantTarget] = useState<StoreProductListing | null>(null)
const [cartOpen, setCartOpen] = useState(false)
const [draftName, setDraftName] = useState('')
const [draftMinPrice, setDraftMinPrice] = useState('')
const [draftMaxPrice, setDraftMaxPrice] = useState('')
const [draftOnlyDiscounted, setDraftOnlyDiscounted] = useState(false)
const [appliedFilters, setAppliedFilters] = useState<StoreListingFilters>(
EMPTY_STORE_LISTING_FILTERS,
)
useEffect(() => {
if (hasItems) {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
return
}
const raw = sessionStorage.getItem(RESUME_SHOPPING_CARD_KEY)
if (!raw) return
try {
const card = JSON.parse(raw) as ShoppingCard
const draftItems = shoppingCardToDraftItems(card)
if (draftItems.length === 0) {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
showToast('Shopping card has no valid items to load.', 'error')
return
}
loadShoppingCard(card)
showToast('Shopping card loaded. Open the cart to continue.', 'success')
} catch {
sessionStorage.removeItem(RESUME_SHOPPING_CARD_KEY)
showToast('Unable to load shopping card.', 'error')
}
}, [hasItems, loadShoppingCard, showToast])
const listings = useMemo(() => groupStoreItemsByProduct(items), [items])
const filteredListings = useMemo(
() => filterStoreListings(listings, appliedFilters),
[listings, appliedFilters],
)
const productItems = useMemo(() => {
const target = editTarget ?? discountTarget ?? festivalTarget
if (!target) return []
return items.filter((item) => item.productId === target.productId)
}, [editTarget, discountTarget, festivalTarget, items])
const existingProductIds = useMemo(
() => listings.map((listing) => listing.productId),
[listings],
)
function applyFilters() {
setAppliedFilters({
name: draftName.trim(),
minPrice: parseIrtInput(draftMinPrice),
maxPrice: parseIrtInput(draftMaxPrice),
onlyDiscounted: draftOnlyDiscounted,
})
}
function clearFilters() {
setDraftName('')
setDraftMinPrice('')
setDraftMaxPrice('')
setDraftOnlyDiscounted(false)
setAppliedFilters(EMPTY_STORE_LISTING_FILTERS)
}
useEffect(() => {
const controller = new AbortController()
void loadItems(controller.signal)
return () => controller.abort()
}, [])
async function loadItems(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listStoreItems(1, 50, signal)
setItems(data.items)
} 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 replaceProductItems(updated: StoreItem[]) {
const productId = updated[0]?.productId ?? editTarget?.productId
if (!productId) return
setItems((prev) => {
const withoutProduct = prev.filter((item) => item.productId !== productId)
return updated.length > 0 ? [...withoutProduct, ...updated] : withoutProduct
})
}
function mergeUpdatedItems(updated: StoreItem[]) {
const byId = new Map(updated.map((item) => [item.id, item]))
setItems((prev) => prev.map((item) => byId.get(item.id) ?? item))
}
function openEdit(listing: StoreProductListing) {
setEditTarget(listing)
}
function openEditForProduct(productId: string) {
const listing = listings.find((entry) => entry.productId === productId)
if (listing) {
setCreateOpen(false)
setEditTarget(listing)
}
}
function handleAddToCart(listing: StoreProductListing) {
if (listing.variantCount > 1) {
setPickVariantTarget(listing)
return
}
const variant = listing.variants[0]
if (!variant) return
const feedback = addVariant(variant)
if (feedback) {
showToast(feedback, 'error')
return
}
showToast('Added to shopping cart.', 'success')
}
function handleVariantPicked(variant: StoreItem) {
const feedback = addVariant(variant)
setPickVariantTarget(null)
if (feedback) {
showToast(feedback, 'error')
return
}
showToast('Added to shopping cart.', 'success')
}
async function confirmDelete() {
if (!deleteTarget) return
setIsDeleting(true)
setError('')
try {
await deleteStoreItemsByProduct(deleteTarget.productId)
setItems((prev) => prev.filter((item) => item.productId !== deleteTarget.productId))
setDeleteTarget(null)
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to remove store item.')
}
} finally {
setIsDeleting(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'My Store Items' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>My Store Items</h2>
<p className={pageStyles.pageSubtitle}>
Products for sale with one or more priced variants.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<label htmlFor="filter-store-name">Name</label>
<input
id="filter-store-name"
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by product name"
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<label htmlFor="filter-store-min-price">Min price (IRT)</label>
<input
id="filter-store-min-price"
inputMode="numeric"
value={draftMinPrice}
onChange={(e) => setDraftMinPrice(formatIrtInput(e.target.value))}
placeholder="0"
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<label htmlFor="filter-store-max-price">Max price (IRT)</label>
<input
id="filter-store-max-price"
inputMode="numeric"
value={draftMaxPrice}
onChange={(e) => setDraftMaxPrice(formatIrtInput(e.target.value))}
placeholder="0"
disabled={isLoading}
/>
</div>
<div className={`${filterStyles.switchField} ${filterStyles.fieldCol3}`}>
<span className={filterStyles.switchFieldSpacer} aria-hidden="true">
Only discounted
</span>
<div className={filterStyles.switchInline}>
<ToggleSwitch
checked={draftOnlyDiscounted}
onChange={setDraftOnlyDiscounted}
disabled={isLoading}
ariaLabel="Only discounted"
/>
<span
className={filterStyles.switchLabel}
onClick={() => !isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)}
>
Only discounted
</span>
</div>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={isLoading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={isLoading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading store items...</p>
) : listings.length === 0 ? (
<p className={styles.empty}>
No store items yet. Use the + button to add products from your catalog.
</p>
) : filteredListings.length === 0 ? (
<p className={styles.empty}>No store items match your filters.</p>
) : (
<div className={styles.grid}>
{filteredListings.map((listing) => (
<StoreItemCard
key={listing.productId}
listing={listing}
onOpen={openEdit}
onEdit={openEdit}
onDiscount={setDiscountTarget}
onFestival={setFestivalTarget}
onRemove={setDeleteTarget}
onAddToCart={handleAddToCart}
/>
))}
</div>
)}
<div className={styles.fabDock}>
{hasItems && (
<button
type="button"
className={styles.cartFabStrip}
onClick={() => setCartOpen(true)}
aria-label={`Open shopping cart, ${itemCount} items`}
>
<span className={styles.cartFabCount}>{itemCount}</span>
<span className={styles.cartFabLabel}>
{itemCount === 1 ? 'item in cart' : 'items in cart'}
</span>
</button>
)}
<button
type="button"
className={styles.addFab}
onClick={() => setCreateOpen(true)}
aria-label="Add store items"
>
<Plus size={24} />
</button>
</div>
<CreateStoreItemsModal
open={createOpen}
existingProductIds={existingProductIds}
onClose={() => setCreateOpen(false)}
onCreated={() => void loadItems()}
onEditExisting={openEditForProduct}
/>
<EditStoreItemsModal
open={!!editTarget}
productTitle={editTarget?.productTitle ?? ''}
productNameFa={editTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setEditTarget(null)}
onSaved={replaceProductItems}
/>
<StoreItemDiscountModal
open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''}
items={productItems}
onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems}
/>
<StoreItemFestivalModal
open={!!festivalTarget}
productTitle={festivalTarget?.productTitle ?? ''}
items={productItems}
onClose={() => setFestivalTarget(null)}
onSaved={mergeUpdatedItems}
/>
<ConfirmDeleteModal
open={!!deleteTarget}
title="Remove from Store"
message={
deleteTarget
? `Remove "${deleteTarget.productTitle}" and all ${formatVariantCount(deleteTarget.variantCount)} from your store? This cannot be undone.`
: ''
}
onConfirm={() => void confirmDelete()}
onCancel={() => !isDeleting && setDeleteTarget(null)}
/>
<PickStoreVariantModal
open={!!pickVariantTarget}
productTitle={pickVariantTarget?.productTitle ?? ''}
productNameFa={pickVariantTarget?.productNameFa ?? ''}
variants={pickVariantTarget?.variants ?? []}
onClose={() => setPickVariantTarget(null)}
onSelect={handleVariantPicked}
/>
<ShoppingCartModal
open={cartOpen}
onClose={() => setCartOpen(false)}
onOrderCreated={() => void loadItems()}
/>
</main>
)
}
+75
View File
@@ -0,0 +1,75 @@
import {
Package,
ShoppingCart,
Truck,
CreditCard,
Settings,
} from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import styles from '../components/PageContent.module.css'
const storeSections = [
{
icon: Package,
title: 'My Store Items',
description: 'View and manage all items listed in your store.',
linkText: 'View items',
href: '/store/items',
},
{
icon: ShoppingCart,
title: 'My Orders',
description: 'Track and manage customer orders and fulfillment.',
linkText: 'View orders',
href: '/store/orders',
},
{
icon: Truck,
title: 'Shipping Fees',
description: 'Configure shipping rates, zones and delivery options.',
linkText: 'Manage shipping',
href: '/store/shipping',
},
{
icon: CreditCard,
title: 'Shopping Cards',
description: 'Manage saved shopping cards and payment methods.',
linkText: 'View cards',
href: '/store/cards',
},
{
icon: Settings,
title: 'Settings',
description: 'Configure store preferences, pages and themes.',
linkText: 'View settings',
href: '/store/settings',
},
]
export function StorePage() {
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store' },
]}
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Store</h2>
<p className={styles.pageSubtitle}>
Manage your store items, orders and settings.
</p>
</div>
</div>
<div className={styles.gridHome}>
{storeSections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,131 @@
.stepsPanel {
margin-top: 20px;
}
.stepsHeader {
margin-bottom: 20px;
}
.stepsDescription {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
margin-top: -12px;
}
.stepsList {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.stepRow {
display: flex;
align-items: center;
gap: 10px;
}
.stepIndex {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.1);
border-radius: 50%;
}
.stepInput {
flex: 1;
min-width: 0;
height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
transition: border-color 0.2s, box-shadow 0.2s;
}
.stepInput:focus {
outline: none;
border-color: rgba(var(--primary-rgb) / 0.5);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.stepControls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.emptyText {
font-size: 13px;
color: var(--text-muted);
text-align: center;
padding: 16px 12px;
}
.addStepBtn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 11px;
font-size: 14px;
font-weight: 600;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px dashed rgba(var(--primary-rgb) / 0.35);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.addStepBtn:hover {
background: rgba(var(--primary-rgb) / 0.14);
}
.stepsActions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.saveBtn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: var(--primary);
border-radius: var(--radius-sm);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.stepRow {
flex-wrap: wrap;
}
.stepInput {
width: 100%;
flex: 1 1 100%;
}
.stepControls {
margin-left: auto;
}
}
@@ -0,0 +1,342 @@
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { StepColorPicker } from '../components/StepColorPicker'
import { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
getSettings,
updateStoreSettings,
DEFAULT_ORDER_PROCESS_STEPS,
type OrderProcessStep,
type StoreSettings,
} from '../services/settingsService'
import { createId } from '../utils/id'
import {
defaultStepColorForId,
normalizeStepColor,
type StepColorPreset,
} from '../utils/stepColors'
import controlStyles from '../components/CategoryRow.module.css'
import removeStyles from '../components/VariationsModal.module.css'
import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css'
import styles from './StoreSettingsPage.module.css'
const DEFAULT_STORE_SETTINGS: StoreSettings = {
onlineSellEnabled: true,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
}
function normalizeSteps(steps: OrderProcessStep[]) {
return steps.map((step, index) => ({
id: step.id,
label: step.label.trim(),
color: normalizeStepColor(step.color, defaultStepColorForId(step.id, index)),
}))
}
function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
if (a.length !== b.length) return false
return a.every((step, index) => {
const other = b[index]
return (
step.id === other.id && step.label === other.label && step.color === other.color
)
})
}
export function StoreSettingsPage() {
const { showToast } = useToast()
const [settings, setSettings] = useState<StoreSettings>(DEFAULT_STORE_SETTINGS)
const [draftSteps, setDraftSteps] = useState<OrderProcessStep[]>(
DEFAULT_STORE_SETTINGS.orderProcessSteps,
)
const [isLoading, setIsLoading] = useState(true)
const [savingKey, setSavingKey] = useState<string | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSettings(data.settings.store)
setDraftSteps(data.settings.store.orderProcessSteps)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load settings.')
}
} finally {
setIsLoading(false)
}
}
async function handleOnlineSellChange(checked: boolean) {
setSavingKey('onlineSell')
setError('')
try {
const data = await updateStoreSettings({ onlineSellEnabled: checked })
setSettings(data.settings.store)
showToast('Settings saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save settings.')
}
} finally {
setSavingKey(null)
}
}
function updateStepLabel(id: string, label: string) {
setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, label } : step)),
)
}
function updateStepColor(id: string, color: StepColorPreset) {
setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, color } : step)),
)
}
function addStep() {
setDraftSteps((current) => {
const id = createId()
return [
...current,
{
id,
label: '',
color: defaultStepColorForId(id, current.length),
},
]
})
}
function removeStep(id: string) {
setDraftSteps((current) => current.filter((step) => step.id !== id))
}
function moveStep(id: string, direction: -1 | 1) {
setDraftSteps((current) => {
const index = current.findIndex((step) => step.id === id)
if (index < 0) return current
const targetIndex = index + direction
if (targetIndex < 0 || targetIndex >= current.length) return current
const next = [...current]
const [item] = next.splice(index, 1)
next.splice(targetIndex, 0, item)
return next
})
}
async function handleSaveSteps() {
const normalized = normalizeSteps(draftSteps)
const hasEmptyLabel = normalized.some((step) => !step.label)
if (!normalized.length) {
setError('Add at least one order process step.')
return
}
if (hasEmptyLabel) {
setError('Every order step needs a label.')
return
}
setSavingKey('orderSteps')
setError('')
try {
const data = await updateStoreSettings({ orderProcessSteps: normalized })
setSettings(data.settings.store)
setDraftSteps(data.settings.store.orderProcessSteps)
showToast('Order process steps saved.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to save order process steps.')
}
} finally {
setSavingKey(null)
}
}
const stepsDirty = !stepsAreEqual(
normalizeSteps(draftSteps),
normalizeSteps(settings.orderProcessSteps),
)
const canSaveSteps =
stepsDirty &&
draftSteps.length > 0 &&
draftSteps.every((step) => step.label.trim().length > 0)
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Store', href: '/store' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Store settings</h2>
<p className={pageStyles.pageSubtitle}>
Control online sales and define how orders move through fulfillment.
</p>
</div>
</div>
{error && (
<div className={sharedStyles.alertError} role="alert">
{error}
</div>
)}
<section className={sharedStyles.panel}>
<h3 className={sharedStyles.sectionTitle}>Sales</h3>
{isLoading ? (
<p className={sharedStyles.status}>Loading settings...</p>
) : (
<div className={sharedStyles.list}>
<div className={sharedStyles.row}>
<div className={sharedStyles.rowText}>
<label htmlFor="online-sell" className={sharedStyles.rowLabel}>
Online sell
</label>
<p className={sharedStyles.rowDescription}>
When disabled, all sales on your website are turned off. Customers
will not be able to place new orders online.
</p>
</div>
<Switch
id="online-sell"
checked={settings.onlineSellEnabled}
disabled={savingKey === 'onlineSell'}
aria-label="Online sell"
onChange={(checked) => void handleOnlineSellChange(checked)}
/>
</div>
</div>
)}
</section>
<section className={`${sharedStyles.panel} ${styles.stepsPanel}`}>
<div className={styles.stepsHeader}>
<div>
<h3 className={sharedStyles.sectionTitle}>Order process</h3>
<p className={styles.stepsDescription}>
Define the steps an order can move through for example: under
processing, ready for shipping, shipped, delivered.
</p>
</div>
</div>
{isLoading ? (
<p className={sharedStyles.status}>Loading settings...</p>
) : (
<>
<div className={styles.stepsList}>
{draftSteps.map((step, index) => (
<div key={step.id} className={styles.stepRow}>
<span className={styles.stepIndex}>{index + 1}</span>
<StepColorPicker
value={normalizeStepColor(step.color, defaultStepColorForId(step.id, index))}
onChange={(color) => updateStepColor(step.id, color)}
ariaLabel={`Color for step ${index + 1}`}
/>
<input
type="text"
className={styles.stepInput}
value={step.label}
placeholder="Step label"
aria-label={`Order step ${index + 1}`}
onChange={(e) => updateStepLabel(step.id, e.target.value)}
/>
<div className={styles.stepControls}>
<Tooltip label="Move step up">
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => moveStep(step.id, -1)}
disabled={index === 0}
aria-label="Move step up"
>
<ChevronUp size={16} />
</button>
</Tooltip>
<Tooltip label="Move step down">
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => moveStep(step.id, 1)}
disabled={index === draftSteps.length - 1}
aria-label="Move step down"
>
<ChevronDown size={16} />
</button>
</Tooltip>
<Tooltip label="Remove step">
<button
type="button"
className={removeStyles.removeRowBtn}
onClick={() => removeStep(step.id)}
disabled={draftSteps.length <= 1}
aria-label="Remove step"
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</div>
))}
{!draftSteps.length && (
<p className={styles.emptyText}>
No order steps yet. Add the first step to define your workflow.
</p>
)}
</div>
<button type="button" className={styles.addStepBtn} onClick={addStep}>
<Plus size={18} />
Add step
</button>
<div className={styles.stepsActions}>
<button
type="button"
className={styles.saveBtn}
onClick={() => void handleSaveSteps()}
disabled={!canSaveSteps || savingKey === 'orderSteps'}
>
{savingKey === 'orderSteps' ? 'Saving...' : 'Save steps'}
</button>
</div>
</>
)}
</section>
</main>
)
}
@@ -0,0 +1,25 @@
.carousels {
display: flex;
flex-direction: column;
gap: 8px;
}
.empty {
padding: 48px 24px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
}
.error {
margin-bottom: 16px;
padding: 12px 14px;
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-sm);
}
@@ -0,0 +1,461 @@
import { useEffect, useMemo, useState } from 'react'
import { Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
import { PickStoreSpecialItemsModal } from '../components/PickStoreSpecialItemsModal'
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
import { ShoppingCartModal } from '../components/ShoppingCartModal'
import { StoreItemDiscountModal } from '../components/StoreItemDiscountModal'
import { StoreItemFestivalModal } from '../components/StoreItemFestivalModal'
import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
createStoreSpecial,
deleteStoreSpecial,
listStoreSpecials,
updateStoreSpecial,
} from '../services/storeSpecialService'
import type { StoreItem } from '../services/storeItemService'
import type { StoreSpecial } from '../types/storeSpecial'
import type { StoreProductListing } from '../utils/storeProductGroups'
import pageStyles from '../components/PageContent.module.css'
import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function StoreSpecialsPage() {
const { itemCount, hasItems, addVariant } = useDraftCart()
const { showToast } = useToast()
const [specials, setSpecials] = useState<StoreSpecial[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editSpecialTarget, setEditSpecialTarget] = useState<StoreSpecial | null>(null)
const [deleteSpecialTarget, setDeleteSpecialTarget] = useState<StoreSpecial | null>(null)
const [pickItemsTarget, setPickItemsTarget] = useState<StoreSpecial | null>(null)
const [editListingTarget, setEditListingTarget] = useState<StoreProductListing | null>(null)
const [discountTarget, setDiscountTarget] = useState<StoreProductListing | null>(null)
const [festivalTarget, setFestivalTarget] = useState<StoreProductListing | null>(null)
const [removeTarget, setRemoveTarget] = useState<{
special: StoreSpecial
listing: StoreProductListing
} | null>(null)
const [pickVariantTarget, setPickVariantTarget] = useState<StoreProductListing | null>(null)
const [cartOpen, setCartOpen] = useState(false)
const productItems = useMemo(() => {
const target = editListingTarget ?? discountTarget ?? festivalTarget
if (!target) return []
return target.variants
}, [editListingTarget, discountTarget, festivalTarget])
useEffect(() => {
const controller = new AbortController()
void loadSpecials(controller.signal)
return () => controller.abort()
}, [])
async function loadSpecials(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listStoreSpecials(1, 50, signal)
setSpecials(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load special categories.')
}
} finally {
setIsLoading(false)
}
}
function replaceSpecial(updated: StoreSpecial) {
setSpecials((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
function mergeUpdatedItems(updated: StoreItem[]) {
const byId = new Map(updated.map((item) => [item.id, item]))
setSpecials((prev) =>
prev.map((special) => ({
...special,
items: special.items.map((storeItem) => ({
...storeItem,
variants: storeItem.variants.map((variant) => {
const next = byId.get(variant.id)
if (!next) return variant
return {
...variant,
price: next.price,
discountedPrice: next.discountedPrice,
stockQuantity: next.stockQuantity,
rewardPoints: next.rewardPoints,
isFestival: next.isFestival,
}
}),
})),
})),
)
}
function replaceProductItems(updated: StoreItem[]) {
const productId = updated[0]?.productId ?? editListingTarget?.productId
if (!productId) return
setSpecials((prev) =>
prev.map((special) => ({
...special,
items: special.items.map((storeItem) => {
if (storeItem.productId !== productId) return storeItem
if (updated.length === 0) return storeItem
return {
...storeItem,
productTitle: updated[0].productTitle,
productNameFa: updated[0].productNameFa,
productImage: updated[0].productImage,
variants: updated.map((item) => ({
id: item.id,
label: item.label,
selections: item.selections,
price: item.price,
discountedPrice: item.discountedPrice,
stockQuantity: item.stockQuantity,
rewardPoints: item.rewardPoints,
isFestival: item.isFestival,
sortOrder: item.sortOrder,
})),
}
}),
})),
)
}
async function handleCreateSpecial(title: string) {
setIsSaving(true)
setError('')
try {
const result = await createStoreSpecial({
title,
sortOrder: specials.length,
})
setSpecials((prev) => [...prev, result.special])
setCreateOpen(false)
showToast('Special category created.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to create special category.')
}
} finally {
setIsSaving(false)
}
}
async function handleEditSpecial(title: string) {
if (!editSpecialTarget) return
setIsSaving(true)
setError('')
try {
const result = await updateStoreSpecial(editSpecialTarget.id, { title })
replaceSpecial(result.special)
setEditSpecialTarget(null)
showToast('Special category updated.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update special category.')
}
} finally {
setIsSaving(false)
}
}
async function confirmDeleteSpecial() {
if (!deleteSpecialTarget) return
setIsSaving(true)
setError('')
try {
await deleteStoreSpecial(deleteSpecialTarget.id)
setSpecials((prev) => prev.filter((entry) => entry.id !== deleteSpecialTarget.id))
setDeleteSpecialTarget(null)
showToast('Special category deleted.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete special category.')
}
} finally {
setIsSaving(false)
}
}
async function handleAddItems(storeItemIds: string[]) {
if (!pickItemsTarget) return
const existingIds = pickItemsTarget.items.map((item) => item.id)
const nextIds = [...existingIds, ...storeItemIds]
setIsSaving(true)
setError('')
try {
const result = await updateStoreSpecial(pickItemsTarget.id, {
storeItemIds: nextIds,
})
replaceSpecial(result.special)
setPickItemsTarget(null)
showToast('Store items added to special category.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to add store items.')
}
} finally {
setIsSaving(false)
}
}
async function confirmRemoveFromSpecial() {
if (!removeTarget) return
const { special, listing } = removeTarget
const storeItemId = listing.representative.storeItemId ?? listing.representative.id
const nextIds = special.items
.map((item) => item.id)
.filter((id) => id !== storeItemId)
setIsSaving(true)
setError('')
try {
const result = await updateStoreSpecial(special.id, {
storeItemIds: nextIds,
})
replaceSpecial(result.special)
setRemoveTarget(null)
showToast('Removed from special category.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to remove store item.')
}
} finally {
setIsSaving(false)
}
}
function handleAddToCart(listing: StoreProductListing) {
if (listing.variantCount > 1) {
setPickVariantTarget(listing)
return
}
const variant = listing.variants[0]
if (!variant) return
const feedback = addVariant(variant)
if (feedback) {
showToast(feedback, 'error')
return
}
showToast('Added to shopping cart.', 'success')
}
function handleVariantPicked(variant: StoreItem) {
const feedback = addVariant(variant)
setPickVariantTarget(null)
if (feedback) {
showToast(feedback, 'error')
return
}
showToast('Added to shopping cart.', 'success')
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Special Items' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Special Items</h2>
<p className={pageStyles.pageSubtitle}>
Curate featured store items into categories for your website carousels.
</p>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading special categories...</p>
) : specials.length === 0 ? (
<p className={styles.empty}>
No special categories yet. Use the + button to create one, then add store items to each
carousel.
</p>
) : (
<div className={styles.carousels}>
{specials.map((special) => (
<StoreSpecialCarousel
key={special.id}
special={special}
onAddItems={setPickItemsTarget}
onEditSpecial={setEditSpecialTarget}
onDeleteSpecial={setDeleteSpecialTarget}
onOpenListing={setEditListingTarget}
onEditListing={setEditListingTarget}
onDiscountListing={setDiscountTarget}
onFestivalListing={setFestivalTarget}
onRemoveListing={(specialEntry, listing) =>
setRemoveTarget({ special: specialEntry, listing })
}
onAddToCart={handleAddToCart}
/>
))}
</div>
)}
<div className={fabStyles.fabDock}>
{hasItems && (
<button
type="button"
className={fabStyles.cartFabStrip}
onClick={() => setCartOpen(true)}
aria-label={`Open shopping cart, ${itemCount} items`}
>
<span className={fabStyles.cartFabCount}>{itemCount}</span>
<span className={fabStyles.cartFabLabel}>
{itemCount === 1 ? 'item in cart' : 'items in cart'}
</span>
</button>
)}
<button
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
aria-label="Add special category"
>
<Plus size={24} />
</button>
</div>
<StoreSpecialModal
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateSpecial}
isSubmitting={isSaving}
/>
<StoreSpecialModal
open={!!editSpecialTarget}
onClose={() => !isSaving && setEditSpecialTarget(null)}
onSubmit={handleEditSpecial}
initialTitle={editSpecialTarget?.title ?? ''}
title="Edit Special Category"
submitLabel="Save"
isSubmitting={isSaving}
/>
<PickStoreSpecialItemsModal
open={!!pickItemsTarget}
specialTitle={pickItemsTarget?.title ?? ''}
existingStoreItemIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
onClose={() => !isSaving && setPickItemsTarget(null)}
onConfirm={handleAddItems}
isSubmitting={isSaving}
/>
<EditStoreItemsModal
open={!!editListingTarget}
productTitle={editListingTarget?.productTitle ?? ''}
productNameFa={editListingTarget?.productNameFa ?? ''}
items={productItems}
onClose={() => setEditListingTarget(null)}
onSaved={replaceProductItems}
/>
<StoreItemDiscountModal
open={!!discountTarget}
productTitle={discountTarget?.productTitle ?? ''}
items={productItems}
onClose={() => setDiscountTarget(null)}
onSaved={mergeUpdatedItems}
/>
<StoreItemFestivalModal
open={!!festivalTarget}
productTitle={festivalTarget?.productTitle ?? ''}
items={productItems}
onClose={() => setFestivalTarget(null)}
onSaved={mergeUpdatedItems}
/>
<ConfirmDeleteModal
open={!!deleteSpecialTarget}
title="Delete Special Category"
message={
deleteSpecialTarget
? `Delete "${deleteSpecialTarget.title}"? Store items will remain in your catalog.`
: ''
}
onConfirm={() => void confirmDeleteSpecial()}
onCancel={() => !isSaving && setDeleteSpecialTarget(null)}
/>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove from Special"
message={
removeTarget
? `Remove "${removeTarget.listing.productTitle}" from "${removeTarget.special.title}"?`
: ''
}
onConfirm={() => void confirmRemoveFromSpecial()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
<PickStoreVariantModal
open={!!pickVariantTarget}
productTitle={pickVariantTarget?.productTitle ?? ''}
productNameFa={pickVariantTarget?.productNameFa ?? ''}
variants={pickVariantTarget?.variants ?? []}
onClose={() => setPickVariantTarget(null)}
onSelect={handleVariantPicked}
/>
<ShoppingCartModal
open={cartOpen}
onClose={() => setCartOpen(false)}
onOrderCreated={() => void loadSpecials()}
/>
</main>
)
}
@@ -0,0 +1,24 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import styles from './WebsitePage.module.css'
export function WebsiteBadgesPage() {
return (
<WebsiteSectionShell
sectionLabel="Badges"
title="Badges"
subtitle="Show trust badges and highlights on your public website."
>
<p className={styles.placeholderLead}>
Display certifications, guarantees, and trust signals to website visitors.
</p>
<p className={styles.placeholderNote}>
Badge management will be connected here. Planned options include:
</p>
<ul className={styles.featureList}>
<li>Upload badge images with title and link</li>
<li>Reorder badges for homepage or footer display</li>
<li>Toggle visibility per badge</li>
</ul>
</WebsiteSectionShell>
)
}
@@ -0,0 +1,138 @@
.tablePanel {
margin-top: 12px;
padding: 0;
background: transparent;
border-radius: var(--radius);
overflow: hidden;
}
.tableWrap {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
}
.tableHeader {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.tableHeaderTitle {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.meta {
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
}
.tableScroll {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
}
.th,
.td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle;
}
.th {
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.35);
}
.td {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.clickableRow {
cursor: pointer;
transition: background 0.15s ease;
}
.clickableRow:hover,
.clickableRow:focus-visible {
background: rgba(var(--primary-rgb) / 0.06);
outline: none;
}
.errorBanner {
margin: 12px 16px 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.22);
color: #b91c1c;
font-size: 13px;
}
.pagination {
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagerBtns {
display: flex;
align-items: center;
gap: 6px;
}
.pageBtn {
min-width: 34px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(148, 163, 184, 0.25);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.pageBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
border-color: rgba(var(--primary-rgb) / 0.25);
}
.pageBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.pageBtnActive {
color: white;
background: linear-gradient(
135deg,
var(--primary-glow) 0%,
var(--primary) 55%,
var(--primary-dark) 100%
);
border-color: transparent;
}
@@ -0,0 +1,297 @@
import { useEffect, useMemo, useState } from 'react'
import { RotateCcw, Search } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
listContactSubmissions,
} from '../services/contactSubmissionService'
import type {
ContactSubmission,
ContactSubmissionsListResponse,
} from '../types/contactSubmission'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './WebsiteContactPage.module.css'
const PAGE_SIZE = 20
const COLUMN_COUNT = 6
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
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 WebsiteContactPage() {
const [data, setData] = useState<ContactSubmissionsListResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [appliedQuery, setAppliedQuery] = useState('')
const [draftQuery, setDraftQuery] = useState('')
const [page, setPage] = useState(1)
const [selected, setSelected] = useState<ContactSubmission | null>(null)
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setError('')
try {
const result = await listContactSubmissions(
{
page,
pageSize: PAGE_SIZE,
...(appliedQuery ? { q: appliedQuery } : {}),
},
controller.signal,
)
if (controller.signal.aborted) return
setData(result)
} catch (err) {
if (isAbortError(err) || controller.signal.aborted) return
setError(err instanceof ApiError ? err.message : 'Unable to load contact submissions.')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}
void load()
return () => {
controller.abort()
}
}, [page, appliedQuery])
const totalPages = useMemo(() => {
const total = data?.total ?? 0
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
}, [data, page])
const showingTo = useMemo(() => {
if (!data) return 0
return Math.min(data.total, page * PAGE_SIZE)
}, [data, page])
function applyFilters() {
setPage(1)
setAppliedQuery(draftQuery.trim())
}
function clearFilters() {
setDraftQuery('')
setAppliedQuery('')
setPage(1)
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Contact Us Form' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Contact us form</h2>
<p className={pageStyles.pageSubtitle}>
Submissions received from your website contact form.
</p>
</div>
</div>
<div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div>
<div className={filterStyles.filtersGrid}>
<div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
<label htmlFor="filter-contact-q">Search</label>
<input
id="filter-contact-q"
value={draftQuery}
onChange={(e) => setDraftQuery(e.target.value)}
placeholder="Title, name, email, cell number, or message"
onKeyDown={(e) => {
if (e.key === 'Enter') applyFilters()
}}
/>
</div>
</div>
<div className={filterStyles.filterActions}>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters}
disabled={loading}
aria-label="Search"
title="Search"
>
<Search size={18} />
</button>
<button
type="button"
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters}
disabled={loading}
aria-label="Clear filters"
title="Clear filters"
>
<RotateCcw size={18} />
</button>
</div>
</div>
</div>
<div className={styles.tablePanel}>
<div className={styles.tableWrap}>
<div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Submissions</div>
<div className={styles.meta}>
{data ? (
data.total > 0 ? (
<>
Showing {showingFrom} - {showingTo} of {data.total}
</>
) : (
'No submissions'
)
) : (
' '
)}
</div>
</div>
{error && <div className={styles.errorBanner}>{error}</div>}
<div className={styles.tableScroll}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.th}>Title</th>
<th className={styles.th}>Name</th>
<th className={styles.th}>Email</th>
<th className={styles.th}>Cell number</th>
<th className={styles.th}>Date</th>
<th className={styles.th}>Time</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
Loading...
</td>
</tr>
)}
{!loading && data?.items.length === 0 && (
<tr>
<td className={styles.td} colSpan={COLUMN_COUNT}>
No submissions found.
</td>
</tr>
)}
{!loading &&
data?.items.map((item) => {
const { date, time } = formatDateTime(item.createdAt)
return (
<tr
key={item.id}
className={styles.clickableRow}
onClick={() => setSelected(item)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelected(item)
}
}}
tabIndex={0}
role="button"
aria-label={`View submission from ${item.name}`}
>
<td className={styles.td}>{item.title}</td>
<td className={styles.td}>{item.name}</td>
<td className={styles.td}>{item.email ?? '—'}</td>
<td className={styles.td}>
{item.cellNumber ? formatCellForDisplay(item.cellNumber) : '—'}
</td>
<td className={styles.td}>{date}</td>
<td className={styles.td}>{time || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
</div>
</div>
</div>
</div>
<ContactSubmissionDetailModal
open={selected !== null}
submission={selected}
onClose={() => setSelected(null)}
/>
</main>
)
}
@@ -0,0 +1,24 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import styles from './WebsitePage.module.css'
export function WebsiteEPaymentPage() {
return (
<WebsiteSectionShell
sectionLabel="E-Payment"
title="E-Payment"
subtitle="Configure online payment methods for your website checkout."
>
<p className={styles.placeholderLead}>
Connect payment gateways so customers can pay online through your store.
</p>
<p className={styles.placeholderNote}>
E-payment settings will be connected here. Planned options include:
</p>
<ul className={styles.featureList}>
<li>Enable or disable online payments</li>
<li>Configure payment provider credentials</li>
<li>Set supported payment methods and test mode</li>
</ul>
</WebsiteSectionShell>
)
}
@@ -0,0 +1,24 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import styles from './WebsitePage.module.css'
export function WebsiteFaqPage() {
return (
<WebsiteSectionShell
sectionLabel="FAQ"
title="FAQ"
subtitle="Create and organize frequently asked questions for your website."
>
<p className={styles.placeholderLead}>
Build an FAQ section to answer common customer questions before they contact you.
</p>
<p className={styles.placeholderNote}>
FAQ editor will be connected here. Planned options include:
</p>
<ul className={styles.featureList}>
<li>Add, edit, and reorder questions and answers</li>
<li>Group items by category</li>
<li>Publish or hide individual entries</li>
</ul>
</WebsiteSectionShell>
)
}
@@ -0,0 +1,40 @@
.panel {
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
padding: 24px;
}
.placeholderLead {
margin: 0 0 10px;
font-size: 15px;
line-height: 1.6;
color: var(--text-primary);
font-weight: 500;
}
.placeholderNote {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
.featureList {
margin: 16px 0 0;
padding-left: 18px;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.featureList li::marker {
color: var(--primary);
}
+107
View File
@@ -0,0 +1,107 @@
import {
Mail,
BellRing,
CircleHelp,
Award,
CreditCard,
Images,
LayoutGrid,
Building2,
Sparkles,
} from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs'
import styles from '../components/PageContent.module.css'
const websiteSections = [
{
icon: Images,
title: 'Sliders',
description: 'Manage homepage banner sliders and promotional image carousels.',
linkText: 'Manage sliders',
href: '/website/sliders',
},
{
icon: LayoutGrid,
title: 'Special Categories',
description: 'Highlight selected product categories on your website homepage.',
linkText: 'Manage categories',
href: '/website/special-categories',
},
{
icon: Building2,
title: 'Special Brands',
description: 'Showcase partner or featured brands on your website homepage.',
linkText: 'Manage brands',
href: '/website/special-brands',
},
{
icon: Sparkles,
title: 'Special Items',
description: 'Curate featured store items into categories for your website carousels.',
linkText: 'Manage special items',
href: '/website/special-items',
},
{
icon: Mail,
title: 'Contact Us Form',
description: 'View submissions from your website contact form.',
linkText: 'View submissions',
href: '/website/contact',
},
{
icon: BellRing,
title: 'Subscriptions',
description: 'Manage newsletter sign-ups and subscription options for visitors.',
linkText: 'Manage subscriptions',
href: '/website/subscriptions',
},
{
icon: CircleHelp,
title: 'FAQ',
description: 'Create and organize frequently asked questions for your website.',
linkText: 'Manage FAQ',
href: '/website/faq',
},
{
icon: Award,
title: 'Badges',
description: 'Show trust badges, certifications, and highlights on your website.',
linkText: 'Manage badges',
href: '/website/badges',
},
{
icon: CreditCard,
title: 'E-Payment',
description: 'Configure online payment gateways and checkout payment options.',
linkText: 'Manage e-payment',
href: '/website/e-payment',
},
]
export function WebsitePage() {
return (
<main className={styles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website' },
]}
/>
<div className={styles.pageHeader}>
<div>
<h2 className={styles.pageTitle}>Website</h2>
<p className={styles.pageSubtitle}>
Manage public website content, forms, and customer-facing settings.
</p>
</div>
</div>
<div className={styles.gridHome}>
{websiteSections.map((section) => (
<SectionCard key={section.title} {...section} />
))}
</div>
</main>
)
}
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react'
import {
AddWebsiteSliderSlideModal,
type WebsiteSliderSlideFormData,
} from '../components/AddWebsiteSliderSlideModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { WebsiteSliderGallery } from '../components/WebsiteSliderGallery'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import {
createWebsiteSlider,
listWebsiteSliders,
slidesToPayload,
updateWebsiteSlider,
} from '../services/websiteSliderService'
import type { WebsiteSlider, WebsiteSliderSlide } from '../types/websiteSlider'
import pageStyles from '../components/PageContent.module.css'
import styles from './StoreSpecialsPage.module.css'
const DEFAULT_SLIDER_TITLE = 'Homepage slider'
export function WebsiteSlidersPage() {
const { showToast } = useToast()
const [slider, setSlider] = useState<WebsiteSlider | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [addSlideOpen, setAddSlideOpen] = useState(false)
const [removeTarget, setRemoveTarget] = useState<WebsiteSliderSlide | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadSlider(controller.signal)
return () => controller.abort()
}, [])
async function loadSlider(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWebsiteSliders(1, 1, signal)
setSlider(data.items[0] ?? null)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load slides.')
}
} finally {
setIsLoading(false)
}
}
async function handleAddSlide(data: WebsiteSliderSlideFormData) {
setIsSaving(true)
setError('')
try {
const imageMediaId = await resolveDataUrlToMediaId(data.image, 'slider-slide.jpg')
if (!imageMediaId) {
throw new Error('Unable to upload slide image.')
}
const slideInput = {
imageMediaId,
...(data.title ? { title: data.title } : {}),
...(data.linkUrl ? { linkUrl: data.linkUrl } : {}),
isActive: true,
}
if (slider) {
const result = await updateWebsiteSlider(slider.id, {
slides: [...slidesToPayload(slider.slides), slideInput],
})
setSlider(result.slider)
} else {
const result = await createWebsiteSlider({
title: DEFAULT_SLIDER_TITLE,
slides: [slideInput],
})
setSlider(result.slider)
}
setAddSlideOpen(false)
showToast('Slide added.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to add slide.')
}
} finally {
setIsSaving(false)
}
}
async function confirmRemoveSlide() {
if (!slider || !removeTarget) return
const nextSlides = slidesToPayload(
slider.slides.filter((entry) => entry.id !== removeTarget.id),
)
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteSlider(slider.id, { slides: nextSlides })
setSlider(result.slider)
setRemoveTarget(null)
showToast('Slide removed.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to remove slide.')
}
} finally {
setIsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Sliders' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Sliders</h2>
<p className={pageStyles.pageSubtitle}>
Manage homepage banner slides in a 9:4 gallery.
</p>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading slides...</p>
) : (
<WebsiteSliderGallery
slides={slider?.slides ?? []}
onAddSlide={() => setAddSlideOpen(true)}
onRemoveSlide={setRemoveTarget}
/>
)}
<AddWebsiteSliderSlideModal
open={addSlideOpen}
onClose={() => !isSaving && setAddSlideOpen(false)}
onSubmit={(data) => void handleAddSlide(data)}
isSubmitting={isSaving}
/>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove Slide"
message="Remove this slide from the homepage slider?"
onConfirm={() => void confirmRemoveSlide()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
</main>
)
}
@@ -0,0 +1,314 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { PickWebsiteBrandsModal } from '../components/PickWebsiteBrandsModal'
import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
createWebsiteBrandGroup,
deleteWebsiteBrandGroup,
listWebsiteBrandGroups,
updateWebsiteBrandGroup,
} from '../services/websiteBrandGroupService'
import type { WebsiteBrandGroup } from '../types/websiteBrandGroup'
import pageStyles from '../components/PageContent.module.css'
import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialBrandsPage() {
const { showToast } = useToast()
const [groups, setGroups] = useState<WebsiteBrandGroup[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editGroupTarget, setEditGroupTarget] = useState<WebsiteBrandGroup | null>(null)
const [deleteGroupTarget, setDeleteGroupTarget] = useState<WebsiteBrandGroup | null>(null)
const [pickItemsTarget, setPickItemsTarget] = useState<WebsiteBrandGroup | null>(null)
const [removeTarget, setRemoveTarget] = useState<{
group: WebsiteBrandGroup
brandId: string
brandName: string
} | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadGroups(controller.signal)
return () => controller.abort()
}, [])
async function loadGroups(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWebsiteBrandGroups(1, 50, signal)
setGroups(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load special brand groups.')
}
} finally {
setIsLoading(false)
}
}
function replaceGroup(updated: WebsiteBrandGroup) {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
async function handleCreateGroup(title: string) {
setIsSaving(true)
setError('')
try {
const result = await createWebsiteBrandGroup({
title,
sortOrder: groups.length,
})
setGroups((prev) => [...prev, result.group])
setCreateOpen(false)
showToast('Special brand group created.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to create special brand group.')
}
} finally {
setIsSaving(false)
}
}
async function handleEditGroup(title: string) {
if (!editGroupTarget) return
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteBrandGroup(editGroupTarget.id, { title })
replaceGroup(result.group)
setEditGroupTarget(null)
showToast('Special brand group updated.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update special brand group.')
}
} finally {
setIsSaving(false)
}
}
async function confirmDeleteGroup() {
if (!deleteGroupTarget) return
setIsSaving(true)
setError('')
try {
await deleteWebsiteBrandGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null)
showToast('Special brand group deleted.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete special brand group.')
}
} finally {
setIsSaving(false)
}
}
async function handleAddBrands(brandIds: string[]) {
if (!pickItemsTarget) return
const existingIds = pickItemsTarget.items.map((item) => item.id)
const nextIds = [...existingIds, ...brandIds]
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteBrandGroup(pickItemsTarget.id, {
brandIds: nextIds,
})
replaceGroup(result.group)
setPickItemsTarget(null)
showToast('Brands added to group.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to add brands.')
}
} finally {
setIsSaving(false)
}
}
async function confirmRemoveFromGroup() {
if (!removeTarget) return
const { group, brandId } = removeTarget
const nextIds = group.items.map((item) => item.id).filter((id) => id !== brandId)
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteBrandGroup(group.id, {
brandIds: nextIds,
})
replaceGroup(result.group)
setRemoveTarget(null)
showToast('Removed from special brand group.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to remove brand.')
}
} finally {
setIsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Special Brands' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Special Brands</h2>
<p className={pageStyles.pageSubtitle}>
Curate featured brands into groups for your website homepage.
</p>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading special brand groups...</p>
) : groups.length === 0 ? (
<p className={styles.empty}>
No special brand groups yet. Use the + button to create one, then add brands to each
carousel.
</p>
) : (
<div className={styles.carousels}>
{groups.map((group) => (
<WebsiteGroupCarousel
key={group.id}
title={group.title}
items={group.items}
itemKey={(item) => item.id}
onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)}
addTooltip={`Add brands to ${group.title}`}
editTooltip="Edit group"
deleteTooltip="Delete group"
renderItem={(item) => (
<WebsiteGroupItemCard
title={item.nameEn}
nameFa={item.nameFa}
subtitle={item.about}
onRemove={() =>
setRemoveTarget({
group,
brandId: item.id,
brandName: item.nameEn,
})
}
removeTooltip="Remove from group"
/>
)}
/>
))}
</div>
)}
<div className={fabStyles.fabDock}>
<button
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
aria-label="Add special brand group"
>
<Plus size={24} />
</button>
</div>
<StoreSpecialModal
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup}
title="Add Special Brand Group"
isSubmitting={isSaving}
/>
<StoreSpecialModal
open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup}
initialTitle={editGroupTarget?.title ?? ''}
title="Edit Special Brand Group"
submitLabel="Save"
isSubmitting={isSaving}
/>
<PickWebsiteBrandsModal
open={!!pickItemsTarget}
groupTitle={pickItemsTarget?.title ?? ''}
existingBrandIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
onClose={() => !isSaving && setPickItemsTarget(null)}
onConfirm={handleAddBrands}
isSubmitting={isSaving}
/>
<ConfirmDeleteModal
open={!!deleteGroupTarget}
title="Delete Special Brand Group"
message={
deleteGroupTarget
? `Delete "${deleteGroupTarget.title}"? Brands will remain in your catalog.`
: ''
}
onConfirm={() => void confirmDeleteGroup()}
onCancel={() => !isSaving && setDeleteGroupTarget(null)}
/>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove from Group"
message={
removeTarget
? `Remove "${removeTarget.brandName}" from "${removeTarget.group.title}"?`
: ''
}
onConfirm={() => void confirmRemoveFromGroup()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
</main>
)
}
@@ -0,0 +1,314 @@
import { useEffect, useState } from 'react'
import { Plus } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { PickWebsiteCategoriesModal } from '../components/PickWebsiteCategoriesModal'
import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
import {
createWebsiteCategoryGroup,
deleteWebsiteCategoryGroup,
listWebsiteCategoryGroups,
updateWebsiteCategoryGroup,
} from '../services/websiteCategoryGroupService'
import type { WebsiteCategoryGroup } from '../types/websiteCategoryGroup'
import pageStyles from '../components/PageContent.module.css'
import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialCategoriesPage() {
const { showToast } = useToast()
const [groups, setGroups] = useState<WebsiteCategoryGroup[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editGroupTarget, setEditGroupTarget] = useState<WebsiteCategoryGroup | null>(null)
const [deleteGroupTarget, setDeleteGroupTarget] = useState<WebsiteCategoryGroup | null>(null)
const [pickItemsTarget, setPickItemsTarget] = useState<WebsiteCategoryGroup | null>(null)
const [removeTarget, setRemoveTarget] = useState<{
group: WebsiteCategoryGroup
categoryId: string
categoryName: string
} | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadGroups(controller.signal)
return () => controller.abort()
}, [])
async function loadGroups(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWebsiteCategoryGroups(1, 50, signal)
setGroups(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to load special category groups.')
}
} finally {
setIsLoading(false)
}
}
function replaceGroup(updated: WebsiteCategoryGroup) {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
async function handleCreateGroup(title: string) {
setIsSaving(true)
setError('')
try {
const result = await createWebsiteCategoryGroup({
title,
sortOrder: groups.length,
})
setGroups((prev) => [...prev, result.group])
setCreateOpen(false)
showToast('Special category group created.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to create special category group.')
}
} finally {
setIsSaving(false)
}
}
async function handleEditGroup(title: string) {
if (!editGroupTarget) return
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteCategoryGroup(editGroupTarget.id, { title })
replaceGroup(result.group)
setEditGroupTarget(null)
showToast('Special category group updated.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to update special category group.')
}
} finally {
setIsSaving(false)
}
}
async function confirmDeleteGroup() {
if (!deleteGroupTarget) return
setIsSaving(true)
setError('')
try {
await deleteWebsiteCategoryGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null)
showToast('Special category group deleted.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to delete special category group.')
}
} finally {
setIsSaving(false)
}
}
async function handleAddCategories(categoryIds: string[]) {
if (!pickItemsTarget) return
const existingIds = pickItemsTarget.items.map((item) => item.id)
const nextIds = [...existingIds, ...categoryIds]
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteCategoryGroup(pickItemsTarget.id, {
categoryIds: nextIds,
})
replaceGroup(result.group)
setPickItemsTarget(null)
showToast('Categories added to group.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to add categories.')
}
} finally {
setIsSaving(false)
}
}
async function confirmRemoveFromGroup() {
if (!removeTarget) return
const { group, categoryId } = removeTarget
const nextIds = group.items.map((item) => item.id).filter((id) => id !== categoryId)
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteCategoryGroup(group.id, {
categoryIds: nextIds,
})
replaceGroup(result.group)
setRemoveTarget(null)
showToast('Removed from special category group.', 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError('Unable to remove category.')
}
} finally {
setIsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Special Categories' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>Special Categories</h2>
<p className={pageStyles.pageSubtitle}>
Curate featured product categories into groups for your website homepage.
</p>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
{isLoading ? (
<p className={styles.empty}>Loading special category groups...</p>
) : groups.length === 0 ? (
<p className={styles.empty}>
No special category groups yet. Use the + button to create one, then add categories to
each carousel.
</p>
) : (
<div className={styles.carousels}>
{groups.map((group) => (
<WebsiteGroupCarousel
key={group.id}
title={group.title}
items={group.items}
itemKey={(item) => item.id}
onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)}
addTooltip={`Add categories to ${group.title}`}
editTooltip="Edit group"
deleteTooltip="Delete group"
renderItem={(item) => (
<WebsiteGroupItemCard
title={item.name}
nameFa={item.nameFa}
subtitle={item.description}
onRemove={() =>
setRemoveTarget({
group,
categoryId: item.id,
categoryName: item.name,
})
}
removeTooltip="Remove from group"
/>
)}
/>
))}
</div>
)}
<div className={fabStyles.fabDock}>
<button
type="button"
className={fabStyles.addFab}
onClick={() => setCreateOpen(true)}
aria-label="Add special category group"
>
<Plus size={24} />
</button>
</div>
<StoreSpecialModal
open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup}
title="Add Special Category Group"
isSubmitting={isSaving}
/>
<StoreSpecialModal
open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup}
initialTitle={editGroupTarget?.title ?? ''}
title="Edit Special Category Group"
submitLabel="Save"
isSubmitting={isSaving}
/>
<PickWebsiteCategoriesModal
open={!!pickItemsTarget}
groupTitle={pickItemsTarget?.title ?? ''}
existingCategoryIds={pickItemsTarget?.items.map((item) => item.id) ?? []}
onClose={() => !isSaving && setPickItemsTarget(null)}
onConfirm={handleAddCategories}
isSubmitting={isSaving}
/>
<ConfirmDeleteModal
open={!!deleteGroupTarget}
title="Delete Special Category Group"
message={
deleteGroupTarget
? `Delete "${deleteGroupTarget.title}"? Product categories will remain in your catalog.`
: ''
}
onConfirm={() => void confirmDeleteGroup()}
onCancel={() => !isSaving && setDeleteGroupTarget(null)}
/>
<ConfirmDeleteModal
open={!!removeTarget}
title="Remove from Group"
message={
removeTarget
? `Remove "${removeTarget.categoryName}" from "${removeTarget.group.title}"?`
: ''
}
onConfirm={() => void confirmRemoveFromGroup()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
</main>
)
}
@@ -0,0 +1,24 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import styles from './WebsitePage.module.css'
export function WebsiteSubscriptionsPage() {
return (
<WebsiteSectionShell
sectionLabel="Subscriptions"
title="Subscriptions"
subtitle="Manage newsletter and subscription sign-ups on your website."
>
<p className={styles.placeholderLead}>
Control how visitors subscribe to updates from your business.
</p>
<p className={styles.placeholderNote}>
Subscription management will be connected here. Planned options include:
</p>
<ul className={styles.featureList}>
<li>Enable or disable subscription forms</li>
<li>Custom welcome message and consent text</li>
<li>Export or view subscriber list</li>
</ul>
</WebsiteSectionShell>
)
}