mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Initial commit: Meshkee dashboards monorepo.
Includes business, customer, and super-admin apps with shared packages and production deploy scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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's what'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;
|
||||
}
|
||||
}
|
||||
@@ -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'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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user