mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Polish business FA layout and wire special-group keys plus SSL sync.
RTL carousels/FABs, special key+title fields, slider gallery fixes, and dashboard SSL sync agent for super-admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
66004a0fba
commit
672091d1f5
@@ -6,6 +6,7 @@ import { TagInput } from '../components/TagInput'
|
||||
import { RichTextEditor } from '../components/RichTextEditor'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
listBlogCategories,
|
||||
@@ -32,6 +33,7 @@ export function AddNewBlogPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -61,7 +63,7 @@ export function AddNewBlogPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('blog.categories.errorLoad'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,10 +133,10 @@ export function AddNewBlogPage() {
|
||||
categoryId: categoryId || null,
|
||||
featuredMediaId: nextFeaturedMediaId,
|
||||
})
|
||||
showToast('Blog post updated.', 'success')
|
||||
showToast(t('blog.form.toast.updated'), 'success')
|
||||
} else {
|
||||
await createBlog(payload)
|
||||
showToast('Blog post created.', 'success')
|
||||
showToast(t('blog.form.toast.created'), 'success')
|
||||
}
|
||||
|
||||
navigate('/blog/list')
|
||||
@@ -144,7 +146,7 @@ export function AddNewBlogPage() {
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save blog post.')
|
||||
setError(t('blog.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -172,12 +174,10 @@ export function AddNewBlogPage() {
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? 'Edit Blog' : 'Add New Blog'}
|
||||
{isEdit ? t('title.editBlog') : t('title.addBlog')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit
|
||||
? 'Update blog post details and save changes.'
|
||||
: 'Create and publish a new blog post.'}
|
||||
{isEdit ? t('blog.form.edit.subtitle') : t('blog.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,19 +191,19 @@ export function AddNewBlogPage() {
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
|
||||
<label>Title Image</label>
|
||||
<label>{t('blog.form.titleImage')}</label>
|
||||
<ImageCropper
|
||||
value={titleImage}
|
||||
onChange={setTitleImage}
|
||||
aspect={3 / 2}
|
||||
uploadLabel="Upload title image"
|
||||
hint="Click to select, then crop"
|
||||
changeLabel="Change title image"
|
||||
uploadLabel={t('blog.form.uploadTitleImage')}
|
||||
hint={t('blog.form.cropHint')}
|
||||
changeLabel={t('blog.form.changeTitleImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col3}`}>
|
||||
<label htmlFor="blog-type">Type</label>
|
||||
<label htmlFor="blog-type">{t('blog.form.type')}</label>
|
||||
<select
|
||||
id="blog-type"
|
||||
value={type}
|
||||
@@ -212,29 +212,35 @@ export function AddNewBlogPage() {
|
||||
>
|
||||
{BLOG_TYPE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(
|
||||
option.value === 'news'
|
||||
? 'blog.form.type.news'
|
||||
: option.value === 'article'
|
||||
? 'blog.form.type.article'
|
||||
: 'blog.form.type.blog',
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col6Span}`}>
|
||||
<label>Category</label>
|
||||
<label>{t('blog.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
placeholder={t('blog.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col9}`}>
|
||||
<label htmlFor="blog-title">Title</label>
|
||||
<label htmlFor="blog-title">{t('blog.form.title')}</label>
|
||||
<input
|
||||
id="blog-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Blog post title"
|
||||
placeholder={t('blog.form.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
@@ -243,12 +249,12 @@ export function AddNewBlogPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col9}`}>
|
||||
<label htmlFor="blog-abstract">Abstract</label>
|
||||
<label htmlFor="blog-abstract">{t('blog.form.abstract')}</label>
|
||||
<textarea
|
||||
id="blog-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in blog listings"
|
||||
placeholder={t('blog.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
@@ -257,28 +263,32 @@ export function AddNewBlogPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Main Text</label>
|
||||
<label>{t('blog.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder="Full blog content with formatting and images..."
|
||||
placeholder={t('blog.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={320}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Tags</label>
|
||||
<label>{t('blog.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/blog/list" className={styles.cancelBtn}>
|
||||
Cancel
|
||||
{t('blog.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : isEdit ? 'Update Blog' : 'Save Blog'}
|
||||
{isSubmitting
|
||||
? t('blog.form.saving')
|
||||
: isEdit
|
||||
? t('blog.form.update')
|
||||
: t('blog.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TagInput } from '../components/TagInput'
|
||||
import { RichTextEditor } from '../components/RichTextEditor'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
listPortfolioCategories,
|
||||
@@ -32,19 +33,19 @@ type TitleImageAspectId = 'square' | '3:2' | '16:9' | '9:16'
|
||||
|
||||
const TITLE_IMAGE_ASPECTS: {
|
||||
id: TitleImageAspectId
|
||||
label: string
|
||||
aspect: number
|
||||
}[] = [
|
||||
{ id: 'square', label: 'Square', aspect: 1 },
|
||||
{ id: '3:2', label: '3:2', aspect: 3 / 2 },
|
||||
{ id: '16:9', label: '16:9', aspect: 16 / 9 },
|
||||
{ id: '9:16', label: '9:16 (Reel)', aspect: 9 / 16 },
|
||||
{ id: 'square', aspect: 1 },
|
||||
{ id: '3:2', aspect: 3 / 2 },
|
||||
{ id: '16:9', aspect: 16 / 9 },
|
||||
{ id: '9:16', aspect: 9 / 16 },
|
||||
]
|
||||
|
||||
export function AddNewPortfolioPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const isEdit = Boolean(id)
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -79,7 +80,7 @@ export function AddNewPortfolioPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('portfolio.categories.errorLoad'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,10 +160,10 @@ export function AddNewPortfolioPage() {
|
||||
categoryId: categoryId || null,
|
||||
featuredMediaId: nextFeaturedMediaId,
|
||||
})
|
||||
showToast('Portfolio updated.', 'success')
|
||||
showToast(t('portfolio.form.toast.updated'), 'success')
|
||||
} else {
|
||||
await createPortfolio(payload)
|
||||
showToast('Portfolio created.', 'success')
|
||||
showToast(t('portfolio.form.toast.created'), 'success')
|
||||
}
|
||||
|
||||
navigate('/portfolios/list')
|
||||
@@ -172,7 +173,7 @@ export function AddNewPortfolioPage() {
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save portfolio.')
|
||||
setError(t('portfolio.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -200,12 +201,10 @@ export function AddNewPortfolioPage() {
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>
|
||||
{isEdit ? 'Edit Portfolio' : 'Add New Portfolio'}
|
||||
{isEdit ? t('title.editPortfolio') : t('title.addPortfolio')}
|
||||
</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{isEdit
|
||||
? 'Update portfolio details and save changes.'
|
||||
: 'Create and publish a new portfolio project.'}
|
||||
{isEdit ? t('portfolio.form.edit.subtitle') : t('portfolio.form.add.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,11 +218,11 @@ export function AddNewPortfolioPage() {
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
|
||||
<label>Title Image</label>
|
||||
<label>{t('portfolio.form.titleImage')}</label>
|
||||
<div
|
||||
className={styles.aspectOptions}
|
||||
role="radiogroup"
|
||||
aria-label="Title image aspect ratio"
|
||||
aria-label={t('portfolio.form.aspectAria')}
|
||||
>
|
||||
{TITLE_IMAGE_ASPECTS.map((option) => (
|
||||
<button
|
||||
@@ -245,7 +244,11 @@ export function AddNewPortfolioPage() {
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{option.label}
|
||||
{option.id === 'square'
|
||||
? t('portfolio.form.aspect.square')
|
||||
: option.id === '9:16'
|
||||
? t('portfolio.form.aspect.reel')
|
||||
: option.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -253,26 +256,26 @@ export function AddNewPortfolioPage() {
|
||||
value={titleImage}
|
||||
onChange={setTitleImage}
|
||||
aspect={selectedAspect}
|
||||
uploadLabel="Upload title image"
|
||||
hint="Click to select, then crop"
|
||||
changeLabel="Change title image"
|
||||
uploadLabel={t('portfolio.form.uploadTitleImage')}
|
||||
hint={t('portfolio.form.cropHint')}
|
||||
changeLabel={t('portfolio.form.changeTitleImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>Category</label>
|
||||
<label>{t('portfolio.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
placeholder={t('portfolio.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-fa">Title (FA)</label>
|
||||
<label htmlFor="portfolio-title-fa">{t('portfolio.form.titleFa')}</label>
|
||||
<input
|
||||
id="portfolio-title-fa"
|
||||
name="titleFa"
|
||||
@@ -280,7 +283,7 @@ export function AddNewPortfolioPage() {
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder="عنوان فارسی"
|
||||
placeholder={t('portfolio.form.titleFaPlaceholder')}
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
@@ -289,13 +292,13 @@ export function AddNewPortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-en">Title (EN)</label>
|
||||
<label htmlFor="portfolio-title-en">{t('portfolio.form.titleEn')}</label>
|
||||
<input
|
||||
id="portfolio-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="English title"
|
||||
placeholder={t('portfolio.form.titleEnPlaceholder')}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -304,12 +307,12 @@ export function AddNewPortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-abstract">Abstract</label>
|
||||
<label htmlFor="portfolio-abstract">{t('portfolio.form.abstract')}</label>
|
||||
<textarea
|
||||
id="portfolio-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in portfolio listings"
|
||||
placeholder={t('portfolio.form.abstractPlaceholder')}
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
@@ -318,11 +321,11 @@ export function AddNewPortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>Main Text</label>
|
||||
<label>{t('portfolio.form.mainText')}</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder="Full portfolio content with formatting and images..."
|
||||
placeholder={t('portfolio.form.mainTextPlaceholder')}
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
@@ -330,22 +333,26 @@ export function AddNewPortfolioPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Image Gallery</label>
|
||||
<label>{t('portfolio.form.gallery')}</label>
|
||||
<ImageUploader images={images} onChange={setImages} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Tags</label>
|
||||
<label>{t('portfolio.form.tags')}</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/portfolios/list" className={styles.cancelBtn}>
|
||||
Cancel
|
||||
{t('portfolio.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : isEdit ? 'Update Portfolio' : 'Save Portfolio'}
|
||||
{isSubmitting
|
||||
? t('portfolio.form.saving')
|
||||
: isEdit
|
||||
? t('portfolio.form.update')
|
||||
: t('portfolio.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -66,14 +66,14 @@ export function AddNewProductPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load form options.')
|
||||
setError(t('products.form.errorOptions'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadFormOptions()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEdit || !id) return
|
||||
@@ -157,7 +157,7 @@ export function AddNewProductPage() {
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save product.')
|
||||
setError(t('products.form.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -202,43 +202,44 @@ export function AddNewProductPage() {
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
|
||||
<label>Thumbnail Image</label>
|
||||
<label>{t('products.form.thumbnail')}</label>
|
||||
<ImageCropper value={thumbnail} onChange={setThumbnail} />
|
||||
</div>
|
||||
|
||||
<div className={styles.col10}>
|
||||
<div className={styles.topFields}>
|
||||
<div className={`${styles.field} ${styles.fieldCategory}`}>
|
||||
<label>Category</label>
|
||||
<label>{t('products.form.category')}</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
placeholder={t('products.form.categoryPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldBrand}`}>
|
||||
<label>
|
||||
Brand <span className={styles.optional}>(optional)</span>
|
||||
{t('products.form.brand')}{' '}
|
||||
<span className={styles.optional}>{t('products.form.optional')}</span>
|
||||
</label>
|
||||
<SearchableSelect
|
||||
options={brandOptions}
|
||||
value={brandId}
|
||||
onChange={setBrandId}
|
||||
placeholder="Search and select brand..."
|
||||
placeholder={t('products.form.brandPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldHalf}`}>
|
||||
<label htmlFor="nameFa">Name (FA)</label>
|
||||
<label htmlFor="nameFa">{t('products.form.nameFa')}</label>
|
||||
<input
|
||||
id="nameFa"
|
||||
name="nameFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
className="faText"
|
||||
placeholder="نام محصول"
|
||||
placeholder={t('products.form.nameFaPlaceholder')}
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
required
|
||||
@@ -247,13 +248,13 @@ export function AddNewProductPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldHalf}`}>
|
||||
<label htmlFor="nameEn">Name (EN)</label>
|
||||
<label htmlFor="nameEn">{t('products.form.nameEn')}</label>
|
||||
<input
|
||||
id="nameEn"
|
||||
name="nameEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Product name"
|
||||
placeholder={t('products.form.nameEnPlaceholder')}
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
required
|
||||
@@ -262,12 +263,12 @@ export function AddNewProductPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
|
||||
<label htmlFor="summary">Summary</label>
|
||||
<label htmlFor="summary">{t('products.form.summary')}</label>
|
||||
<textarea
|
||||
id="summary"
|
||||
name="summary"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in product listings"
|
||||
placeholder={t('products.form.summaryPlaceholder')}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
required
|
||||
@@ -278,31 +279,39 @@ export function AddNewProductPage() {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Description</label>
|
||||
<label>{t('products.form.description')}</label>
|
||||
<RichTextEditor
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
placeholder="Full product description with formatting..."
|
||||
placeholder={t('products.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Product Images</label>
|
||||
<label>{t('products.form.images')}</label>
|
||||
<ImageUploader images={images} onChange={setImages} />
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Tags</label>
|
||||
<TagInput tags={tags} onChange={setTags} />
|
||||
<label>{t('products.form.tags')}</label>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder={t('products.form.tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link to="/products/list" className={styles.cancelBtn}>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</Link>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : isEdit ? 'Update Product' : 'Save Product'}
|
||||
{isSubmitting
|
||||
? t('products.form.saving')
|
||||
: isEdit
|
||||
? t('products.form.update')
|
||||
: t('products.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createBlogCategory,
|
||||
deleteBlogCategory,
|
||||
@@ -18,11 +19,12 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function BlogCategoriesPage() {
|
||||
const t = useT()
|
||||
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 [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -46,17 +48,17 @@ export function BlogCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('blog.categories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title = 'Add Category') {
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
@@ -66,7 +68,7 @@ export function BlogCategoriesPage() {
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ export function BlogCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save category.')
|
||||
setError(t('blog.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -139,7 +141,7 @@ export function BlogCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete category.')
|
||||
setError(t('blog.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -157,10 +159,8 @@ export function BlogCategoriesPage() {
|
||||
/>
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('blog.categories.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('blog.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,15 +172,15 @@ export function BlogCategoriesPage() {
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading categories...</p>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>No categories yet. Click + to add one.</p>
|
||||
<p className={styles.empty}>{t('blog.categories.empty')}</p>
|
||||
) : (
|
||||
<BlogCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
@@ -211,10 +211,10 @@ export function BlogCategoriesPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Category"
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
@@ -226,7 +226,7 @@ export function BlogCategoriesPage() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { CalendarDays, User } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { BlogCommentsSection } from '../components/BlogCommentsSection'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
formatBlogAuthor,
|
||||
@@ -16,6 +18,8 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogDetailsPage.module.css'
|
||||
|
||||
export function BlogDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [blog, setBlog] = useState<BlogDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -38,7 +42,7 @@ export function BlogDetailsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load blog post.')
|
||||
setError(t('blog.details.errorLoad'))
|
||||
}
|
||||
setBlog(null)
|
||||
} finally {
|
||||
@@ -53,7 +57,7 @@ export function BlogDetailsPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>Loading blog post...</p>
|
||||
<p className={styles.status}>{t('blog.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -61,17 +65,28 @@ export function BlogDetailsPage() {
|
||||
if (error || !blog || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || 'Blog post not found.'}</p>
|
||||
<p className={styles.error}>{error || t('blog.details.notFound')}</p>
|
||||
<Link to="/blog/list" className={styles.backLink}>
|
||||
Back to My Blogs
|
||||
{t('blog.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
|
||||
const typeLabel =
|
||||
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type
|
||||
const displayDate = formatBlogDate(
|
||||
blog.publishedAt ?? blog.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
const typeValue = BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.value
|
||||
const typeLabel = typeValue
|
||||
? t(
|
||||
typeValue === 'news'
|
||||
? 'blog.form.type.news'
|
||||
: typeValue === 'article'
|
||||
? 'blog.form.type.article'
|
||||
: 'blog.form.type.blog',
|
||||
)
|
||||
: blog.type
|
||||
const titleLocale = textLocaleAttrs(blog.title)
|
||||
const abstractLocale = textLocaleAttrs(blog.abstract)
|
||||
const contentLocale = textLocaleAttrs(
|
||||
@@ -155,13 +170,13 @@ export function BlogDetailsPage() {
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content} lang="en" dir="ltr">
|
||||
No content yet.
|
||||
{t('blog.details.noContent')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.authorRow}>
|
||||
<User size={16} />
|
||||
<span className={styles.authorLabel}>Author</span>
|
||||
<span className={styles.authorLabel}>{t('blog.details.author')}</span>
|
||||
<span className={styles.authorName}>{formatBlogAuthor(blog.author)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BlogCommentsModal } from '../components/BlogCommentsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
BLOGS_PER_PAGE,
|
||||
@@ -23,6 +24,7 @@ import styles from './BlogPage.module.css'
|
||||
export function BlogListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const [blogs, setBlogs] = useState<Blog[]>([])
|
||||
const [totalBlogs, setTotalBlogs] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -58,7 +60,7 @@ export function BlogListPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load blog posts.')
|
||||
setError(t('blog.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -91,7 +93,7 @@ export function BlogListPage() {
|
||||
|
||||
try {
|
||||
await deleteBlog(deleteTarget.id)
|
||||
showToast('Blog post removed.', 'success')
|
||||
showToast(t('blog.list.toast.removed'), 'success')
|
||||
const nextTotal = totalBlogs - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / BLOGS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
@@ -102,7 +104,7 @@ export function BlogListPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete blog post.')
|
||||
setError(t('blog.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
@@ -135,12 +137,15 @@ export function BlogListPage() {
|
||||
setBlogs((prev) =>
|
||||
prev.map((item) => (item.id === id ? mapBlogApiToUi(result.blog) : item)),
|
||||
)
|
||||
showToast(nextVerified ? 'Blog verified.' : 'Blog unverified.', 'success')
|
||||
showToast(
|
||||
nextVerified ? t('blog.list.toast.verified') : t('blog.list.toast.unverified'),
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to update blog verification.')
|
||||
setError(t('blog.list.errorVerify'))
|
||||
}
|
||||
} finally {
|
||||
setVerifyingId(null)
|
||||
@@ -159,9 +164,9 @@ export function BlogListPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Blogs</h2>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.myBlogs')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{totalBlogs} posts · View, edit and manage your blog content.
|
||||
{t('blog.list.subtitle', { count: totalBlogs })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,9 +178,9 @@ export function BlogListPage() {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading blog posts...</p>
|
||||
<p className={styles.empty}>{t('blog.list.loading')}</p>
|
||||
) : blogs.length === 0 ? (
|
||||
<p className={styles.empty}>No blog posts found.</p>
|
||||
<p className={styles.empty}>{t('blog.list.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
@@ -205,17 +210,17 @@ export function BlogListPage() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/blog/new')}
|
||||
aria-label="Add new blog"
|
||||
aria-label={t('blog.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Blog Post"
|
||||
title={t('blog.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.`
|
||||
? t('blog.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
|
||||
.addFab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -32,7 +33,7 @@
|
||||
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;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.addFab:hover {
|
||||
@@ -42,7 +43,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.addFab {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
import { FileText, PlusCircle, FolderTree, Settings } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const blogSections = [
|
||||
const blogSections: {
|
||||
icon: typeof FileText
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'My Blogs',
|
||||
description: 'View, edit and manage all your blog posts.',
|
||||
linkText: 'View blogs',
|
||||
titleKey: 'nav.blog.list',
|
||||
descKey: 'blog.card.list.desc',
|
||||
linkKey: 'blog.card.list.link',
|
||||
href: '/blog/list',
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
title: 'Add New Blog',
|
||||
description: 'Create and publish a new blog post.',
|
||||
linkText: 'Add blog',
|
||||
titleKey: 'nav.blog.new',
|
||||
descKey: 'blog.card.new.desc',
|
||||
linkKey: 'blog.card.new.link',
|
||||
href: '/blog/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
title: 'Blog Categories',
|
||||
description: 'Organize your blog posts into categories and subcategories.',
|
||||
linkText: 'View categories',
|
||||
titleKey: 'blog.card.categories.title',
|
||||
descKey: 'blog.card.categories.desc',
|
||||
linkKey: 'blog.card.categories.link',
|
||||
href: '/blog/categories',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
description: 'Configure blog comment moderation and display options.',
|
||||
linkText: 'View settings',
|
||||
titleKey: 'nav.blog.settings',
|
||||
descKey: 'blog.card.settings.desc',
|
||||
linkKey: 'blog.card.settings.link',
|
||||
href: '/blog/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function BlogPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -45,16 +55,21 @@ export function BlogPage() {
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Blog</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Create and manage blog posts and categories.
|
||||
</p>
|
||||
<h2 className={styles.pageTitle}>{t('title.blog')}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('blog.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{blogSections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
|
||||
}
|
||||
|
||||
export function BlogSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -41,7 +43,7 @@ export function BlogSettingsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load settings.')
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -57,12 +59,12 @@ export function BlogSettingsPage() {
|
||||
comments: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast('Settings saved.', 'success')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -81,10 +83,8 @@ export function BlogSettingsPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Blog settings</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Configure how blog comments are moderated.
|
||||
</p>
|
||||
<h2 className={pageStyles.pageTitle}>{t('blog.settings.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('blog.settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,27 +95,24 @@ export function BlogSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h3 className={styles.sectionTitle}>Moderation</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>Loading settings...</p>
|
||||
<p className={styles.status}>{t('productSettings.loading')}</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
|
||||
{t('blog.settings.commentsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>
|
||||
New blog comments are published immediately when submitted. You can still
|
||||
reject them later if needed.
|
||||
</p>
|
||||
<p className={styles.rowDescription}>{t('blog.settings.commentsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="blog-comments-auto-approve"
|
||||
checked={settings.comments.autoApprove}
|
||||
disabled={savingKey === 'comments'}
|
||||
aria-label="Auto-approve blog comments"
|
||||
aria-label={t('blog.settings.commentsAutoAria')}
|
||||
onChange={(checked) => void handleCommentsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
z-index: 50;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.addFab {
|
||||
@@ -52,7 +53,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fabDock {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,16 @@ import {
|
||||
toBrandFormPayload,
|
||||
updateBrand,
|
||||
} from '../services/brandService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BrandsPage.module.css'
|
||||
|
||||
export function BrandsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [brands, setBrands] = useState<Brand[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [modalTitle, setModalTitle] = useState('Add Brand')
|
||||
const [modalTitle, setModalTitle] = useState(() => t('brands.add'))
|
||||
const [editingBrand, setEditingBrand] = useState<Brand | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -47,7 +49,7 @@ export function BrandsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load brands.')
|
||||
setError(t('brands.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -56,13 +58,13 @@ export function BrandsPage() {
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingBrand(null)
|
||||
setModalTitle('Add Brand')
|
||||
setModalTitle(t('brands.add'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function openEditModal(brand: Brand) {
|
||||
setEditingBrand(brand)
|
||||
setModalTitle('Edit Brand')
|
||||
setModalTitle(t('brands.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
@@ -87,11 +89,11 @@ export function BrandsPage() {
|
||||
brand.id === editingBrand.id ? mapBrandApiToUi(result.brand) : brand,
|
||||
),
|
||||
)
|
||||
showToast('Brand updated.', 'success')
|
||||
showToast(t('brands.toast.updated'), 'success')
|
||||
} else {
|
||||
const result = await createBrand(toBrandFormPayload(data, resolvedImageMediaId))
|
||||
setBrands((prev) => [...prev, mapBrandApiToUi(result.brand)])
|
||||
showToast('Brand created.', 'success')
|
||||
showToast(t('brands.toast.created'), 'success')
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
@@ -100,7 +102,7 @@ export function BrandsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save brand.')
|
||||
setError(t('brands.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -117,12 +119,12 @@ export function BrandsPage() {
|
||||
await deleteBrand(deleteTarget.id)
|
||||
setBrands((prev) => prev.filter((brand) => brand.id !== deleteTarget.id))
|
||||
setDeleteTarget(null)
|
||||
showToast('Brand deleted.', 'success')
|
||||
showToast(t('brands.toast.deleted'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete brand.')
|
||||
setError(t('brands.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -140,10 +142,8 @@ export function BrandsPage() {
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Brands</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage product brands for your store catalog.
|
||||
</p>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.brands')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('brands.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,9 +155,9 @@ export function BrandsPage() {
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading brands...</p>
|
||||
<p className={styles.empty}>{t('brands.loading')}</p>
|
||||
) : brands.length === 0 ? (
|
||||
<p className={styles.empty}>No brands yet. Click + to add one.</p>
|
||||
<p className={styles.empty}>{t('brands.empty')}</p>
|
||||
) : (
|
||||
brands.map((brand) => (
|
||||
<BrandRow
|
||||
@@ -192,10 +192,12 @@ export function BrandsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Brand"
|
||||
title={t('brands.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Products linked to this brand will have their brand cleared.`
|
||||
? t('brands.deleteMessage', {
|
||||
name: deleteTarget.nameFa || deleteTarget.nameEn,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
@@ -207,7 +209,7 @@ export function BrandsPage() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={openCreateModal}
|
||||
aria-label="Add brand"
|
||||
aria-label={t('brands.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
@@ -27,12 +27,13 @@
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.addFab {
|
||||
@@ -56,7 +57,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fabDock {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -33,17 +33,19 @@ import {
|
||||
toCategoryFormPayload,
|
||||
updateProductCategory,
|
||||
} from '../services/productCategoryService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function CategoriesPage() {
|
||||
const t = useT()
|
||||
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 [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -95,10 +97,10 @@ export function CategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title = 'Add Category') {
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
@@ -108,7 +110,7 @@ export function CategoriesPage() {
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
@@ -224,7 +226,9 @@ export function CategoriesPage() {
|
||||
|
||||
setVariationsTarget({
|
||||
categoryId,
|
||||
categoryName: `${category.nameEn} · ${category.nameFa}`,
|
||||
categoryName: category.nameFa
|
||||
? `${category.nameFa} · ${category.nameEn}`
|
||||
: category.nameEn,
|
||||
})
|
||||
setVariationsLoading(true)
|
||||
setError('')
|
||||
@@ -263,7 +267,9 @@ export function CategoriesPage() {
|
||||
|
||||
setTechnicalTarget({
|
||||
categoryId,
|
||||
categoryName: `${category.nameEn} · ${category.nameFa}`,
|
||||
categoryName: category.nameFa
|
||||
? `${category.nameFa} · ${category.nameEn}`
|
||||
: category.nameEn,
|
||||
})
|
||||
setTechnicalLoading(true)
|
||||
setTechnicalError('')
|
||||
@@ -421,10 +427,8 @@ export function CategoriesPage() {
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Categories</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Manage your digital product categories and subcategories.
|
||||
</p>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.categories')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('categories.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,9 +440,9 @@ export function CategoriesPage() {
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading categories...</p>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>No categories yet. Use + or AI to add categories.</p>
|
||||
<p className={styles.empty}>{t('categories.empty')}</p>
|
||||
) : (
|
||||
<CategoryTree
|
||||
categories={categories}
|
||||
@@ -446,7 +450,7 @@ export function CategoriesPage() {
|
||||
variationCounts={variationCountsMap()}
|
||||
technicalFieldCounts={technicalFieldCountsMap()}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onVariations={openVariations}
|
||||
onTechnicalForm={openTechnicalForm}
|
||||
onOptions={(id) => {
|
||||
@@ -479,10 +483,12 @@ export function CategoriesPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Category"
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
|
||||
? t('categories.deleteMessage', {
|
||||
name: deleteTarget.nameFa || deleteTarget.nameEn,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
@@ -558,13 +564,13 @@ export function CategoriesPage() {
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span>Fill categories by AI</span>
|
||||
<span>{t('categories.addByAi')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
@@ -41,27 +41,23 @@
|
||||
}
|
||||
|
||||
.colName {
|
||||
width: 16%;
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.colCell {
|
||||
width: 11%;
|
||||
}
|
||||
|
||||
.colEmail {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colOrders {
|
||||
width: 9%;
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.colTransactions {
|
||||
width: 11%;
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.colDate {
|
||||
width: 10%;
|
||||
width: 12%;
|
||||
}
|
||||
|
||||
.colActions {
|
||||
@@ -71,7 +67,7 @@
|
||||
.th,
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -104,15 +100,10 @@
|
||||
|
||||
.customerName[dir='rtl'] {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.emailCell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
max-width: 0;
|
||||
.nameAlignFa {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dateCell {
|
||||
@@ -126,14 +117,14 @@
|
||||
}
|
||||
|
||||
.thActions {
|
||||
text-align: center;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tdActions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
@@ -141,6 +132,19 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* FA: عملیات column is on the physical left — pin controls to that edge. */
|
||||
.tableFa .thActions,
|
||||
.tableFa .tdActions {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.tableFa .rowActions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toggleInActions {
|
||||
@@ -243,9 +247,10 @@
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
z-index: 50;
|
||||
z-index: 110;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
@@ -265,7 +270,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fab {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
@@ -8,6 +9,7 @@ import { Pagination } from '../components/Pagination'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -24,12 +26,13 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomersPage.module.css'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const COLUMN_COUNT = 7
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function displayName(customer: BusinessCustomerListItem) {
|
||||
@@ -37,13 +40,6 @@ function displayName(customer: BusinessCustomerListItem) {
|
||||
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>
|
||||
@@ -52,6 +48,8 @@ function formatTransactionTotal(total: number | null | undefined) {
|
||||
}
|
||||
|
||||
export function CustomersPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<CustomersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -88,7 +86,7 @@ export function CustomersPage() {
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load customers.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.load'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -99,7 +97,7 @@ export function CustomersPage() {
|
||||
return () => {
|
||||
controller.abort()
|
||||
}
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber])
|
||||
}, [page, appliedFilters.name, appliedFilters.cellNumber, t])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = data?.total ?? 0
|
||||
@@ -147,12 +145,12 @@ export function CustomersPage() {
|
||||
})
|
||||
showToast(
|
||||
isEnabled
|
||||
? `"${displayName(customer)}" has been enabled.`
|
||||
: `"${displayName(customer)}" has been disabled.`,
|
||||
? t('customers.toast.enabled', { name: displayName(customer) })
|
||||
: t('customers.toast.disabled', { name: displayName(customer) }),
|
||||
'success',
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to update customer.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.update'))
|
||||
} finally {
|
||||
setTogglingId(null)
|
||||
}
|
||||
@@ -172,21 +170,24 @@ export function CustomersPage() {
|
||||
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(removeTarget)}" has been removed.`, 'success')
|
||||
showToast(t('customers.toast.removed', { name: displayName(removeTarget) }), 'success')
|
||||
setRemoveTarget(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove customer.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.error.remove'))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendSms(customer: BusinessCustomerListItem) {
|
||||
showToast(`SMS to ${formatCellForDisplay(customer.cellNumber)} is not available yet.`, 'info')
|
||||
showToast(
|
||||
t('customers.toast.smsSoon', { phone: formatCellForDisplay(customer.cellNumber) }),
|
||||
'info',
|
||||
)
|
||||
}
|
||||
|
||||
function handleTickets(customer: BusinessCustomerListItem) {
|
||||
showToast(`Tickets for "${displayName(customer)}" are not available yet.`, 'info')
|
||||
showToast(t('customers.toast.ticketsSoon', { name: displayName(customer) }), 'info')
|
||||
}
|
||||
|
||||
function handleCustomerSaved(updated: BusinessCustomerListItem) {
|
||||
@@ -197,7 +198,7 @@ export function CustomersPage() {
|
||||
items: prev.items.map((item) => (item.id === updated.id ? updated : item)),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(updated)}" has been updated.`, 'success')
|
||||
showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success')
|
||||
}
|
||||
|
||||
function handleCustomerCreated(customer: BusinessCustomerListItem) {
|
||||
@@ -221,7 +222,7 @@ export function CustomersPage() {
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(`"${displayName(customer)}" has been added.`, 'success')
|
||||
showToast(t('customers.toast.added', { name: displayName(customer) }), 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -235,33 +236,31 @@ export function CustomersPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('customers.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('customers.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"
|
||||
placeholder={t('customers.filter.name')}
|
||||
aria-label={t('customers.filter.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..."
|
||||
placeholder={t('customers.filter.cell')}
|
||||
aria-label={t('customers.filter.cell')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -272,8 +271,8 @@ export function CustomersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('customers.search')}
|
||||
title={t('customers.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -282,8 +281,8 @@ export function CustomersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('customers.clearFilters')}
|
||||
title={t('customers.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -294,15 +293,17 @@ export function CustomersPage() {
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Customer list</div>
|
||||
<div className={styles.tableHeaderTitle}>{t('customers.listTitle')}</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
t('customers.showing', {
|
||||
from: showingFrom,
|
||||
to: showingTo,
|
||||
total: data.total,
|
||||
})
|
||||
) : (
|
||||
'No customers'
|
||||
t('customers.none')
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
@@ -312,11 +313,13 @@ export function CustomersPage() {
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<table
|
||||
className={`${styles.table}${locale === 'fa' ? ` ${styles.tableFa}` : ''}`}
|
||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<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} />
|
||||
@@ -324,123 +327,133 @@ export function CustomersPage() {
|
||||
</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>
|
||||
<th className={styles.th}>{t('customers.col.name')}</th>
|
||||
<th className={styles.th}>{t('customers.col.cell')}</th>
|
||||
<th className={styles.th}>{t('customers.col.orders')}</th>
|
||||
<th className={styles.th}>{t('customers.col.transactions')}</th>
|
||||
<th className={styles.th}>{t('customers.col.date')}</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>{t('customers.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('customers.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
{t('customers.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((customer) => (
|
||||
{!loading &&
|
||||
data?.items?.map((customer) => {
|
||||
const name = displayName(customer)
|
||||
const nameLocale = textLocaleAttrs(name)
|
||||
return (
|
||||
<tr
|
||||
key={customer.id}
|
||||
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
||||
>
|
||||
<td className={styles.td}>
|
||||
{(() => {
|
||||
const name = displayName(customer)
|
||||
const locale = textLocaleAttrs(name)
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
locale.className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={locale.lang}
|
||||
dir={locale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
nameLocale.className,
|
||||
locale === 'fa' ? styles.nameAlignFa : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={nameLocale.lang}
|
||||
dir={nameLocale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{!customer.isEnabled && (
|
||||
<div className={styles.statusDisabled}>Disabled</div>
|
||||
<div className={styles.statusDisabled}>{t('customers.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 className={styles.td}>
|
||||
{customer.orderCount == null || customer.orderCount === 0 ? (
|
||||
<span className={styles.subText}>{t('customers.noOrders')}</span>
|
||||
) : (
|
||||
customer.orderCount
|
||||
)}
|
||||
</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)}
|
||||
{formatDate(customer.createdAt, locale)}
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<Tooltip
|
||||
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
|
||||
label={
|
||||
customer.isEnabled
|
||||
? t('customers.disable')
|
||||
: t('customers.enable')
|
||||
}
|
||||
>
|
||||
<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)}
|
||||
ariaLabel={
|
||||
customer.isEnabled
|
||||
? t('customers.disableAria', { name })
|
||||
: t('customers.enableAria', { name })
|
||||
}
|
||||
onChange={(isEnabled) =>
|
||||
void handleToggleEnabled(customer, isEnabled)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit customer">
|
||||
<Tooltip label={t('customers.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setEditTarget(customer)}
|
||||
aria-label="Edit customer"
|
||||
aria-label={t('customers.edit')}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Send SMS">
|
||||
<Tooltip label={t('customers.sms')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => handleSendSms(customer)}
|
||||
aria-label="Send SMS"
|
||||
aria-label={t('customers.sms')}
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Tickets">
|
||||
<Tooltip label={t('customers.tickets')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => handleTickets(customer)}
|
||||
aria-label="Tickets"
|
||||
aria-label={t('customers.tickets')}
|
||||
>
|
||||
<Ticket size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove customer">
|
||||
<Tooltip label={t('customers.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(customer)}
|
||||
aria-label="Remove customer"
|
||||
aria-label={t('customers.remove')}
|
||||
disabled={removing}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
@@ -449,13 +462,19 @@ export function CustomersPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
{t('customers.pageMeta', {
|
||||
page,
|
||||
totalPages,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data?.total ?? 0,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
@@ -483,10 +502,10 @@ export function CustomersPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove customer?"
|
||||
title={t('customers.deleteTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove "${displayName(removeTarget)}" from your business? Their account will not be deleted.`
|
||||
? t('customers.deleteMessage', { name: displayName(removeTarget) })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
@@ -497,7 +516,7 @@ export function CustomersPage() {
|
||||
type="button"
|
||||
className={styles.fab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add customer"
|
||||
aria-label={t('customers.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
@@ -33,14 +33,23 @@
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
.paginationMeta {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
line-height: 1;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
grid-column: 2;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@@ -83,17 +92,19 @@
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fabDock {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProductVariantsModal } from '../components/ProductVariantsModal'
|
||||
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
|
||||
import { ProductCommentsModal } from '../components/ProductCommentsModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
deleteProduct,
|
||||
@@ -22,6 +23,7 @@ import styles from './MyProductsPage.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
|
||||
export function MyProductsPage() {
|
||||
const t = useT()
|
||||
const navigate = useNavigate()
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
@@ -75,7 +77,7 @@ export function MyProductsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load products.')
|
||||
setError(t('products.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -89,7 +91,7 @@ export function MyProductsPage() {
|
||||
function handleComments(id: string) {
|
||||
const product = products.find((p) => p.id === id)
|
||||
if (product) {
|
||||
setCommentsTarget({ id, name: product.nameEn })
|
||||
setCommentsTarget({ id, name: product.nameFa || product.nameEn })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ export function MyProductsPage() {
|
||||
if (product) {
|
||||
setVariantsTarget({
|
||||
id,
|
||||
name: product.nameEn,
|
||||
name: product.nameFa || product.nameEn,
|
||||
categoryId: product.categoryId,
|
||||
})
|
||||
}
|
||||
@@ -109,7 +111,7 @@ export function MyProductsPage() {
|
||||
if (product) {
|
||||
setTechnicalTarget({
|
||||
id,
|
||||
name: product.nameEn,
|
||||
name: product.nameFa || product.nameEn,
|
||||
categoryId: product.categoryId,
|
||||
})
|
||||
}
|
||||
@@ -118,7 +120,7 @@ export function MyProductsPage() {
|
||||
function handleRemoveRequest(id: string) {
|
||||
const product = products.find((p) => p.id === id)
|
||||
if (product) {
|
||||
setDeleteTarget({ id, name: product.nameEn })
|
||||
setDeleteTarget({ id, name: product.nameFa || product.nameEn })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +142,7 @@ export function MyProductsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete product.')
|
||||
setError(t('products.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
@@ -182,24 +184,24 @@ export function MyProductsPage() {
|
||||
/>
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Products</h2>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.myProducts')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{totalProducts} products · View, edit and manage your catalog.
|
||||
{t('products.list.subtitle', { count: totalProducts })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('products.list.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"
|
||||
placeholder={t('products.list.filterNamePlaceholder')}
|
||||
aria-label={t('products.list.filterName')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -210,8 +212,8 @@ export function MyProductsPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={isLoading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('products.list.search')}
|
||||
title={t('products.list.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -220,8 +222,8 @@ export function MyProductsPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={isLoading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('products.list.clearFilters')}
|
||||
title={t('products.list.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -236,10 +238,10 @@ export function MyProductsPage() {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading products...</p>
|
||||
<p className={styles.empty}>{t('products.list.loading')}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
{appliedName ? 'No products match your filters.' : 'No products found.'}
|
||||
{appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
@@ -261,9 +263,13 @@ export function MyProductsPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
|
||||
{totalProducts} total
|
||||
<div className={styles.paginationMeta}>
|
||||
{t('products.list.pagination', {
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
perPage: PRODUCTS_PER_PAGE,
|
||||
total: totalProducts,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
@@ -278,10 +284,10 @@ export function MyProductsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Product"
|
||||
title={t('products.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.name}"? This action cannot be undone.`
|
||||
? t('products.list.deleteMessage', { name: deleteTarget.name })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
@@ -337,9 +343,9 @@ export function MyProductsPage() {
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span>Add product by AI</span>
|
||||
<span>{t('products.list.addByAi')}</span>
|
||||
</button>
|
||||
<Link to="/products/new" className={styles.addFab} aria-label="Add new product">
|
||||
<Link to="/products/new" className={styles.addFab} aria-label={t('products.list.addNew')}>
|
||||
<Plus size={24} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
.th,
|
||||
.td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -226,9 +226,9 @@
|
||||
}
|
||||
|
||||
.tdActions {
|
||||
text-align: right;
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
@@ -8,6 +9,7 @@ import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -45,12 +47,13 @@ function displayName(order: Order) {
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,16 +61,39 @@ 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 defaultFaForLabel(label: string | null | undefined) {
|
||||
const trimmed = label?.trim()
|
||||
if (!trimmed) return undefined
|
||||
return DEFAULT_ORDER_PROCESS_STEPS.find(
|
||||
(step) => step.label.toLowerCase() === trimmed.toLowerCase(),
|
||||
)?.labelFa
|
||||
}
|
||||
|
||||
function stepLabel(
|
||||
steps: OrderProcessStep[],
|
||||
processStepId: string,
|
||||
processStepLabel?: string | null,
|
||||
processStepLabelFa?: string | null,
|
||||
locale: 'en' | 'fa' = 'en',
|
||||
) {
|
||||
const step =
|
||||
steps.find((item) => item.id === processStepId) ??
|
||||
DEFAULT_ORDER_PROCESS_STEPS.find((item) => item.id === processStepId)
|
||||
|
||||
if (locale === 'fa') {
|
||||
let fa = processStepLabelFa?.trim() || step?.labelFa?.trim()
|
||||
|
||||
if (fa && processStepLabel?.trim() && fa === processStepLabel.trim()) {
|
||||
fa = undefined
|
||||
}
|
||||
|
||||
fa = fa || defaultFaForLabel(processStepLabel) || defaultFaForLabel(step?.label)
|
||||
|
||||
if (fa) return fa
|
||||
}
|
||||
|
||||
if (processStepLabel?.trim()) return processStepLabel.trim()
|
||||
return step?.label ?? processStepId
|
||||
}
|
||||
|
||||
function sourceClass(source: OrderSource) {
|
||||
@@ -82,15 +108,6 @@ function sourceClass(source: OrderSource) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -112,6 +129,8 @@ function stepColor(
|
||||
}
|
||||
|
||||
export function OrdersPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<OrdersListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -134,6 +153,18 @@ export function OrdersPage() {
|
||||
const [removeTarget, setRemoveTarget] = useState<Order | null>(null)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
|
||||
function sourceLabel(source: OrderSource) {
|
||||
switch (source) {
|
||||
case 'admin':
|
||||
return t('orders.source.operator')
|
||||
case 'app':
|
||||
return t('orders.source.app')
|
||||
case 'website':
|
||||
default:
|
||||
return t('orders.source.website')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -169,7 +200,7 @@ export function OrdersPage() {
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load orders.')
|
||||
setError(err instanceof ApiError ? err.message : t('orders.error.load'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -188,6 +219,7 @@ export function OrdersPage() {
|
||||
appliedFilters.dateTo,
|
||||
appliedFilters.minTotal,
|
||||
appliedFilters.maxTotal,
|
||||
t,
|
||||
])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
@@ -210,7 +242,7 @@ export function OrdersPage() {
|
||||
const maxTotal = parseIrtInput(draftMaxCost)
|
||||
|
||||
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
|
||||
setError('Minimum cost cannot be greater than maximum cost.')
|
||||
setError(t('orders.error.minMax'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -261,10 +293,10 @@ export function OrdersPage() {
|
||||
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
||||
}
|
||||
})
|
||||
showToast(`Order ${removeTarget.orderNumber} has been removed.`, 'success')
|
||||
showToast(t('orders.removed', { orderNumber: removeTarget.orderNumber }), 'success')
|
||||
setRemoveTarget(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove order.')
|
||||
setError(err instanceof ApiError ? err.message : t('orders.error.remove'))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
@@ -282,75 +314,75 @@ export function OrdersPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('orders.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('orders.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('orders.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-..."
|
||||
placeholder={t('orders.filter.orderId')}
|
||||
aria-label={t('orders.filter.orderId')}
|
||||
/>
|
||||
</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"
|
||||
placeholder={t('orders.filter.customer')}
|
||||
aria-label={t('orders.filter.customer')}
|
||||
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)}
|
||||
aria-label={t('orders.filter.dateFrom')}
|
||||
title={t('orders.filter.dateFrom')}
|
||||
/>
|
||||
</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)}
|
||||
aria-label={t('orders.filter.dateTo')}
|
||||
title={t('orders.filter.dateTo')}
|
||||
/>
|
||||
</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"
|
||||
placeholder={t('orders.filter.minCost')}
|
||||
aria-label={t('orders.filter.minCost')}
|
||||
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"
|
||||
placeholder={t('orders.filter.maxCost')}
|
||||
aria-label={t('orders.filter.maxCost')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -361,8 +393,8 @@ export function OrdersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('orders.search')}
|
||||
title={t('orders.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -371,8 +403,8 @@ export function OrdersPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('orders.clearFilters')}
|
||||
title={t('orders.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -383,15 +415,17 @@ export function OrdersPage() {
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Order list</div>
|
||||
<div className={styles.tableHeaderTitle}>{t('orders.listTitle')}</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
t('orders.showing', {
|
||||
from: showingFrom,
|
||||
to: showingTo,
|
||||
total: data.total,
|
||||
})
|
||||
) : (
|
||||
'No orders'
|
||||
t('orders.none')
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
@@ -401,7 +435,7 @@ export function OrdersPage() {
|
||||
|
||||
{error && <div className={styles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={styles.table}>
|
||||
<table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
|
||||
<colgroup>
|
||||
<col className={styles.colOrderId} />
|
||||
<col className={styles.colCustomer} />
|
||||
@@ -414,21 +448,21 @@ export function OrdersPage() {
|
||||
</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>
|
||||
<th className={styles.th}>{t('orders.col.orderId')}</th>
|
||||
<th className={styles.th}>{t('orders.col.customer')}</th>
|
||||
<th className={styles.th}>{t('orders.col.items')}</th>
|
||||
<th className={styles.th}>{t('orders.col.total')}</th>
|
||||
<th className={styles.th}>{t('orders.col.date')}</th>
|
||||
<th className={styles.th}>{t('orders.col.step')}</th>
|
||||
<th className={styles.th}>{t('orders.col.source')}</th>
|
||||
<th className={`${styles.th} ${styles.thActions}`}>{t('orders.col.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
{t('orders.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -436,15 +470,22 @@ export function OrdersPage() {
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
No results found.
|
||||
{t('orders.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((order) => {
|
||||
const { date, time } = formatDateTime(order.createdAt)
|
||||
const { date, time } = formatDateTime(order.createdAt, locale)
|
||||
const itemQty = totalItemQuantity(order)
|
||||
const currentStepLabel = stepLabel(
|
||||
processSteps,
|
||||
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
||||
order.processStepLabel,
|
||||
order.processStepLabelFa,
|
||||
locale,
|
||||
)
|
||||
|
||||
return (
|
||||
<tr key={order.id}>
|
||||
@@ -477,17 +518,9 @@ export function OrdersPage() {
|
||||
),
|
||||
)}
|
||||
onClick={() => setStepOrder(order)}
|
||||
aria-label={`Change step: ${stepLabel(
|
||||
processSteps,
|
||||
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
||||
order.processStepLabel,
|
||||
)}`}
|
||||
aria-label={t('orders.changeStep', { step: currentStepLabel })}
|
||||
>
|
||||
{stepLabel(
|
||||
processSteps,
|
||||
order.processStepId ?? processSteps[0]?.id ?? 'processing',
|
||||
order.processStepLabel,
|
||||
)}
|
||||
{currentStepLabel}
|
||||
</button>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
@@ -497,32 +530,32 @@ export function OrdersPage() {
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<Tooltip label="View items">
|
||||
<Tooltip label={t('orders.viewItems')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setViewOrder(order)}
|
||||
aria-label="View items"
|
||||
aria-label={t('orders.viewItems')}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Transaction details">
|
||||
<Tooltip label={t('orders.transactions')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.actionBtn}
|
||||
onClick={() => setTransactionsOrder(order)}
|
||||
aria-label="Transaction details"
|
||||
aria-label={t('orders.transactions')}
|
||||
>
|
||||
<CreditCard size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove order">
|
||||
<Tooltip label={t('orders.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(order)}
|
||||
aria-label="Remove order"
|
||||
aria-label={t('orders.remove')}
|
||||
disabled={removing}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
@@ -538,7 +571,12 @@ export function OrdersPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
{t('orders.pageMeta', {
|
||||
page,
|
||||
totalPages,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data?.total ?? 0,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
@@ -573,10 +611,10 @@ export function OrdersPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove order?"
|
||||
title={t('orders.deleteTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove order ${removeTarget.orderNumber}? This cannot be undone.`
|
||||
? t('orders.deleteMessage', { orderNumber: removeTarget.orderNumber })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
createPortfolioCategory,
|
||||
deletePortfolioCategory,
|
||||
@@ -18,11 +19,12 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
export function PortfolioCategoriesPage() {
|
||||
const t = useT()
|
||||
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 [modalTitle, setModalTitle] = useState(() => t('categories.add'))
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
@@ -46,17 +48,17 @@ export function PortfolioCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('portfolio.categories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal(parentId = '', title = 'Add Category') {
|
||||
function openCreateModal(parentId = '', title?: string) {
|
||||
setEditingCategory(null)
|
||||
setDefaultParentId(parentId)
|
||||
setModalTitle(title)
|
||||
setModalTitle(title ?? t('categories.add'))
|
||||
setModalOpen(true)
|
||||
if (parentId) {
|
||||
setExpandedIds((prev) => new Set(prev).add(parentId))
|
||||
@@ -66,7 +68,7 @@ export function PortfolioCategoriesPage() {
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalTitle(t('categories.edit'))
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ export function PortfolioCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save category.')
|
||||
setError(t('portfolio.categories.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -139,7 +141,7 @@ export function PortfolioCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete category.')
|
||||
setError(t('portfolio.categories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -157,10 +159,8 @@ export function PortfolioCategoriesPage() {
|
||||
/>
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('portfolio.categories.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('portfolio.categories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,15 +172,15 @@ export function PortfolioCategoriesPage() {
|
||||
|
||||
<div className={styles.list}>
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading categories...</p>
|
||||
<p className={styles.empty}>{t('categories.loading')}</p>
|
||||
) : categories.length === 0 ? (
|
||||
<p className={styles.empty}>No categories yet. Click + to add one.</p>
|
||||
<p className={styles.empty}>{t('portfolio.categories.empty')}</p>
|
||||
) : (
|
||||
<BlogCategoryTree
|
||||
categories={categories}
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
@@ -211,10 +211,10 @@ export function PortfolioCategoriesPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Category"
|
||||
title={t('categories.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.`
|
||||
? t('categories.deleteMessage', { name: deleteTarget.nameEn })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
@@ -226,7 +226,7 @@ export function PortfolioCategoriesPage() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
aria-label={t('categories.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ImageLightbox } from '../components/ImageLightbox'
|
||||
import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService'
|
||||
import type { PortfolioDetail } from '../types/portfolio'
|
||||
@@ -11,6 +13,8 @@ import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './PortfolioDetailsPage.module.css'
|
||||
|
||||
export function PortfolioDetailsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { id } = useParams()
|
||||
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -35,7 +39,7 @@ export function PortfolioDetailsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load portfolio.')
|
||||
setError(t('portfolio.details.errorLoad'))
|
||||
}
|
||||
setPortfolio(null)
|
||||
} finally {
|
||||
@@ -60,7 +64,7 @@ export function PortfolioDetailsPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.status}>Loading portfolio...</p>
|
||||
<p className={styles.status}>{t('portfolio.details.loading')}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -68,15 +72,18 @@ export function PortfolioDetailsPage() {
|
||||
if (error || !portfolio || !id) {
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<p className={styles.error}>{error || 'Portfolio not found.'}</p>
|
||||
<p className={styles.error}>{error || t('portfolio.details.notFound')}</p>
|
||||
<Link to="/portfolios/list" className={styles.backLink}>
|
||||
Back to My Portfolios
|
||||
{t('portfolio.details.back')}
|
||||
</Link>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const displayDate = formatPortfolioDate(portfolio.publishedAt ?? portfolio.createdAt)
|
||||
const displayDate = formatPortfolioDate(
|
||||
portfolio.publishedAt ?? portfolio.createdAt,
|
||||
locale === 'fa' ? 'fa' : 'en',
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,16 +152,16 @@ export function PortfolioDetailsPage() {
|
||||
dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content}>No content yet.</p>
|
||||
<p className={styles.content}>{t('portfolio.details.noContent')}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{galleryImages.length > 0 && (
|
||||
<section className={styles.galleryBand} aria-label="Portfolio gallery">
|
||||
<section className={styles.galleryBand} aria-label={t('portfolio.details.gallery')}>
|
||||
<div className={styles.galleryInner}>
|
||||
<h3 className={styles.galleryTitle}>Gallery</h3>
|
||||
<h3 className={styles.galleryTitle}>{t('portfolio.details.gallery')}</h3>
|
||||
<div className={styles.galleryGrid}>
|
||||
{galleryImages.map((src, index) => (
|
||||
<button
|
||||
@@ -162,7 +169,7 @@ export function PortfolioDetailsPage() {
|
||||
type="button"
|
||||
className={styles.galleryItem}
|
||||
onClick={() => openLightbox(index)}
|
||||
aria-label={`View gallery image ${index + 1}`}
|
||||
aria-label={t('portfolio.details.galleryImage', { index: index + 1 })}
|
||||
>
|
||||
<img src={src} alt={`${portfolio.title} gallery ${index + 1}`} loading="lazy" />
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
PORTFOLIOS_PER_PAGE,
|
||||
@@ -21,6 +22,7 @@ import styles from './BlogPage.module.css'
|
||||
export function PortfolioListPage() {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const t = useT()
|
||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
|
||||
const [totalPortfolios, setTotalPortfolios] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -58,7 +60,7 @@ export function PortfolioListPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load portfolios.')
|
||||
setError(t('portfolio.list.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -94,7 +96,6 @@ export function PortfolioListPage() {
|
||||
|
||||
try {
|
||||
if (index === 0) {
|
||||
// First on this page, but not global first — bump above the current band.
|
||||
await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 })
|
||||
} else {
|
||||
const previous = portfolios[index - 1]
|
||||
@@ -107,13 +108,13 @@ export function PortfolioListPage() {
|
||||
])
|
||||
}
|
||||
}
|
||||
showToast('Portfolio moved up.', 'success')
|
||||
showToast(t('portfolio.list.toast.movedUp'), 'success')
|
||||
await loadPortfolios(currentPage)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to move portfolio.')
|
||||
setError(t('portfolio.list.errorMove'))
|
||||
}
|
||||
} finally {
|
||||
setMovingUpId(null)
|
||||
@@ -128,7 +129,7 @@ export function PortfolioListPage() {
|
||||
|
||||
try {
|
||||
await deletePortfolio(deleteTarget.id)
|
||||
showToast('Portfolio removed.', 'success')
|
||||
showToast(t('portfolio.list.toast.removed'), 'success')
|
||||
const nextTotal = totalPortfolios - 1
|
||||
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE))
|
||||
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
|
||||
@@ -139,7 +140,7 @@ export function PortfolioListPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete portfolio.')
|
||||
setError(t('portfolio.list.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
@@ -171,9 +172,9 @@ export function PortfolioListPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>My Portfolios</h2>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.myPortfolios')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
{totalPortfolios} items · View, edit and manage your portfolio content.
|
||||
{t('portfolio.list.subtitle', { count: totalPortfolios })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,9 +186,9 @@ export function PortfolioListPage() {
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading portfolios...</p>
|
||||
<p className={styles.empty}>{t('portfolio.list.loading')}</p>
|
||||
) : portfolios.length === 0 ? (
|
||||
<p className={styles.empty}>No portfolio items found.</p>
|
||||
<p className={styles.empty}>{t('portfolio.list.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.gridCols4}>
|
||||
@@ -218,17 +219,17 @@ export function PortfolioListPage() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => navigate('/portfolios/new')}
|
||||
aria-label="Add new portfolio"
|
||||
aria-label={t('portfolio.list.addNew')}
|
||||
>
|
||||
<Plus size={26} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete Portfolio"
|
||||
title={t('portfolio.list.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.`
|
||||
? t('portfolio.list.deleteMessage', { name: deleteTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={confirmDelete}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
|
||||
}
|
||||
|
||||
export function PortfolioSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -41,7 +43,7 @@ export function PortfolioSettingsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load settings.')
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -57,12 +59,12 @@ export function PortfolioSettingsPage() {
|
||||
comments: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast('Settings saved.', 'success')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -81,10 +83,8 @@ export function PortfolioSettingsPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>Portfolio settings</h2>
|
||||
<p className={pageStyles.pageSubtitle}>
|
||||
Configure how portfolio comments are moderated.
|
||||
</p>
|
||||
<h2 className={pageStyles.pageTitle}>{t('portfolio.settings.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('portfolio.settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,27 +95,24 @@ export function PortfolioSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h3 className={styles.sectionTitle}>Moderation</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>Loading settings...</p>
|
||||
<p className={styles.status}>{t('productSettings.loading')}</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
|
||||
{t('portfolio.settings.commentsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>
|
||||
New portfolio comments are published immediately when submitted. You can still
|
||||
reject them later if needed.
|
||||
</p>
|
||||
<p className={styles.rowDescription}>{t('portfolio.settings.commentsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="portfolio-comments-auto-approve"
|
||||
checked={settings.comments.autoApprove}
|
||||
disabled={savingKey === 'comments'}
|
||||
aria-label="Auto-approve portfolio comments"
|
||||
aria-label={t('portfolio.settings.commentsAutoAria')}
|
||||
onChange={(checked) => void handleCommentsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,50 @@
|
||||
import { Briefcase, PlusCircle, FolderTree, Settings } from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const portfolioSections = [
|
||||
const portfolioSections: {
|
||||
icon: typeof Briefcase
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: Briefcase,
|
||||
title: 'My Portfolios',
|
||||
description: 'View, edit and manage all your portfolio items.',
|
||||
linkText: 'View portfolios',
|
||||
titleKey: 'nav.portfolios.list',
|
||||
descKey: 'portfolio.card.list.desc',
|
||||
linkKey: 'portfolio.card.list.link',
|
||||
href: '/portfolios/list',
|
||||
},
|
||||
{
|
||||
icon: PlusCircle,
|
||||
title: 'Add New Portfolio',
|
||||
description: 'Create and publish a new portfolio project.',
|
||||
linkText: 'Add portfolio',
|
||||
titleKey: 'nav.portfolios.new',
|
||||
descKey: 'portfolio.card.new.desc',
|
||||
linkKey: 'portfolio.card.new.link',
|
||||
href: '/portfolios/new',
|
||||
},
|
||||
{
|
||||
icon: FolderTree,
|
||||
title: 'Portfolio Categories',
|
||||
description: 'Organize portfolio items into categories and subcategories.',
|
||||
linkText: 'View categories',
|
||||
titleKey: 'portfolio.card.categories.title',
|
||||
descKey: 'portfolio.card.categories.desc',
|
||||
linkKey: 'portfolio.card.categories.link',
|
||||
href: '/portfolios/categories',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
description: 'Configure portfolio comment moderation and display options.',
|
||||
linkText: 'View settings',
|
||||
titleKey: 'nav.portfolios.settings',
|
||||
descKey: 'portfolio.card.settings.desc',
|
||||
linkKey: 'portfolio.card.settings.link',
|
||||
href: '/portfolios/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function PortfoliosPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -45,16 +55,21 @@ export function PortfoliosPage() {
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Portfolios</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Manage your portfolio items and showcase projects.
|
||||
</p>
|
||||
<h2 className={styles.pageTitle}>{t('title.portfolios')}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('portfolio.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{portfolioSections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
|
||||
}
|
||||
|
||||
export function ProductSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -41,7 +43,7 @@ export function ProductSettingsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load settings.')
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -57,12 +59,12 @@ export function ProductSettingsPage() {
|
||||
comments: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast('Settings saved.', 'success')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -78,12 +80,12 @@ export function ProductSettingsPage() {
|
||||
expertReviews: { autoApprove: checked },
|
||||
})
|
||||
setSettings(data.settings.dashboard)
|
||||
showToast('Settings saved.', 'success')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -102,10 +104,8 @@ export function ProductSettingsPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('productSettings.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('productSettings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,27 +116,24 @@ export function ProductSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={styles.panel}>
|
||||
<h3 className={styles.sectionTitle}>Moderation</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.status}>Loading settings...</p>
|
||||
<p className={styles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowText}>
|
||||
<label htmlFor="comments-auto-approve" className={styles.rowLabel}>
|
||||
Auto-approve comments
|
||||
{t('productSettings.commentsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>
|
||||
New comments are published immediately when submitted. You can still reject
|
||||
them later if needed.
|
||||
</p>
|
||||
<p className={styles.rowDescription}>{t('productSettings.commentsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="comments-auto-approve"
|
||||
checked={settings.comments.autoApprove}
|
||||
disabled={savingKey === 'comments'}
|
||||
aria-label="Auto-approve comments"
|
||||
aria-label={t('productSettings.commentsAuto')}
|
||||
onChange={(checked) => void handleCommentsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
@@ -144,18 +141,15 @@ export function ProductSettingsPage() {
|
||||
<div className={styles.row}>
|
||||
<div className={styles.rowText}>
|
||||
<label htmlFor="expert-reviews-auto-approve" className={styles.rowLabel}>
|
||||
Auto-approve expert reviews
|
||||
{t('productSettings.reviewsAuto')}
|
||||
</label>
|
||||
<p className={styles.rowDescription}>
|
||||
New expert reviews are published immediately when submitted. You can still
|
||||
reject them later if needed.
|
||||
</p>
|
||||
<p className={styles.rowDescription}>{t('productSettings.reviewsAutoDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="expert-reviews-auto-approve"
|
||||
checked={settings.expertReviews.autoApprove}
|
||||
disabled={savingKey === 'expertReviews'}
|
||||
aria-label="Auto-approve expert reviews"
|
||||
aria-label={t('productSettings.reviewsAuto')}
|
||||
onChange={(checked) => void handleExpertReviewsAutoApprove(checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -40,12 +42,13 @@ function displayName(card: ShoppingCard) {
|
||||
return name || '—'
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +57,8 @@ function totalItemQuantity(card: ShoppingCard) {
|
||||
}
|
||||
|
||||
export function ShoppingCardsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<ShoppingCardsListResponse | null>(null)
|
||||
@@ -91,7 +96,7 @@ export function ShoppingCardsPage() {
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load shopping cards.')
|
||||
setError(err instanceof ApiError ? err.message : t('shoppingCards.error.load'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -109,6 +114,7 @@ export function ShoppingCardsPage() {
|
||||
appliedFilters.dateTo,
|
||||
appliedFilters.minTotal,
|
||||
appliedFilters.maxTotal,
|
||||
t,
|
||||
])
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
@@ -131,7 +137,7 @@ export function ShoppingCardsPage() {
|
||||
const maxTotal = parseIrtInput(draftMaxCost)
|
||||
|
||||
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
|
||||
setError('Minimum cost cannot be greater than maximum cost.')
|
||||
setError(t('orders.error.minMax'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -175,10 +181,10 @@ export function ShoppingCardsPage() {
|
||||
items: prev.items.filter((item) => item.id !== removeTarget.id),
|
||||
}
|
||||
})
|
||||
showToast('Shopping card removed.', 'success')
|
||||
showToast(t('shoppingCards.removed'), 'success')
|
||||
setRemoveTarget(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to remove shopping card.')
|
||||
setError(err instanceof ApiError ? err.message : t('shoppingCards.error.remove'))
|
||||
} finally {
|
||||
setRemoving(false)
|
||||
}
|
||||
@@ -196,66 +202,66 @@ export function ShoppingCardsPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('shoppingCards.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('shoppingCards.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('orders.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"
|
||||
placeholder={t('orders.filter.customer')}
|
||||
aria-label={t('orders.filter.customer')}
|
||||
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)}
|
||||
aria-label={t('orders.filter.dateFrom')}
|
||||
title={t('orders.filter.dateFrom')}
|
||||
/>
|
||||
</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)}
|
||||
aria-label={t('orders.filter.dateTo')}
|
||||
title={t('orders.filter.dateTo')}
|
||||
/>
|
||||
</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"
|
||||
placeholder={t('orders.filter.minCost')}
|
||||
aria-label={t('orders.filter.minCost')}
|
||||
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"
|
||||
placeholder={t('orders.filter.maxCost')}
|
||||
aria-label={t('orders.filter.maxCost')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -266,8 +272,8 @@ export function ShoppingCardsPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('orders.search')}
|
||||
title={t('orders.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -276,8 +282,8 @@ export function ShoppingCardsPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('orders.clearFilters')}
|
||||
title={t('orders.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -288,15 +294,17 @@ export function ShoppingCardsPage() {
|
||||
<div className={tableStyles.tablePanel}>
|
||||
<div className={tableStyles.tableWrap}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<div className={tableStyles.tableHeaderTitle}>Shopping card list</div>
|
||||
<div className={tableStyles.tableHeaderTitle}>{t('shoppingCards.listTitle')}</div>
|
||||
<div className={tableStyles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
</>
|
||||
t('orders.showing', {
|
||||
from: showingFrom,
|
||||
to: showingTo,
|
||||
total: data.total,
|
||||
})
|
||||
) : (
|
||||
'No shopping cards'
|
||||
t('shoppingCards.none')
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
@@ -306,7 +314,7 @@ export function ShoppingCardsPage() {
|
||||
|
||||
{error && <div className={tableStyles.errorBanner}>{error}</div>}
|
||||
|
||||
<table className={tableStyles.table}>
|
||||
<table className={tableStyles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
|
||||
<colgroup>
|
||||
<col className={styles.colCustomer} />
|
||||
<col className={styles.colItems} />
|
||||
@@ -316,18 +324,20 @@ export function ShoppingCardsPage() {
|
||||
</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>
|
||||
<th className={tableStyles.th}>{t('orders.col.customer')}</th>
|
||||
<th className={tableStyles.th}>{t('orders.col.items')}</th>
|
||||
<th className={tableStyles.th}>{t('orders.col.total')}</th>
|
||||
<th className={tableStyles.th}>{t('orders.col.date')}</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>
|
||||
{t('orders.col.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
{t('shoppingCards.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -335,14 +345,14 @@ export function ShoppingCardsPage() {
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={COLUMN_COUNT}>
|
||||
No results found.
|
||||
{t('shoppingCards.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((card) => {
|
||||
const { date, time } = formatDateTime(card.createdAt)
|
||||
const { date, time } = formatDateTime(card.createdAt, locale)
|
||||
const itemQty = totalItemQuantity(card)
|
||||
|
||||
return (
|
||||
@@ -363,22 +373,22 @@ export function ShoppingCardsPage() {
|
||||
</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<Tooltip label="Continue">
|
||||
<Tooltip label={t('shoppingCards.continue')}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.actionBtn}
|
||||
onClick={() => handleContinue(card)}
|
||||
aria-label="Continue shopping card"
|
||||
aria-label={t('shoppingCards.continueAria')}
|
||||
>
|
||||
<Play size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove card">
|
||||
<Tooltip label={t('shoppingCards.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
|
||||
onClick={() => setRemoveTarget(card)}
|
||||
aria-label="Remove shopping card"
|
||||
aria-label={t('shoppingCards.removeAria')}
|
||||
disabled={removing}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
@@ -394,7 +404,12 @@ export function ShoppingCardsPage() {
|
||||
|
||||
<div className={tableStyles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
{t('orders.pageMeta', {
|
||||
page,
|
||||
totalPages,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data?.total ?? 0,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
@@ -409,10 +424,10 @@ export function ShoppingCardsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={removeTarget !== null}
|
||||
title="Remove shopping card?"
|
||||
title={t('shoppingCards.deleteTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove this shopping card for ${displayName(removeTarget)}? This cannot be undone.`
|
||||
? t('shoppingCards.deleteMessage', { name: displayName(removeTarget) })
|
||||
: ''
|
||||
}
|
||||
onCancel={() => setRemoveTarget(null)}
|
||||
|
||||
@@ -26,12 +26,13 @@
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
inset-inline-end: 32px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
.cartFabStrip {
|
||||
@@ -118,7 +119,8 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fabDock {
|
||||
right: 20px;
|
||||
inset-inline-end: 20px;
|
||||
inset-inline-start: auto;
|
||||
bottom: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type StoreProductListing,
|
||||
} from '../utils/storeProductGroups'
|
||||
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './StoreItemsPage.module.css'
|
||||
@@ -40,6 +42,8 @@ export function StoreItemsPage() {
|
||||
}
|
||||
|
||||
function StoreItemsPageContent() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart()
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<StoreItem[]>([])
|
||||
@@ -139,7 +143,7 @@ function StoreItemsPageContent() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load store items.')
|
||||
setError(t('storeItems.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -188,7 +192,7 @@ function StoreItemsPageContent() {
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Added to shopping cart.', 'success')
|
||||
showToast(t('storeItems.addedToCart'), 'success')
|
||||
}
|
||||
|
||||
function handleVariantPicked(variant: StoreItem) {
|
||||
@@ -198,7 +202,7 @@ function StoreItemsPageContent() {
|
||||
showToast(feedback, 'error')
|
||||
return
|
||||
}
|
||||
showToast('Added to shopping cart.', 'success')
|
||||
showToast(t('storeItems.addedToCart'), 'success')
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -215,7 +219,7 @@ function StoreItemsPageContent() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to remove store item.')
|
||||
setError(t('storeItems.errorRemove'))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
@@ -234,65 +238,60 @@ function StoreItemsPageContent() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.storeItems')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('storeItems.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('storeItems.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"
|
||||
placeholder={t('storeItems.filterNamePlaceholder')}
|
||||
aria-label={t('storeItems.filterName')}
|
||||
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"
|
||||
placeholder={t('storeItems.minPrice')}
|
||||
aria-label={t('storeItems.minPrice')}
|
||||
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"
|
||||
placeholder={t('storeItems.maxPrice')}
|
||||
aria-label={t('storeItems.maxPrice')}
|
||||
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"
|
||||
ariaLabel={t('storeItems.onlyDiscounted')}
|
||||
/>
|
||||
<span
|
||||
className={filterStyles.switchLabel}
|
||||
onClick={() => !isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)}
|
||||
>
|
||||
Only discounted
|
||||
{t('storeItems.onlyDiscounted')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,8 +302,8 @@ function StoreItemsPageContent() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={isLoading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('storeItems.search')}
|
||||
title={t('storeItems.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -313,8 +312,8 @@ function StoreItemsPageContent() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={isLoading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('storeItems.clearFilters')}
|
||||
title={t('storeItems.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -325,13 +324,11 @@ function StoreItemsPageContent() {
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading store items...</p>
|
||||
<p className={styles.empty}>{t('storeItems.loading')}</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className={styles.empty}>
|
||||
No store items yet. Use the + button to add products from your catalog.
|
||||
</p>
|
||||
<p className={styles.empty}>{t('storeItems.empty')}</p>
|
||||
) : filteredListings.length === 0 ? (
|
||||
<p className={styles.empty}>No store items match your filters.</p>
|
||||
<p className={styles.empty}>{t('storeItems.emptyFiltered')}</p>
|
||||
) : (
|
||||
<div className={styles.grid}>
|
||||
{filteredListings.map((listing) => (
|
||||
@@ -355,11 +352,11 @@ function StoreItemsPageContent() {
|
||||
type="button"
|
||||
className={styles.cartFabStrip}
|
||||
onClick={() => setCartOpen(true)}
|
||||
aria-label={`Open shopping cart, ${itemCount} items`}
|
||||
aria-label={t('storeItems.cartOpen', { count: itemCount })}
|
||||
>
|
||||
<span className={styles.cartFabCount}>{itemCount}</span>
|
||||
<span className={styles.cartFabLabel}>
|
||||
{itemCount === 1 ? 'item in cart' : 'items in cart'}
|
||||
{itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -367,7 +364,7 @@ function StoreItemsPageContent() {
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add store items"
|
||||
aria-label={t('storeItems.add')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
@@ -408,10 +405,13 @@ function StoreItemsPageContent() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteTarget}
|
||||
title="Remove from Store"
|
||||
title={t('storeItems.deleteTitle')}
|
||||
message={
|
||||
deleteTarget
|
||||
? `Remove "${deleteTarget.productTitle}" and all ${formatVariantCount(deleteTarget.variantCount)} from your store? This cannot be undone.`
|
||||
? t('storeItems.deleteMessage', {
|
||||
name: deleteTarget.productNameFa || deleteTarget.productTitle,
|
||||
variants: formatVariantCount(deleteTarget.variantCount, locale),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
|
||||
@@ -7,47 +7,51 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const storeSections = [
|
||||
const storeSections: {
|
||||
icon: typeof Package
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: Package,
|
||||
title: 'My Store Items',
|
||||
description: 'View and manage all items listed in your store.',
|
||||
linkText: 'View items',
|
||||
titleKey: 'nav.store.items',
|
||||
descKey: 'store.card.items.desc',
|
||||
href: '/store/items',
|
||||
},
|
||||
{
|
||||
icon: ShoppingCart,
|
||||
title: 'My Orders',
|
||||
description: 'Track and manage customer orders and fulfillment.',
|
||||
linkText: 'View orders',
|
||||
titleKey: 'nav.store.orders',
|
||||
descKey: 'store.card.orders.desc',
|
||||
href: '/store/orders',
|
||||
},
|
||||
{
|
||||
icon: Truck,
|
||||
title: 'Shipping Fees',
|
||||
description: 'Configure shipping rates, zones and delivery options.',
|
||||
linkText: 'Manage shipping',
|
||||
titleKey: 'nav.store.shipping',
|
||||
descKey: 'store.card.shipping.desc',
|
||||
href: '/store/shipping',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'Shopping Cards',
|
||||
description: 'Manage saved shopping cards and payment methods.',
|
||||
linkText: 'View cards',
|
||||
titleKey: 'nav.store.cards',
|
||||
descKey: 'store.card.cards.desc',
|
||||
href: '/store/cards',
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
title: 'Settings',
|
||||
description: 'Configure store preferences, pages and themes.',
|
||||
linkText: 'View settings',
|
||||
titleKey: 'nav.store.settings',
|
||||
descKey: 'store.card.settings.desc',
|
||||
href: '/store/settings',
|
||||
},
|
||||
]
|
||||
|
||||
export function StorePage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -58,16 +62,20 @@ export function StorePage() {
|
||||
/>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={styles.pageTitle}>Store</h2>
|
||||
<p className={styles.pageSubtitle}>
|
||||
Manage your store items, orders and settings.
|
||||
</p>
|
||||
<h2 className={styles.pageTitle}>{t('title.store')}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('store.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{storeSections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StepColorPicker } from '../components/StepColorPicker'
|
||||
import { Switch } from '../components/Switch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getSettings,
|
||||
@@ -53,6 +54,7 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
|
||||
}
|
||||
|
||||
export function StoreSettingsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [settings, setSettings] = useState<StoreSettings>(DEFAULT_STORE_SETTINGS)
|
||||
const [draftSteps, setDraftSteps] = useState<OrderProcessStep[]>(
|
||||
@@ -81,7 +83,7 @@ export function StoreSettingsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load settings.')
|
||||
setError(t('productSettings.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -95,12 +97,12 @@ export function StoreSettingsPage() {
|
||||
try {
|
||||
const data = await updateStoreSettings({ onlineSellEnabled: checked })
|
||||
setSettings(data.settings.store)
|
||||
showToast('Settings saved.', 'success')
|
||||
showToast(t('productSettings.saved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save settings.')
|
||||
setError(t('productSettings.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -163,11 +165,11 @@ export function StoreSettingsPage() {
|
||||
const normalized = normalizeSteps(draftSteps)
|
||||
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
|
||||
if (!normalized.length) {
|
||||
setError('Add at least one order process step.')
|
||||
setError(t('storeSettings.error.minSteps'))
|
||||
return
|
||||
}
|
||||
if (hasEmptyLabel) {
|
||||
setError('Every order step needs an English and Farsi label.')
|
||||
setError(t('storeSettings.error.labels'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,12 +180,12 @@ export function StoreSettingsPage() {
|
||||
const data = await updateStoreSettings({ orderProcessSteps: normalized })
|
||||
setSettings(data.settings.store)
|
||||
setDraftSteps(data.settings.store.orderProcessSteps)
|
||||
showToast('Order process steps saved.', 'success')
|
||||
showToast(t('storeSettings.stepsSaved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save order process steps.')
|
||||
setError(t('storeSettings.error.saveSteps'))
|
||||
}
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
@@ -213,10 +215,8 @@ export function StoreSettingsPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('storeSettings.title')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('storeSettings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,27 +227,26 @@ export function StoreSettingsPage() {
|
||||
)}
|
||||
|
||||
<section className={sharedStyles.panel}>
|
||||
<h3 className={sharedStyles.sectionTitle}>Sales</h3>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('storeSettings.sales')}</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>Loading settings...</p>
|
||||
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<div className={sharedStyles.list}>
|
||||
<div className={sharedStyles.row}>
|
||||
<div className={sharedStyles.rowText}>
|
||||
<label htmlFor="online-sell" className={sharedStyles.rowLabel}>
|
||||
Online sell
|
||||
{t('storeSettings.onlineSell')}
|
||||
</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.
|
||||
{t('storeSettings.onlineSellDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="online-sell"
|
||||
checked={settings.onlineSellEnabled}
|
||||
disabled={savingKey === 'onlineSell'}
|
||||
aria-label="Online sell"
|
||||
aria-label={t('storeSettings.onlineSell')}
|
||||
onChange={(checked) => void handleOnlineSellChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
@@ -258,16 +257,13 @@ export function StoreSettingsPage() {
|
||||
<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>
|
||||
<h3 className={sharedStyles.sectionTitle}>{t('storeSettings.orderProcess')}</h3>
|
||||
<p className={styles.stepsDescription}>{t('storeSettings.orderProcessDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className={sharedStyles.status}>Loading settings...</p>
|
||||
<p className={sharedStyles.status}>{t('productSettings.loading')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.stepsList}>
|
||||
@@ -277,14 +273,14 @@ export function StoreSettingsPage() {
|
||||
<StepColorPicker
|
||||
value={normalizeStepColor(step.color, defaultStepColorForId(step.id, index))}
|
||||
onChange={(color) => updateStepColor(step.id, color)}
|
||||
ariaLabel={`Color for step ${index + 1}`}
|
||||
ariaLabel={t('storeSettings.stepColorAria', { index: index + 1 })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.stepInput}
|
||||
value={step.label}
|
||||
placeholder="Label (EN)"
|
||||
aria-label={`Order step ${index + 1} English label`}
|
||||
placeholder={t('storeSettings.labelEn')}
|
||||
aria-label={t('storeSettings.stepEnAria', { index: index + 1 })}
|
||||
dir="ltr"
|
||||
lang="en"
|
||||
onChange={(e) => updateStepLabel(step.id, e.target.value)}
|
||||
@@ -293,42 +289,42 @@ export function StoreSettingsPage() {
|
||||
type="text"
|
||||
className={`${styles.stepInput} ${styles.stepInputFa}`}
|
||||
value={step.labelFa ?? ''}
|
||||
placeholder="عنوان (فارسی)"
|
||||
aria-label={`Order step ${index + 1} Farsi label`}
|
||||
placeholder={t('storeSettings.labelFa')}
|
||||
aria-label={t('storeSettings.stepFaAria', { index: index + 1 })}
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
|
||||
/>
|
||||
<div className={styles.stepControls}>
|
||||
<Tooltip label="Move step up">
|
||||
<Tooltip label={t('storeSettings.moveUp')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => moveStep(step.id, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label="Move step up"
|
||||
aria-label={t('storeSettings.moveUp')}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move step down">
|
||||
<Tooltip label={t('storeSettings.moveDown')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => moveStep(step.id, 1)}
|
||||
disabled={index === draftSteps.length - 1}
|
||||
aria-label="Move step down"
|
||||
aria-label={t('storeSettings.moveDown')}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove step">
|
||||
<Tooltip label={t('storeSettings.removeStep')}>
|
||||
<button
|
||||
type="button"
|
||||
className={removeStyles.removeRowBtn}
|
||||
onClick={() => removeStep(step.id)}
|
||||
disabled={draftSteps.length <= 1}
|
||||
aria-label="Remove step"
|
||||
aria-label={t('storeSettings.removeStep')}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
@@ -338,15 +334,13 @@ export function StoreSettingsPage() {
|
||||
))}
|
||||
|
||||
{!draftSteps.length && (
|
||||
<p className={styles.emptyText}>
|
||||
No order steps yet. Add the first step to define your workflow.
|
||||
</p>
|
||||
<p className={styles.emptyText}>{t('storeSettings.emptySteps')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.addStepBtn} onClick={addStep}>
|
||||
<Plus size={18} />
|
||||
Add step
|
||||
{t('storeSettings.addStep')}
|
||||
</button>
|
||||
|
||||
<div className={styles.stepsActions}>
|
||||
@@ -356,7 +350,9 @@ export function StoreSettingsPage() {
|
||||
onClick={() => void handleSaveSteps()}
|
||||
disabled={!canSaveSteps || savingKey === 'orderSteps'}
|
||||
>
|
||||
{savingKey === 'orderSteps' ? 'Saving...' : 'Save steps'}
|
||||
{savingKey === 'orderSteps'
|
||||
? t('storeSettings.saving')
|
||||
: t('storeSettings.saveSteps')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
|
||||
import { StoreSpecialModal } from '../components/StoreSpecialModal'
|
||||
import { useDraftCart } from '../context/DraftCartContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
createStoreSpecial,
|
||||
@@ -27,6 +28,7 @@ import fabStyles from './StoreItemsPage.module.css'
|
||||
import styles from './StoreSpecialsPage.module.css'
|
||||
|
||||
export function StoreSpecialsPage() {
|
||||
const t = useT()
|
||||
const { itemCount, hasItems, addVariant } = useDraftCart()
|
||||
const { showToast } = useToast()
|
||||
const [specials, setSpecials] = useState<StoreSpecial[]>([])
|
||||
@@ -73,7 +75,7 @@ export function StoreSpecialsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load special categories.')
|
||||
setError(t('website.specialItems.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -141,45 +143,49 @@ export function StoreSpecialsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCreateSpecial(title: string) {
|
||||
async function handleCreateSpecial(values: { key: string; title: string }) {
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createStoreSpecial({
|
||||
title,
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
sortOrder: specials.length,
|
||||
})
|
||||
setSpecials((prev) => [...prev, result.special])
|
||||
setCreateOpen(false)
|
||||
showToast('Special category created.', 'success')
|
||||
showToast(t('website.specialItems.toastCreated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to create special category.')
|
||||
setError(t('website.specialItems.errorCreate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSpecial(title: string) {
|
||||
async function handleEditSpecial(values: { key: string; title: string }) {
|
||||
if (!editSpecialTarget) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await updateStoreSpecial(editSpecialTarget.id, { title })
|
||||
const result = await updateStoreSpecial(editSpecialTarget.id, {
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
})
|
||||
replaceSpecial(result.special)
|
||||
setEditSpecialTarget(null)
|
||||
showToast('Special category updated.', 'success')
|
||||
showToast(t('website.specialItems.toastUpdated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to update special category.')
|
||||
setError(t('website.specialItems.errorUpdate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -196,12 +202,12 @@ export function StoreSpecialsPage() {
|
||||
await deleteStoreSpecial(deleteSpecialTarget.id)
|
||||
setSpecials((prev) => prev.filter((entry) => entry.id !== deleteSpecialTarget.id))
|
||||
setDeleteSpecialTarget(null)
|
||||
showToast('Special category deleted.', 'success')
|
||||
showToast(t('website.specialItems.toastDeleted'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete special category.')
|
||||
setError(t('website.specialItems.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -223,12 +229,12 @@ export function StoreSpecialsPage() {
|
||||
})
|
||||
replaceSpecial(result.special)
|
||||
setPickItemsTarget(null)
|
||||
showToast('Store items added to special category.', 'success')
|
||||
showToast(t('website.specialItems.toastAdded'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to add store items.')
|
||||
setError(t('website.specialItems.errorAdd'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -253,12 +259,12 @@ export function StoreSpecialsPage() {
|
||||
})
|
||||
replaceSpecial(result.special)
|
||||
setRemoveTarget(null)
|
||||
showToast('Removed from special category.', 'success')
|
||||
showToast(t('website.specialItems.toastRemoved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to remove store item.')
|
||||
setError(t('website.specialItems.errorRemove'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -280,7 +286,7 @@ export function StoreSpecialsPage() {
|
||||
return
|
||||
}
|
||||
|
||||
showToast('Added to shopping cart.', 'success')
|
||||
showToast(t('storeItems.addedToCart'), 'success')
|
||||
}
|
||||
|
||||
function handleVariantPicked(variant: StoreItem) {
|
||||
@@ -290,7 +296,7 @@ export function StoreSpecialsPage() {
|
||||
showToast(feedback, 'error')
|
||||
return
|
||||
}
|
||||
showToast('Added to shopping cart.', 'success')
|
||||
showToast(t('storeItems.addedToCart'), 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -305,22 +311,17 @@ export function StoreSpecialsPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.specialItems')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('website.specialItems.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading special categories...</p>
|
||||
<p className={styles.empty}>{t('website.specialItems.loading')}</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>
|
||||
<p className={styles.empty}>{t('website.specialItems.empty')}</p>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{specials.map((special) => (
|
||||
@@ -349,11 +350,11 @@ export function StoreSpecialsPage() {
|
||||
type="button"
|
||||
className={fabStyles.cartFabStrip}
|
||||
onClick={() => setCartOpen(true)}
|
||||
aria-label={`Open shopping cart, ${itemCount} items`}
|
||||
aria-label={t('storeItems.cartOpen', { count: itemCount })}
|
||||
>
|
||||
<span className={fabStyles.cartFabCount}>{itemCount}</span>
|
||||
<span className={fabStyles.cartFabLabel}>
|
||||
{itemCount === 1 ? 'item in cart' : 'items in cart'}
|
||||
{itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -361,7 +362,7 @@ export function StoreSpecialsPage() {
|
||||
type="button"
|
||||
className={fabStyles.addFab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add special category"
|
||||
aria-label={t('website.specialItems.addFab')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
@@ -371,6 +372,8 @@ export function StoreSpecialsPage() {
|
||||
open={createOpen}
|
||||
onClose={() => !isSaving && setCreateOpen(false)}
|
||||
onSubmit={handleCreateSpecial}
|
||||
title={t('website.specialItems.createTitle')}
|
||||
submitLabel={t('website.create')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -378,9 +381,10 @@ export function StoreSpecialsPage() {
|
||||
open={!!editSpecialTarget}
|
||||
onClose={() => !isSaving && setEditSpecialTarget(null)}
|
||||
onSubmit={handleEditSpecial}
|
||||
initialKey={editSpecialTarget?.key ?? ''}
|
||||
initialTitle={editSpecialTarget?.title ?? ''}
|
||||
title="Edit Special Category"
|
||||
submitLabel="Save"
|
||||
title={t('website.specialItems.editTitle')}
|
||||
submitLabel={t('website.save')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -420,10 +424,10 @@ export function StoreSpecialsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteSpecialTarget}
|
||||
title="Delete Special Category"
|
||||
title={t('website.specialItems.deleteTitle')}
|
||||
message={
|
||||
deleteSpecialTarget
|
||||
? `Delete "${deleteSpecialTarget.title}"? Store items will remain in your catalog.`
|
||||
? t('website.specialItems.deleteMessage', { title: deleteSpecialTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmDeleteSpecial()}
|
||||
@@ -432,10 +436,13 @@ export function StoreSpecialsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title="Remove from Special"
|
||||
title={t('website.specialItems.removeTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove "${removeTarget.listing.productTitle}" from "${removeTarget.special.title}"?`
|
||||
? t('website.specialItems.removeMessage', {
|
||||
name: removeTarget.listing.productTitle,
|
||||
group: removeTarget.special.title,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmRemoveFromSpecial()}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './WebsitePage.module.css'
|
||||
|
||||
export function WebsiteBadgesPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<WebsiteSectionShell
|
||||
sectionLabel="Badges"
|
||||
title="Badges"
|
||||
subtitle="Show trust badges and highlights on your public website."
|
||||
title={t('title.badges')}
|
||||
subtitle={t('website.badges.subtitle')}
|
||||
>
|
||||
<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>
|
||||
<p className={styles.placeholderLead}>{t('website.badges.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('website.badges.note')}</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>
|
||||
<li>{t('website.badges.feature.upload')}</li>
|
||||
<li>{t('website.badges.feature.reorder')}</li>
|
||||
<li>{t('website.badges.feature.toggle')}</li>
|
||||
</ul>
|
||||
</WebsiteSectionShell>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RotateCcw, Search } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
listContactSubmissions,
|
||||
} from '../services/contactSubmissionService'
|
||||
import { listContactSubmissions } from '../services/contactSubmissionService'
|
||||
import type {
|
||||
ContactSubmission,
|
||||
ContactSubmissionsListResponse,
|
||||
@@ -19,16 +19,19 @@ import styles from './WebsiteContactPage.module.css'
|
||||
const PAGE_SIZE = 20
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
export function WebsiteContactPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [data, setData] = useState<ContactSubmissionsListResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -58,7 +61,7 @@ export function WebsiteContactPage() {
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load contact submissions.')
|
||||
setError(err instanceof ApiError ? err.message : t('website.contact.errorLoad'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
@@ -109,24 +112,22 @@ export function WebsiteContactPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.contactForm')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('website.contact.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={filterStyles.filtersPanel}>
|
||||
<div className={filterStyles.filtersTitle}>Filters</div>
|
||||
<div className={filterStyles.filtersTitle}>{t('website.contact.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"
|
||||
placeholder={t('website.contact.searchPlaceholder')}
|
||||
aria-label={t('website.contact.search')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') applyFilters()
|
||||
}}
|
||||
@@ -139,8 +140,8 @@ export function WebsiteContactPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
onClick={applyFilters}
|
||||
disabled={loading}
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label={t('website.contact.search')}
|
||||
title={t('website.contact.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
@@ -149,8 +150,8 @@ export function WebsiteContactPage() {
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
aria-label="Clear filters"
|
||||
title="Clear filters"
|
||||
aria-label={t('website.contact.clearFilters')}
|
||||
title={t('website.contact.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
@@ -161,15 +162,19 @@ export function WebsiteContactPage() {
|
||||
<div className={styles.tablePanel}>
|
||||
<div className={styles.tableWrap}>
|
||||
<div className={styles.tableHeader}>
|
||||
<div className={styles.tableHeaderTitle}>Submissions</div>
|
||||
<div className={styles.tableHeaderTitle}>{t('website.contact.listTitle')}</div>
|
||||
<div className={styles.meta}>
|
||||
{data ? (
|
||||
data.total > 0 ? (
|
||||
<>
|
||||
Showing {showingFrom} - {showingTo} of {data.total}
|
||||
{t('website.contact.showing', {
|
||||
from: showingFrom,
|
||||
to: showingTo,
|
||||
total: data.total,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
'No submissions'
|
||||
t('website.contact.none')
|
||||
)
|
||||
) : (
|
||||
' '
|
||||
@@ -183,19 +188,19 @@ export function WebsiteContactPage() {
|
||||
<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>
|
||||
<th className={styles.th}>{t('website.contact.col.title')}</th>
|
||||
<th className={styles.th}>{t('website.contact.col.name')}</th>
|
||||
<th className={styles.th}>{t('website.contact.col.email')}</th>
|
||||
<th className={styles.th}>{t('website.contact.col.cell')}</th>
|
||||
<th className={styles.th}>{t('website.contact.col.date')}</th>
|
||||
<th className={styles.th}>{t('website.contact.col.time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
Loading...
|
||||
{t('website.contact.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -203,14 +208,14 @@ export function WebsiteContactPage() {
|
||||
{!loading && data?.items.length === 0 && (
|
||||
<tr>
|
||||
<td className={styles.td} colSpan={COLUMN_COUNT}>
|
||||
No submissions found.
|
||||
{t('website.contact.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items.map((item) => {
|
||||
const { date, time } = formatDateTime(item.createdAt)
|
||||
const { date, time } = formatDateTime(item.createdAt, locale)
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
@@ -224,7 +229,7 @@ export function WebsiteContactPage() {
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`View submission from ${item.name}`}
|
||||
aria-label={t('website.contact.viewSubmission', { name: item.name })}
|
||||
>
|
||||
<td className={styles.td}>{item.title}</td>
|
||||
<td className={styles.td}>{item.name}</td>
|
||||
@@ -243,7 +248,12 @@ export function WebsiteContactPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
{t('website.contact.pageMeta', {
|
||||
page,
|
||||
totalPages,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: data?.total ?? 0,
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './WebsitePage.module.css'
|
||||
|
||||
export function WebsiteEPaymentPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<WebsiteSectionShell
|
||||
sectionLabel="E-Payment"
|
||||
title="E-Payment"
|
||||
subtitle="Configure online payment methods for your website checkout."
|
||||
title={t('title.ePayment')}
|
||||
subtitle={t('website.ePayment.subtitle')}
|
||||
>
|
||||
<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>
|
||||
<p className={styles.placeholderLead}>{t('website.ePayment.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('website.ePayment.note')}</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>
|
||||
<li>{t('website.ePayment.feature.enable')}</li>
|
||||
<li>{t('website.ePayment.feature.credentials')}</li>
|
||||
<li>{t('website.ePayment.feature.methods')}</li>
|
||||
</ul>
|
||||
</WebsiteSectionShell>
|
||||
)
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './WebsitePage.module.css'
|
||||
|
||||
export function WebsiteFaqPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<WebsiteSectionShell
|
||||
sectionLabel="FAQ"
|
||||
title="FAQ"
|
||||
subtitle="Create and organize frequently asked questions for your website."
|
||||
title={t('title.faq')}
|
||||
subtitle={t('website.faq.subtitle')}
|
||||
>
|
||||
<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>
|
||||
<p className={styles.placeholderLead}>{t('website.faq.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('website.faq.note')}</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>
|
||||
<li>{t('website.faq.feature.crud')}</li>
|
||||
<li>{t('website.faq.feature.groups')}</li>
|
||||
<li>{t('website.faq.feature.publish')}</li>
|
||||
</ul>
|
||||
</WebsiteSectionShell>
|
||||
)
|
||||
|
||||
@@ -11,75 +11,85 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { SectionCard } from '../components/SectionCard'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import styles from '../components/PageContent.module.css'
|
||||
|
||||
const websiteSections = [
|
||||
const websiteSections: {
|
||||
icon: typeof Images
|
||||
titleKey: BusinessMessageKey
|
||||
descKey: BusinessMessageKey
|
||||
linkKey: BusinessMessageKey
|
||||
href: string
|
||||
}[] = [
|
||||
{
|
||||
icon: Images,
|
||||
title: 'Sliders',
|
||||
description: 'Manage homepage banner sliders and promotional image carousels.',
|
||||
linkText: 'Manage sliders',
|
||||
titleKey: 'nav.website.sliders',
|
||||
descKey: 'website.card.sliders.desc',
|
||||
linkKey: 'website.card.sliders.link',
|
||||
href: '/website/sliders',
|
||||
},
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: 'Special Categories',
|
||||
description: 'Highlight selected product categories on your website homepage.',
|
||||
linkText: 'Manage categories',
|
||||
titleKey: 'nav.website.specialCategories',
|
||||
descKey: 'website.card.specialCategories.desc',
|
||||
linkKey: 'website.card.specialCategories.link',
|
||||
href: '/website/special-categories',
|
||||
},
|
||||
{
|
||||
icon: Building2,
|
||||
title: 'Special Brands',
|
||||
description: 'Showcase partner or featured brands on your website homepage.',
|
||||
linkText: 'Manage brands',
|
||||
titleKey: 'nav.website.specialBrands',
|
||||
descKey: 'website.card.specialBrands.desc',
|
||||
linkKey: 'website.card.specialBrands.link',
|
||||
href: '/website/special-brands',
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'Special Items',
|
||||
description: 'Curate featured store items into categories for your website carousels.',
|
||||
linkText: 'Manage special items',
|
||||
titleKey: 'nav.website.specialItems',
|
||||
descKey: 'website.card.specialItems.desc',
|
||||
linkKey: 'website.card.specialItems.link',
|
||||
href: '/website/special-items',
|
||||
},
|
||||
{
|
||||
icon: Mail,
|
||||
title: 'Contact Us Form',
|
||||
description: 'View submissions from your website contact form.',
|
||||
linkText: 'View submissions',
|
||||
titleKey: 'nav.website.contact',
|
||||
descKey: 'website.card.contact.desc',
|
||||
linkKey: 'website.card.contact.link',
|
||||
href: '/website/contact',
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
title: 'Subscriptions',
|
||||
description: 'Manage newsletter sign-ups and subscription options for visitors.',
|
||||
linkText: 'Manage subscriptions',
|
||||
titleKey: 'nav.website.subscriptions',
|
||||
descKey: 'website.card.subscriptions.desc',
|
||||
linkKey: 'website.card.subscriptions.link',
|
||||
href: '/website/subscriptions',
|
||||
},
|
||||
{
|
||||
icon: CircleHelp,
|
||||
title: 'FAQ',
|
||||
description: 'Create and organize frequently asked questions for your website.',
|
||||
linkText: 'Manage FAQ',
|
||||
titleKey: 'nav.website.faq',
|
||||
descKey: 'website.card.faq.desc',
|
||||
linkKey: 'website.card.faq.link',
|
||||
href: '/website/faq',
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
title: 'Badges',
|
||||
description: 'Show trust badges, certifications, and highlights on your website.',
|
||||
linkText: 'Manage badges',
|
||||
titleKey: 'nav.website.badges',
|
||||
descKey: 'website.card.badges.desc',
|
||||
linkKey: 'website.card.badges.link',
|
||||
href: '/website/badges',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'E-Payment',
|
||||
description: 'Configure online payment gateways and checkout payment options.',
|
||||
linkText: 'Manage e-payment',
|
||||
titleKey: 'nav.website.ePayment',
|
||||
descKey: 'website.card.ePayment.desc',
|
||||
linkKey: 'website.card.ePayment.link',
|
||||
href: '/website/e-payment',
|
||||
},
|
||||
]
|
||||
|
||||
export function WebsitePage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<main className={styles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -90,16 +100,21 @@ export function WebsitePage() {
|
||||
/>
|
||||
<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>
|
||||
<h2 className={styles.pageTitle}>{t('title.website')}</h2>
|
||||
<p className={styles.pageSubtitle}>{t('website.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.gridHome}>
|
||||
{websiteSections.map((section) => (
|
||||
<SectionCard key={section.title} {...section} />
|
||||
<SectionCard
|
||||
key={section.href}
|
||||
icon={section.icon}
|
||||
title={t(section.titleKey)}
|
||||
description={t(section.descKey)}
|
||||
linkText={t(section.linkKey)}
|
||||
href={section.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { WebsiteSliderGallery } from '../components/WebsiteSliderGallery'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { resolveDataUrlToMediaId } from '../services/mediaService'
|
||||
import {
|
||||
@@ -19,9 +20,8 @@ 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 t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [slider, setSlider] = useState<WebsiteSlider | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -48,7 +48,7 @@ export function WebsiteSlidersPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load slides.')
|
||||
setError(t('website.sliders.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -62,7 +62,7 @@ export function WebsiteSlidersPage() {
|
||||
try {
|
||||
const imageMediaId = await resolveDataUrlToMediaId(data.image, 'slider-slide.jpg')
|
||||
if (!imageMediaId) {
|
||||
throw new Error('Unable to upload slide image.')
|
||||
throw new Error(t('website.sliders.errorUpload'))
|
||||
}
|
||||
|
||||
const slideInput = {
|
||||
@@ -79,21 +79,21 @@ export function WebsiteSlidersPage() {
|
||||
setSlider(result.slider)
|
||||
} else {
|
||||
const result = await createWebsiteSlider({
|
||||
title: DEFAULT_SLIDER_TITLE,
|
||||
title: t('website.sliders.defaultTitle'),
|
||||
slides: [slideInput],
|
||||
})
|
||||
setSlider(result.slider)
|
||||
}
|
||||
|
||||
setAddSlideOpen(false)
|
||||
showToast('Slide added.', 'success')
|
||||
showToast(t('website.sliders.toastAdded'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to add slide.')
|
||||
setError(t('website.sliders.errorAdd'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -114,12 +114,12 @@ export function WebsiteSlidersPage() {
|
||||
const result = await updateWebsiteSlider(slider.id, { slides: nextSlides })
|
||||
setSlider(result.slider)
|
||||
setRemoveTarget(null)
|
||||
showToast('Slide removed.', 'success')
|
||||
showToast(t('website.sliders.toastRemoved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to remove slide.')
|
||||
setError(t('website.sliders.errorRemove'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -138,17 +138,15 @@ export function WebsiteSlidersPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.sliders')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('website.sliders.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading slides...</p>
|
||||
<p className={styles.empty}>{t('website.sliders.loading')}</p>
|
||||
) : (
|
||||
<WebsiteSliderGallery
|
||||
slides={slider?.slides ?? []}
|
||||
@@ -166,8 +164,8 @@ export function WebsiteSlidersPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title="Remove Slide"
|
||||
message="Remove this slide from the homepage slider?"
|
||||
title={t('website.sliders.removeTitle')}
|
||||
message={t('website.sliders.removeMessage')}
|
||||
onConfirm={() => void confirmRemoveSlide()}
|
||||
onCancel={() => !isSaving && setRemoveTarget(null)}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
|
||||
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
|
||||
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
createWebsiteBrandGroup,
|
||||
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
|
||||
import styles from './StoreSpecialsPage.module.css'
|
||||
|
||||
export function WebsiteSpecialBrandsPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [groups, setGroups] = useState<WebsiteBrandGroup[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -54,7 +56,7 @@ export function WebsiteSpecialBrandsPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load special brand groups.')
|
||||
setError(t('website.specialBrands.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -65,45 +67,49 @@ export function WebsiteSpecialBrandsPage() {
|
||||
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
|
||||
}
|
||||
|
||||
async function handleCreateGroup(title: string) {
|
||||
async function handleCreateGroup(values: { key: string; title: string }) {
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createWebsiteBrandGroup({
|
||||
title,
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
sortOrder: groups.length,
|
||||
})
|
||||
setGroups((prev) => [...prev, result.group])
|
||||
setCreateOpen(false)
|
||||
showToast('Special brand group created.', 'success')
|
||||
showToast(t('website.specialBrands.toastCreated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to create special brand group.')
|
||||
setError(t('website.specialBrands.errorCreate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditGroup(title: string) {
|
||||
async function handleEditGroup(values: { key: string; title: string }) {
|
||||
if (!editGroupTarget) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await updateWebsiteBrandGroup(editGroupTarget.id, { title })
|
||||
const result = await updateWebsiteBrandGroup(editGroupTarget.id, {
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setEditGroupTarget(null)
|
||||
showToast('Special brand group updated.', 'success')
|
||||
showToast(t('website.specialBrands.toastUpdated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to update special brand group.')
|
||||
setError(t('website.specialBrands.errorUpdate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -120,12 +126,12 @@ export function WebsiteSpecialBrandsPage() {
|
||||
await deleteWebsiteBrandGroup(deleteGroupTarget.id)
|
||||
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
|
||||
setDeleteGroupTarget(null)
|
||||
showToast('Special brand group deleted.', 'success')
|
||||
showToast(t('website.specialBrands.toastDeleted'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete special brand group.')
|
||||
setError(t('website.specialBrands.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -147,12 +153,12 @@ export function WebsiteSpecialBrandsPage() {
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setPickItemsTarget(null)
|
||||
showToast('Brands added to group.', 'success')
|
||||
showToast(t('website.specialBrands.toastAdded'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to add brands.')
|
||||
setError(t('website.specialBrands.errorAdd'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -174,12 +180,12 @@ export function WebsiteSpecialBrandsPage() {
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setRemoveTarget(null)
|
||||
showToast('Removed from special brand group.', 'success')
|
||||
showToast(t('website.specialBrands.toastRemoved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to remove brand.')
|
||||
setError(t('website.specialBrands.errorRemove'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -198,22 +204,17 @@ export function WebsiteSpecialBrandsPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.specialBrands')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('website.specialBrands.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading special brand groups...</p>
|
||||
<p className={styles.empty}>{t('website.specialBrands.loading')}</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>
|
||||
<p className={styles.empty}>{t('website.specialBrands.empty')}</p>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{groups.map((group) => (
|
||||
@@ -225,9 +226,7 @@ export function WebsiteSpecialBrandsPage() {
|
||||
onAddItems={() => setPickItemsTarget(group)}
|
||||
onEditGroup={() => setEditGroupTarget(group)}
|
||||
onDeleteGroup={() => setDeleteGroupTarget(group)}
|
||||
addTooltip={`Add brands to ${group.title}`}
|
||||
editTooltip="Edit group"
|
||||
deleteTooltip="Delete group"
|
||||
addTooltip={t('website.specialBrands.addTooltip', { title: group.title })}
|
||||
renderItem={(item) => (
|
||||
<WebsiteGroupItemCard
|
||||
title={item.nameEn}
|
||||
@@ -240,7 +239,6 @@ export function WebsiteSpecialBrandsPage() {
|
||||
brandName: item.nameEn,
|
||||
})
|
||||
}
|
||||
removeTooltip="Remove from group"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -253,7 +251,7 @@ export function WebsiteSpecialBrandsPage() {
|
||||
type="button"
|
||||
className={fabStyles.addFab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add special brand group"
|
||||
aria-label={t('website.specialBrands.addFab')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
@@ -263,7 +261,8 @@ export function WebsiteSpecialBrandsPage() {
|
||||
open={createOpen}
|
||||
onClose={() => !isSaving && setCreateOpen(false)}
|
||||
onSubmit={handleCreateGroup}
|
||||
title="Add Special Brand Group"
|
||||
title={t('website.specialBrands.createTitle')}
|
||||
submitLabel={t('website.create')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -271,9 +270,10 @@ export function WebsiteSpecialBrandsPage() {
|
||||
open={!!editGroupTarget}
|
||||
onClose={() => !isSaving && setEditGroupTarget(null)}
|
||||
onSubmit={handleEditGroup}
|
||||
initialKey={editGroupTarget?.key ?? ''}
|
||||
initialTitle={editGroupTarget?.title ?? ''}
|
||||
title="Edit Special Brand Group"
|
||||
submitLabel="Save"
|
||||
title={t('website.specialBrands.editTitle')}
|
||||
submitLabel={t('website.save')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -288,10 +288,10 @@ export function WebsiteSpecialBrandsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteGroupTarget}
|
||||
title="Delete Special Brand Group"
|
||||
title={t('website.specialBrands.deleteTitle')}
|
||||
message={
|
||||
deleteGroupTarget
|
||||
? `Delete "${deleteGroupTarget.title}"? Brands will remain in your catalog.`
|
||||
? t('website.specialBrands.deleteMessage', { title: deleteGroupTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmDeleteGroup()}
|
||||
@@ -300,10 +300,13 @@ export function WebsiteSpecialBrandsPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title="Remove from Group"
|
||||
title={t('website.removeFromGroupTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove "${removeTarget.brandName}" from "${removeTarget.group.title}"?`
|
||||
? t('website.removeFromGroupMessage', {
|
||||
name: removeTarget.brandName,
|
||||
group: removeTarget.group.title,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmRemoveFromGroup()}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
|
||||
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
|
||||
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
createWebsiteCategoryGroup,
|
||||
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
|
||||
import styles from './StoreSpecialsPage.module.css'
|
||||
|
||||
export function WebsiteSpecialCategoriesPage() {
|
||||
const t = useT()
|
||||
const { showToast } = useToast()
|
||||
const [groups, setGroups] = useState<WebsiteCategoryGroup[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -54,7 +56,7 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load special category groups.')
|
||||
setError(t('website.specialCategories.errorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -65,45 +67,49 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
|
||||
}
|
||||
|
||||
async function handleCreateGroup(title: string) {
|
||||
async function handleCreateGroup(values: { key: string; title: string }) {
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await createWebsiteCategoryGroup({
|
||||
title,
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
sortOrder: groups.length,
|
||||
})
|
||||
setGroups((prev) => [...prev, result.group])
|
||||
setCreateOpen(false)
|
||||
showToast('Special category group created.', 'success')
|
||||
showToast(t('website.specialCategories.toastCreated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to create special category group.')
|
||||
setError(t('website.specialCategories.errorCreate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditGroup(title: string) {
|
||||
async function handleEditGroup(values: { key: string; title: string }) {
|
||||
if (!editGroupTarget) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await updateWebsiteCategoryGroup(editGroupTarget.id, { title })
|
||||
const result = await updateWebsiteCategoryGroup(editGroupTarget.id, {
|
||||
key: values.key,
|
||||
title: values.title,
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setEditGroupTarget(null)
|
||||
showToast('Special category group updated.', 'success')
|
||||
showToast(t('website.specialCategories.toastUpdated'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to update special category group.')
|
||||
setError(t('website.specialCategories.errorUpdate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -120,12 +126,12 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
await deleteWebsiteCategoryGroup(deleteGroupTarget.id)
|
||||
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
|
||||
setDeleteGroupTarget(null)
|
||||
showToast('Special category group deleted.', 'success')
|
||||
showToast(t('website.specialCategories.toastDeleted'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to delete special category group.')
|
||||
setError(t('website.specialCategories.errorDelete'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -147,12 +153,12 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setPickItemsTarget(null)
|
||||
showToast('Categories added to group.', 'success')
|
||||
showToast(t('website.specialCategories.toastAdded'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to add categories.')
|
||||
setError(t('website.specialCategories.errorAdd'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -174,12 +180,12 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
})
|
||||
replaceGroup(result.group)
|
||||
setRemoveTarget(null)
|
||||
showToast('Removed from special category group.', 'success')
|
||||
showToast(t('website.specialCategories.toastRemoved'), 'success')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to remove category.')
|
||||
setError(t('website.specialCategories.errorRemove'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
@@ -198,22 +204,17 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
|
||||
<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>
|
||||
<h2 className={pageStyles.pageTitle}>{t('title.specialCategories')}</h2>
|
||||
<p className={pageStyles.pageSubtitle}>{t('website.specialCategories.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading special category groups...</p>
|
||||
<p className={styles.empty}>{t('website.specialCategories.loading')}</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>
|
||||
<p className={styles.empty}>{t('website.specialCategories.empty')}</p>
|
||||
) : (
|
||||
<div className={styles.carousels}>
|
||||
{groups.map((group) => (
|
||||
@@ -225,9 +226,7 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
onAddItems={() => setPickItemsTarget(group)}
|
||||
onEditGroup={() => setEditGroupTarget(group)}
|
||||
onDeleteGroup={() => setDeleteGroupTarget(group)}
|
||||
addTooltip={`Add categories to ${group.title}`}
|
||||
editTooltip="Edit group"
|
||||
deleteTooltip="Delete group"
|
||||
addTooltip={t('website.specialCategories.addTooltip', { title: group.title })}
|
||||
renderItem={(item) => (
|
||||
<WebsiteGroupItemCard
|
||||
title={item.name}
|
||||
@@ -240,7 +239,6 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
categoryName: item.name,
|
||||
})
|
||||
}
|
||||
removeTooltip="Remove from group"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -253,7 +251,7 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
type="button"
|
||||
className={fabStyles.addFab}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
aria-label="Add special category group"
|
||||
aria-label={t('website.specialCategories.addFab')}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
@@ -263,7 +261,8 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
open={createOpen}
|
||||
onClose={() => !isSaving && setCreateOpen(false)}
|
||||
onSubmit={handleCreateGroup}
|
||||
title="Add Special Category Group"
|
||||
title={t('website.specialCategories.createTitle')}
|
||||
submitLabel={t('website.create')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -271,9 +270,10 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
open={!!editGroupTarget}
|
||||
onClose={() => !isSaving && setEditGroupTarget(null)}
|
||||
onSubmit={handleEditGroup}
|
||||
initialKey={editGroupTarget?.key ?? ''}
|
||||
initialTitle={editGroupTarget?.title ?? ''}
|
||||
title="Edit Special Category Group"
|
||||
submitLabel="Save"
|
||||
title={t('website.specialCategories.editTitle')}
|
||||
submitLabel={t('website.save')}
|
||||
isSubmitting={isSaving}
|
||||
/>
|
||||
|
||||
@@ -288,10 +288,10 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!deleteGroupTarget}
|
||||
title="Delete Special Category Group"
|
||||
title={t('website.specialCategories.deleteTitle')}
|
||||
message={
|
||||
deleteGroupTarget
|
||||
? `Delete "${deleteGroupTarget.title}"? Product categories will remain in your catalog.`
|
||||
? t('website.specialCategories.deleteMessage', { title: deleteGroupTarget.title })
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmDeleteGroup()}
|
||||
@@ -300,10 +300,13 @@ export function WebsiteSpecialCategoriesPage() {
|
||||
|
||||
<ConfirmDeleteModal
|
||||
open={!!removeTarget}
|
||||
title="Remove from Group"
|
||||
title={t('website.removeFromGroupTitle')}
|
||||
message={
|
||||
removeTarget
|
||||
? `Remove "${removeTarget.categoryName}" from "${removeTarget.group.title}"?`
|
||||
? t('website.removeFromGroupMessage', {
|
||||
name: removeTarget.categoryName,
|
||||
group: removeTarget.group.title,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
onConfirm={() => void confirmRemoveFromGroup()}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './WebsitePage.module.css'
|
||||
|
||||
export function WebsiteSubscriptionsPage() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<WebsiteSectionShell
|
||||
sectionLabel="Subscriptions"
|
||||
title="Subscriptions"
|
||||
subtitle="Manage newsletter and subscription sign-ups on your website."
|
||||
title={t('title.subscriptions')}
|
||||
subtitle={t('website.subscriptions.subtitle')}
|
||||
>
|
||||
<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>
|
||||
<p className={styles.placeholderLead}>{t('website.subscriptions.lead')}</p>
|
||||
<p className={styles.placeholderNote}>{t('website.subscriptions.note')}</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>
|
||||
<li>{t('website.subscriptions.feature.enable')}</li>
|
||||
<li>{t('website.subscriptions.feature.welcome')}</li>
|
||||
<li>{t('website.subscriptions.feature.export')}</li>
|
||||
</ul>
|
||||
</WebsiteSectionShell>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user