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:
Alireza Hassani
2026-08-03 00:06:44 +03:30
co-authored by Cursor
parent 66004a0fba
commit 672091d1f5
113 changed files with 4154 additions and 1451 deletions
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber' import { toE164CellNumber } from '../lib/cellNumber'
import { import {
@@ -19,13 +21,16 @@ interface AddCustomerModalProps {
const ANIMATION_MS = 220 const ANIMATION_MS = 220
export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalProps) { export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('') const [lastName, setLastName] = useState('')
const [cellNumber, setCellNumber] = useState('') const [cellNumber, setCellNumber] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [email, setEmail] = useState('') const [passwordConfirm, setPasswordConfirm] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -37,7 +42,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
setLastName('') setLastName('')
setCellNumber('') setCellNumber('')
setPassword('') setPassword('')
setEmail('') setPasswordConfirm('')
setError('') setError('')
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
@@ -69,6 +74,18 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
const normalizedCell = toE164CellNumber(cellNumber.trim()) const normalizedCell = toE164CellNumber(cellNumber.trim())
if (!canSubmit || !normalizedCell) return if (!canSubmit || !normalizedCell) return
const trimmedPassword = password.trim()
if (trimmedPassword || passwordConfirm.trim()) {
if (trimmedPassword.length < 8) {
setError(t('customers.addModal.errorPasswordLength'))
return
}
if (trimmedPassword !== passwordConfirm.trim()) {
setError(t('customers.addModal.errorPasswordMatch'))
return
}
}
setIsSubmitting(true) setIsSubmitting(true)
setError('') setError('')
try { try {
@@ -76,8 +93,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
cellNumber: normalizedCell, cellNumber: normalizedCell,
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
...(password.trim() ? { password: password.trim() } : {}), ...(trimmedPassword ? { password: trimmedPassword } : {}),
...(email.trim() ? { email: email.trim() } : {}),
}) })
onCreated({ onCreated({
id: created.id, id: created.id,
@@ -91,7 +107,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
}) })
onClose() onClose()
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to add customer.') setError(err instanceof ApiError ? err.message : t('customers.addModal.error'))
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
@@ -108,79 +124,91 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="add-customer-title" aria-labelledby="add-customer-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h3 id="add-customer-title" className={modalStyles.title}> <h3 id="add-customer-title" className={modalStyles.title}>
Add customer {t('customers.addModal.title')}
</h3> </h3>
<p className={modalStyles.subtitle}> <p className={modalStyles.subtitle}>{t('customers.addModal.subtitle')}</p>
Creates a verified customer account or links an existing user to your business.
</p>
</div> </div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className={modalStyles.body}> <div className={modalStyles.body}>
<p className={formStyles.hint}> <p className={formStyles.hint}>{t('customers.addModal.hint')}</p>
Password is required only for new accounts. Existing users are added as verified
customers.
</p>
{error && <p className={modalStyles.errorText}>{error}</p>} {error && <p className={modalStyles.errorText}>{error}</p>}
<div className={formStyles.formGrid}> <div className={formStyles.formGrid}>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="add-customer-first-name">First name</label> <label htmlFor="add-customer-first-name">{t('customers.addModal.firstName')}</label>
<input <input
id="add-customer-first-name" id="add-customer-first-name"
value={firstName} value={firstName}
onChange={(e) => setFirstName(e.target.value)} onChange={(e) => setFirstName(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/> />
</div> </div>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="add-customer-last-name">Last name</label> <label htmlFor="add-customer-last-name">{t('customers.addModal.lastName')}</label>
<input <input
id="add-customer-last-name" id="add-customer-last-name"
value={lastName} value={lastName}
onChange={(e) => setLastName(e.target.value)} onChange={(e) => setLastName(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/> />
</div> </div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}> <div className={`${modalStyles.field} ${formStyles.formFull}`}>
<label htmlFor="add-customer-cell">Cell number</label> <label htmlFor="add-customer-cell">{t('customers.addModal.cell')}</label>
<input <input
id="add-customer-cell" id="add-customer-cell"
value={cellNumber} value={cellNumber}
onChange={(e) => setCellNumber(e.target.value)} onChange={(e) => setCellNumber(e.target.value)}
placeholder="0912..." placeholder={t('customers.addModal.cellPlaceholder')}
autoComplete="off" autoComplete="off"
disabled={isSubmitting} disabled={isSubmitting}
inputMode="tel"
dir="ltr"
/> />
</div> </div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}> <div className={modalStyles.field}>
<label htmlFor="add-customer-password">Password (new users)</label> <label htmlFor="add-customer-password">{t('customers.addModal.password')}</label>
<input <input
id="add-customer-password" id="add-customer-password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters" placeholder={t('customers.addModal.passwordPlaceholder')}
disabled={isSubmitting} disabled={isSubmitting}
dir="ltr"
/> />
</div> </div>
<div className={`${modalStyles.field} ${formStyles.formFull}`}> <div className={modalStyles.field}>
<label htmlFor="add-customer-email">Email (optional)</label> <label htmlFor="add-customer-password-confirm">
{t('customers.addModal.passwordConfirm')}
</label>
<input <input
id="add-customer-email" id="add-customer-password-confirm"
type="email" type="password"
value={email} autoComplete="new-password"
onChange={(e) => setEmail(e.target.value)} value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
placeholder={t('customers.addModal.passwordConfirmPlaceholder')}
disabled={isSubmitting} disabled={isSubmitting}
dir="ltr"
/> />
</div> </div>
</div> </div>
@@ -192,7 +220,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('customers.addModal.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -200,7 +228,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
onClick={() => void handleSubmit()} onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSubmit} disabled={isSubmitting || !canSubmit}
> >
Add customer {t('customers.addModal.submit')}
</button> </button>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper' import { ImageCropper } from './ImageCropper'
import { useT } from '../i18n/useT'
import modalStyles from './CategoryModal.module.css' import modalStyles from './CategoryModal.module.css'
import styles from './AddWebsiteSliderSlideModal.module.css' import styles from './AddWebsiteSliderSlideModal.module.css'
@@ -26,6 +27,7 @@ export function AddWebsiteSliderSlideModal({
onSubmit, onSubmit,
isSubmitting = false, isSubmitting = false,
}: AddWebsiteSliderSlideModalProps) { }: AddWebsiteSliderSlideModalProps) {
const t = useT()
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -85,32 +87,37 @@ export function AddWebsiteSliderSlideModal({
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<h3 id="add-slide-title" className={modalStyles.title}> <h3 id="add-slide-title" className={modalStyles.title}>
Add slide {t('website.sliders.modal.title')}
</h3> </h3>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form ref={formRef} className={modalStyles.form} onSubmit={handleSubmit}> <form ref={formRef} className={modalStyles.form} onSubmit={handleSubmit}>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label>Slide image</label> <label>{t('website.sliders.modal.image')}</label>
<ImageCropper <ImageCropper
value={image} value={image}
onChange={setImage} onChange={setImage}
aspect={9 / 4} aspect={9 / 4}
uploadLabel="Upload slide image" uploadLabel={t('website.sliders.modal.upload')}
hint="9:4 banner ratio recommended" hint={t('website.sliders.modal.hint')}
changeLabel="Change image" changeLabel={t('website.sliders.modal.changeImage')}
/> />
</div> </div>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="slide-title">Title (optional)</label> <label htmlFor="slide-title">{t('website.sliders.modal.titleOptional')}</label>
<input <input
id="slide-title" id="slide-title"
type="text" type="text"
placeholder="e.g. Summer sale" placeholder={t('website.sliders.modal.titlePlaceholder')}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
@@ -118,7 +125,7 @@ export function AddWebsiteSliderSlideModal({
</div> </div>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="slide-link">Link URL (optional)</label> <label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
<input <input
id="slide-link" id="slide-link"
type="url" type="url"
@@ -137,14 +144,14 @@ export function AddWebsiteSliderSlideModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('products.form.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className={modalStyles.submitBtn} className={modalStyles.submitBtn}
disabled={isSubmitting || !image} disabled={isSubmitting || !image}
> >
{isSubmitting ? 'Adding...' : 'Add slide'} {isSubmitting ? t('website.adding') : t('website.sliders.modal.addSlide')}
</button> </button>
</div> </div>
</form> </form>
+36 -16
View File
@@ -1,11 +1,13 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2, BadgeCheck, Clock } from 'lucide-react' import { Pencil, MessageSquare, Trash2, BadgeCheck, Clock } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Blog } from '../types/blog' import type { Blog } from '../types/blog'
import { import {
formatBlogAuthor, formatBlogAuthor,
formatBlogCardDate, formatBlogCardDate,
isBlogVerified, isBlogVerified,
} from '../services/blogService' } from '../services/blogService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale' import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css' import cardStyles from './BlogCard.module.css'
@@ -30,11 +32,21 @@ export function BlogCard({
onRemove, onRemove,
isVerifying = false, isVerifying = false,
}: BlogCardProps) { }: BlogCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const verified = isBlogVerified(blog) const verified = isBlogVerified(blog)
const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt) const publishDate = formatBlogCardDate(
blog.publishedAt ?? blog.createdAt,
isFa ? 'fa' : 'en',
)
const titleLocale = textLocaleAttrs(blog.title) const titleLocale = textLocaleAttrs(blog.title)
const abstractLocale = textLocaleAttrs(blog.abstract) const abstractLocale = textLocaleAttrs(blog.abstract)
const hasAbstract = Boolean(blog.abstract?.trim()) const hasAbstract = Boolean(blog.abstract?.trim())
const verifyLabel = verified ? t('blog.card.unverify') : t('blog.card.verify')
// In FA UI, keep cards RTL even when title/abstract are English.
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
return ( return (
<article className={cardStyles.card}> <article className={cardStyles.card}>
@@ -53,21 +65,21 @@ export function BlogCard({
{!verified && ( {!verified && (
<span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}> <span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}>
<Clock size={11} aria-hidden="true" /> <Clock size={11} aria-hidden="true" />
Waiting {t('blog.card.waiting')}
</span> </span>
)} )}
{verified && ( {verified && (
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}> <span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
Verified {t('blog.card.verified')}
</span> </span>
)} )}
</div> </div>
<div className={cardStyles.body}> <div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
<h3 <h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')} className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang} lang={titleLocale.lang}
dir={titleLocale.dir} dir={titleDir}
> >
{blog.title} {blog.title}
</h3> </h3>
@@ -77,13 +89,17 @@ export function BlogCard({
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
lang={abstractLocale.lang} lang={abstractLocale.lang}
dir={abstractLocale.dir} dir={abstractDir}
> >
{blog.abstract} {blog.abstract}
</p> </p>
) : ( ) : (
<p className={cardStyles.abstract} lang="en" dir="ltr"> <p
No summary yet. className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('blog.card.noSummary')}
</p> </p>
)} )}
<p className={cardStyles.meta}> <p className={cardStyles.meta}>
@@ -95,28 +111,32 @@ export function BlogCard({
</Link> </Link>
<div className={controlStyles.controls}> <div className={controlStyles.controls}>
<Tooltip label="Edit blog"> <Tooltip label={t('blog.card.edit')}>
<button type="button" onClick={() => onEdit(blog.id)} aria-label="Edit"> <button
type="button"
onClick={() => onEdit(blog.id)}
aria-label={t('blog.card.edit')}
>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label={verified ? 'Unverify blog' : 'Verify blog'}> <Tooltip label={verifyLabel}>
<button <button
type="button" type="button"
className={verified ? cardStyles.verifyActive : cardStyles.verifyWaiting} className={verified ? cardStyles.verifyActive : cardStyles.verifyWaiting}
disabled={isVerifying} disabled={isVerifying}
onClick={() => onToggleVerify(blog.id)} onClick={() => onToggleVerify(blog.id)}
aria-label={verified ? 'Unverify blog' : 'Verify blog'} aria-label={verifyLabel}
> >
{verified ? <BadgeCheck size={16} /> : <Clock size={16} />} {verified ? <BadgeCheck size={16} /> : <Clock size={16} />}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="View comments"> <Tooltip label={t('blog.card.comments')}>
<button <button
type="button" type="button"
className={controlStyles.iconBtn} className={controlStyles.iconBtn}
onClick={() => onComments(blog.id)} onClick={() => onComments(blog.id)}
aria-label={`Comments (${commentCount})`} aria-label={`${t('blog.card.comments')} (${commentCount})`}
> >
<MessageSquare size={16} /> <MessageSquare size={16} />
{commentCount > 0 && ( {commentCount > 0 && (
@@ -124,12 +144,12 @@ export function BlogCard({
)} )}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove blog"> <Tooltip label={t('blog.card.remove')}>
<button <button
type="button" type="button"
className={controlStyles.danger} className={controlStyles.danger}
onClick={() => onRemove(blog.id)} onClick={() => onRemove(blog.id)}
aria-label="Remove" aria-label={t('blog.card.remove')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -1,5 +1,7 @@
import { FolderPlus, Pencil, Trash2, ChevronRight } from 'lucide-react' import { FolderPlus, Pencil, Trash2, ChevronRight } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css' import styles from './CategoryRow.module.css'
@@ -24,6 +26,16 @@ export function BlogCategoryRow({
onEdit, onEdit,
onRemove, onRemove,
}: BlogCategoryRowProps) { }: BlogCategoryRowProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const primaryName = isFa ? category.nameFa || category.nameEn : category.nameEn
const secondaryName = isFa
? category.nameFa
? category.nameEn
: ''
: category.nameFa
return ( return (
<div className={styles.row} style={{ marginLeft: depth * 8 }}> <div className={styles.row} style={{ marginLeft: depth * 8 }}>
<div <div
@@ -45,9 +57,13 @@ export function BlogCategoryRow({
> >
<div className={styles.textBlock}> <div className={styles.textBlock}>
<div className={styles.names}> <div className={styles.names}>
<span className={styles.nameEn}>{category.nameEn}</span> <span className={isFa ? styles.nameFa : styles.nameEn}>{primaryName}</span>
<span className={styles.separator}>·</span> {secondaryName ? (
<span className={styles.nameFa}>{category.nameFa}</span> <>
<span className={styles.separator}>·</span>
<span className={isFa ? styles.nameEn : styles.nameFa}>{secondaryName}</span>
</>
) : null}
{hasChildren && ( {hasChildren && (
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}> <span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={18} /> <ChevronRight size={18} />
@@ -59,32 +75,32 @@ export function BlogCategoryRow({
</div> </div>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label="Add sub category"> <Tooltip label={t('categories.tooltip.addSub')}>
<button <button
type="button" type="button"
className={styles.controlBtn} className={styles.controlBtn}
onClick={() => onAddSub(category.id)} onClick={() => onAddSub(category.id)}
aria-label="Add sub category" aria-label={t('categories.tooltip.addSub')}
> >
<FolderPlus size={17} /> <FolderPlus size={17} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Edit category"> <Tooltip label={t('categories.tooltip.edit')}>
<button <button
type="button" type="button"
className={styles.controlBtn} className={styles.controlBtn}
onClick={() => onEdit(category.id)} onClick={() => onEdit(category.id)}
aria-label="Edit category" aria-label={t('categories.tooltip.edit')}
> >
<Pencil size={17} /> <Pencil size={17} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove category"> <Tooltip label={t('categories.tooltip.remove')}>
<button <button
type="button" type="button"
className={`${styles.controlBtn} ${styles.danger}`} className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)} onClick={() => onRemove(category.id)}
aria-label="Remove" aria-label={t('categories.tooltip.remove')}
> >
<Trash2 size={17} /> <Trash2 size={17} />
</button> </button>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -30,6 +31,8 @@ export function BlogCommentsModal({
onClose, onClose,
onCountChange, onCountChange,
}: BlogCommentsModalProps) { }: BlogCommentsModalProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
@@ -200,7 +203,7 @@ export function BlogCommentsModal({
<div className={styles.commentMeta}> <div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}> <div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)} {formatCommentDate(comment.createdAt, dateLocale)}
</div> </div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -18,6 +19,8 @@ interface BlogCommentsSectionProps {
} }
export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSectionProps) { export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSectionProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0) const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -130,7 +133,9 @@ export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSecti
<div className={styles.commentHeader}> <div className={styles.commentHeader}>
<div className={styles.commentMeta}> <div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>{formatCommentDate(comment.createdAt)}</div> <div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
<ThumbsUp size={13} /> <ThumbsUp size={13} />
+29 -16
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ImageCropper } from './ImageCropper' import { ImageCropper } from './ImageCropper'
import type { Brand, BrandFormData } from '../types/brand' import type { Brand, BrandFormData } from '../types/brand'
import styles from './BrandModal.module.css' import styles from './BrandModal.module.css'
@@ -21,9 +23,12 @@ export function BrandModal({
onClose, onClose,
onSubmit, onSubmit,
editingBrand = null, editingBrand = null,
title = 'Add Brand', title,
isSubmitting = false, isSubmitting = false,
}: BrandModalProps) { }: BrandModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -85,39 +90,46 @@ export function BrandModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="brand-modal-title" aria-labelledby="brand-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={styles.header}> <div className={styles.header}>
<h3 id="brand-modal-title" className={styles.title}> <h3 id="brand-modal-title" className={styles.title}>
{title} {title ?? t('brands.add')}
</h3> </h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}> <form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}> <div className={styles.field}>
<label>Brand logo</label> <label>{t('brands.modal.logo')}</label>
<ImageCropper <ImageCropper
value={image} value={image}
onChange={setImage} onChange={setImage}
outputFormat="png" outputFormat="png"
accept="image/png" accept="image/png"
uploadLabel="Upload brand logo" uploadLabel={t('brands.modal.uploadLogo')}
hint="PNG only — transparent background recommended" hint={t('brands.modal.logoHint')}
changeLabel="Change logo" changeLabel={t('brands.modal.changeLogo')}
/> />
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="nameFa">Name (FA)</label> <label htmlFor="nameFa">{t('brands.modal.nameFa')}</label>
<input <input
id="nameFa" id="nameFa"
name="nameFa" name="nameFa"
type="text" type="text"
dir="rtl" dir="rtl"
className="faText" className="faText"
placeholder="نام برند" placeholder={t('brands.modal.nameFaPlaceholder')}
value={nameFa} value={nameFa}
onChange={(e) => setNameFa(e.target.value)} onChange={(e) => setNameFa(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
@@ -125,13 +137,13 @@ export function BrandModal({
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="nameEn">Name (EN)</label> <label htmlFor="nameEn">{t('brands.modal.nameEn')}</label>
<input <input
id="nameEn" id="nameEn"
name="nameEn" name="nameEn"
type="text" type="text"
dir="ltr" dir={isFa ? 'rtl' : 'ltr'}
placeholder="Brand name" placeholder={t('brands.modal.nameEnPlaceholder')}
value={nameEn} value={nameEn}
onChange={(e) => setNameEn(e.target.value)} onChange={(e) => setNameEn(e.target.value)}
required required
@@ -140,12 +152,13 @@ export function BrandModal({
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="about">About</label> <label htmlFor="about">{t('brands.modal.about')}</label>
<textarea <textarea
id="about" id="about"
name="about" name="about"
rows={3} rows={3}
placeholder="Short description of this brand" dir={isFa ? 'rtl' : 'ltr'}
placeholder={t('brands.modal.aboutPlaceholder')}
value={about} value={about}
onChange={(e) => setAbout(e.target.value)} onChange={(e) => setAbout(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
@@ -159,10 +172,10 @@ export function BrandModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('brands.modal.cancel')}
</button> </button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Brand'} {isSubmitting ? t('brands.modal.saving') : t('brands.modal.save')}
</button> </button>
</div> </div>
</form> </form>
+16 -8
View File
@@ -1,5 +1,7 @@
import { Pencil, Trash2 } from 'lucide-react' import { Pencil, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Brand } from '../types/brand' import type { Brand } from '../types/brand'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import rowStyles from './CategoryRow.module.css' import rowStyles from './CategoryRow.module.css'
import styles from './BrandRow.module.css' import styles from './BrandRow.module.css'
@@ -11,6 +13,12 @@ interface BrandRowProps {
} }
export function BrandRow({ brand, onEdit, onRemove }: BrandRowProps) { export function BrandRow({ brand, onEdit, onRemove }: BrandRowProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const primaryName = isFa ? brand.nameFa || brand.nameEn : brand.nameEn
const secondaryName = isFa ? (brand.nameFa ? brand.nameEn : '') : brand.nameFa
return ( return (
<div className={rowStyles.row}> <div className={rowStyles.row}>
<div className={styles.info}> <div className={styles.info}>
@@ -19,33 +27,33 @@ export function BrandRow({ brand, onEdit, onRemove }: BrandRowProps) {
)} )}
<div className={rowStyles.textBlock}> <div className={rowStyles.textBlock}>
<div className={rowStyles.names}> <div className={rowStyles.names}>
<span className={rowStyles.nameEn}>{brand.nameEn}</span> <span className={isFa ? rowStyles.nameFa : rowStyles.nameEn}>{primaryName}</span>
{brand.nameFa && ( {secondaryName ? (
<> <>
<span className={rowStyles.separator}>·</span> <span className={rowStyles.separator}>·</span>
<span className={rowStyles.nameFa}>{brand.nameFa}</span> <span className={isFa ? rowStyles.nameEn : rowStyles.nameFa}>{secondaryName}</span>
</> </>
)} ) : null}
</div> </div>
{brand.about && <p className={rowStyles.description}>{brand.about}</p>} {brand.about && <p className={rowStyles.description}>{brand.about}</p>}
</div> </div>
</div> </div>
<div className={rowStyles.controls}> <div className={rowStyles.controls}>
<Tooltip label="Edit brand"> <Tooltip label={t('brands.tooltip.edit')}>
<button <button
className={rowStyles.controlBtn} className={rowStyles.controlBtn}
onClick={() => onEdit(brand.id)} onClick={() => onEdit(brand.id)}
aria-label="Edit brand" aria-label={t('brands.tooltip.edit')}
> >
<Pencil size={17} /> <Pencil size={17} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove brand"> <Tooltip label={t('brands.tooltip.remove')}>
<button <button
className={`${rowStyles.controlBtn} ${rowStyles.danger}`} className={`${rowStyles.controlBtn} ${rowStyles.danger}`}
onClick={() => onRemove(brand.id)} onClick={() => onRemove(brand.id)}
aria-label="Remove brand" aria-label={t('brands.tooltip.remove')}
> >
<Trash2 size={17} /> <Trash2 size={17} />
</button> </button>
@@ -70,6 +70,13 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.hint {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--text-muted);
}
.field input, .field input,
.field textarea { .field textarea {
padding: var(--field-padding-y) var(--field-padding-x); padding: var(--field-padding-y) var(--field-padding-x);
+27 -13
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect' import { SearchableSelect } from './SearchableSelect'
@@ -26,9 +28,12 @@ export function CategoryModal({
categories, categories,
defaultParentId = '', defaultParentId = '',
editingCategory = null, editingCategory = null,
title = 'Add Category', title,
isSubmitting = false, isSubmitting = false,
}: CategoryModalProps) { }: CategoryModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -91,35 +96,43 @@ export function CategoryModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="category-modal-title" aria-labelledby="category-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={styles.header}> <div className={styles.header}>
<h3 id="category-modal-title" className={styles.title}> <h3 id="category-modal-title" className={styles.title}>
{title} {title ?? t('categories.add')}
</h3> </h3>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}> <form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}> <div className={styles.field}>
<label>Parent Category</label> <label>{t('categories.modal.parent')}</label>
<SearchableSelect <SearchableSelect
options={parentOptions} options={parentOptions}
value={parentId} value={parentId}
onChange={setParentId} onChange={setParentId}
placeholder={t('categories.select.search')}
/> />
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="nameFa">Name (FA)</label> <label htmlFor="nameFa">{t('categories.modal.nameFa')}</label>
<input <input
id="nameFa" id="nameFa"
name="nameFa" name="nameFa"
type="text" type="text"
dir="rtl" dir="rtl"
className="faText" className="faText"
placeholder="نام دسته‌بندی" placeholder={t('categories.modal.nameFaPlaceholder')}
value={nameFa} value={nameFa}
onChange={(e) => setNameFa(e.target.value)} onChange={(e) => setNameFa(e.target.value)}
required required
@@ -128,13 +141,13 @@ export function CategoryModal({
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="nameEn">Name (EN)</label> <label htmlFor="nameEn">{t('categories.modal.nameEn')}</label>
<input <input
id="nameEn" id="nameEn"
name="nameEn" name="nameEn"
type="text" type="text"
dir="ltr" dir={isFa ? 'rtl' : 'ltr'}
placeholder="Category name" placeholder={t('categories.modal.nameEnPlaceholder')}
value={nameEn} value={nameEn}
onChange={(e) => setNameEn(e.target.value)} onChange={(e) => setNameEn(e.target.value)}
required required
@@ -143,12 +156,13 @@ export function CategoryModal({
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="description">Description</label> <label htmlFor="description">{t('categories.modal.description')}</label>
<textarea <textarea
id="description" id="description"
name="description" name="description"
rows={3} rows={3}
placeholder="Short description of this category" dir={isFa ? 'rtl' : 'ltr'}
placeholder={t('categories.modal.descriptionPlaceholder')}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
@@ -162,10 +176,10 @@ export function CategoryModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('categories.modal.cancel')}
</button> </button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Category'} {isSubmitting ? t('categories.modal.saving') : t('categories.modal.save')}
</button> </button>
</div> </div>
</form> </form>
@@ -85,11 +85,17 @@
.nameFa { .nameFa {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
direction: rtl; direction: rtl;
} }
.names .nameEn:not(:first-child),
.names .nameFa:not(:first-child) {
font-weight: 500;
color: var(--text-secondary);
}
.description { .description {
margin-top: 4px; margin-top: 4px;
font-size: 13px; font-size: 13px;
+29 -13
View File
@@ -1,5 +1,7 @@
import { FolderPlus, Layers, ListTree, Trash2, ChevronRight, ClipboardList } from 'lucide-react' import { FolderPlus, Layers, ListTree, Trash2, ChevronRight, ClipboardList } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css' import styles from './CategoryRow.module.css'
@@ -32,6 +34,16 @@ export function CategoryRow({
onOptions, onOptions,
onRemove, onRemove,
}: CategoryRowProps) { }: CategoryRowProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const primaryName = isFa ? category.nameFa || category.nameEn : category.nameEn
const secondaryName = isFa
? category.nameFa
? category.nameEn
: ''
: category.nameFa
return ( return (
<div <div
className={styles.row} className={styles.row}
@@ -56,9 +68,13 @@ export function CategoryRow({
> >
<div className={styles.textBlock}> <div className={styles.textBlock}>
<div className={styles.names}> <div className={styles.names}>
<span className={styles.nameEn}>{category.nameEn}</span> <span className={isFa ? styles.nameFa : styles.nameEn}>{primaryName}</span>
<span className={styles.separator}>·</span> {secondaryName ? (
<span className={styles.nameFa}>{category.nameFa}</span> <>
<span className={styles.separator}>·</span>
<span className={isFa ? styles.nameEn : styles.nameFa}>{secondaryName}</span>
</>
) : null}
{hasChildren && ( {hasChildren && (
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}> <span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
<ChevronRight size={18} /> <ChevronRight size={18} />
@@ -72,20 +88,20 @@ export function CategoryRow({
</div> </div>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label="Add sub category"> <Tooltip label={t('categories.tooltip.addSub')}>
<button <button
className={styles.controlBtn} className={styles.controlBtn}
onClick={() => onAddSub(category.id)} onClick={() => onAddSub(category.id)}
aria-label="Add sub category" aria-label={t('categories.tooltip.addSub')}
> >
<FolderPlus size={17} /> <FolderPlus size={17} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Manage variations"> <Tooltip label={t('categories.tooltip.variations')}>
<button <button
className={`${styles.controlBtn} ${styles.iconBtn}`} className={`${styles.controlBtn} ${styles.iconBtn}`}
onClick={() => onVariations(category.id)} onClick={() => onVariations(category.id)}
aria-label={`Variations (${variationCount})`} aria-label={`${t('categories.tooltip.variations')} (${variationCount})`}
> >
<Layers size={17} /> <Layers size={17} />
{variationCount > 0 && ( {variationCount > 0 && (
@@ -93,11 +109,11 @@ export function CategoryRow({
)} )}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Technical data form"> <Tooltip label={t('categories.tooltip.technical')}>
<button <button
className={`${styles.controlBtn} ${styles.iconBtn}`} className={`${styles.controlBtn} ${styles.iconBtn}`}
onClick={() => onTechnicalForm(category.id)} onClick={() => onTechnicalForm(category.id)}
aria-label={`Technical data form (${technicalFieldCount})`} aria-label={`${t('categories.tooltip.technical')} (${technicalFieldCount})`}
> >
<ClipboardList size={17} /> <ClipboardList size={17} />
{technicalFieldCount > 0 && ( {technicalFieldCount > 0 && (
@@ -105,20 +121,20 @@ export function CategoryRow({
)} )}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Category options"> <Tooltip label={t('categories.tooltip.options')}>
<button <button
className={styles.controlBtn} className={styles.controlBtn}
onClick={() => onOptions(category.id)} onClick={() => onOptions(category.id)}
aria-label="Options" aria-label={t('categories.tooltip.options')}
> >
<ListTree size={17} /> <ListTree size={17} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove category"> <Tooltip label={t('categories.tooltip.remove')}>
<button <button
className={`${styles.controlBtn} ${styles.danger}`} className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)} onClick={() => onRemove(category.id)}
aria-label="Remove" aria-label={t('categories.tooltip.remove')}
> >
<Trash2 size={17} /> <Trash2 size={17} />
</button> </button>
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ContactSubmission } from '../types/contactSubmission' import type { ContactSubmission } from '../types/contactSubmission'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { useT } from '../i18n/useT'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
import styles from './ContactSubmissionDetailModal.module.css' import styles from './ContactSubmissionDetailModal.module.css'
@@ -14,12 +16,13 @@ interface ContactSubmissionDetailModalProps {
const ANIMATION_MS = 220 const ANIMATION_MS = 220
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
} }
} }
@@ -28,6 +31,8 @@ export function ContactSubmissionDetailModal({
submission, submission,
onClose, onClose,
}: ContactSubmissionDetailModalProps) { }: ContactSubmissionDetailModalProps) {
const t = useT()
const { locale } = useLocale()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -56,7 +61,7 @@ export function ContactSubmissionDetailModal({
if (!mounted || !submission) return null if (!mounted || !submission) return null
const { date, time } = formatDateTime(submission.createdAt) const { date, time } = formatDateTime(submission.createdAt, locale)
return createPortal( return createPortal(
<div <div
@@ -73,7 +78,7 @@ export function ContactSubmissionDetailModal({
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="contact-submission-title" className={modalStyles.title}> <h2 id="contact-submission-title" className={modalStyles.title}>
Contact submission {t('website.contact.detailTitle')}
</h2> </h2>
<p className={modalStyles.subtitle}>{submission.title}</p> <p className={modalStyles.subtitle}>{submission.title}</p>
</div> </div>
@@ -81,7 +86,7 @@ export function ContactSubmissionDetailModal({
type="button" type="button"
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t('common.close')}
> >
<X size={20} /> <X size={20} />
</button> </button>
@@ -90,31 +95,31 @@ export function ContactSubmissionDetailModal({
<div className={styles.body}> <div className={styles.body}>
<dl className={styles.metaGrid}> <dl className={styles.metaGrid}>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>Name</dt> <dt>{t('website.contact.col.name')}</dt>
<dd>{submission.name}</dd> <dd>{submission.name}</dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>Email</dt> <dt>{t('website.contact.col.email')}</dt>
<dd>{submission.email ?? '—'}</dd> <dd>{submission.email ?? '—'}</dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>Cell number</dt> <dt>{t('website.contact.col.cell')}</dt>
<dd> <dd>
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'} {submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
</dd> </dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>Date</dt> <dt>{t('website.contact.col.date')}</dt>
<dd>{date}</dd> <dd>{date}</dd>
</div> </div>
<div className={styles.metaItem}> <div className={styles.metaItem}>
<dt>Time</dt> <dt>{t('website.contact.col.time')}</dt>
<dd>{time || '—'}</dd> <dd>{time || '—'}</dd>
</div> </div>
</dl> </dl>
<div className={styles.messageBlock}> <div className={styles.messageBlock}>
<h3 className={styles.messageLabel}>Message</h3> <h3 className={styles.messageLabel}>{t('website.contact.detailMessage')}</h3>
<p className={styles.messageText}>{submission.text}</p> <p className={styles.messageText}>{submission.text}</p>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts } from '../services/productService'
import { import {
@@ -40,6 +42,8 @@ export function CreateStoreItemsModal({
onCreated, onCreated,
onEditExisting, onEditExisting,
}: CreateStoreItemsModalProps) { }: CreateStoreItemsModalProps) {
const t = useT()
const { locale } = useLocale()
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([]) const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
@@ -118,7 +122,7 @@ export function CreateStoreItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load products.') setError(t('storeItems.create.errorProducts'))
} }
} finally { } finally {
setIsLoadingProducts(false) setIsLoadingProducts(false)
@@ -138,7 +142,7 @@ export function CreateStoreItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load product variations.') setError(t('storeItems.create.errorVariations'))
} }
setVariations([]) setVariations([])
setRows([]) setRows([])
@@ -235,7 +239,7 @@ export function CreateStoreItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create store items.') setError(t('storeItems.create.errorCreate'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -253,36 +257,40 @@ export function CreateStoreItemsModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="create-store-items-title" aria-labelledby="create-store-items-title"
lang={locale === 'fa' ? 'fa' : 'en'}
dir={locale === 'fa' ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h3 id="create-store-items-title" className={modalStyles.title}> <h3 id="create-store-items-title" className={modalStyles.title}>
Add Store Items {t('storeItems.create.title')}
</h3> </h3>
<p className={modalStyles.subtitle}> <p className={modalStyles.subtitle}>{t('storeItems.create.subtitle')}</p>
Create sellable variants from a product&apos;s variations.
</p>
</div> </div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}> <form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
<div className={modalStyles.field}> <div className={modalStyles.field}>
<label htmlFor="store-item-product">Product</label>
<ProductSearchSelect <ProductSearchSelect
id="store-item-product" id="store-item-product"
options={products} options={products}
value={productId} value={productId}
onChange={handleProductChange} onChange={handleProductChange}
placeholder="Type 3+ characters to search products" placeholder={t('storeItems.create.productPlaceholder')}
disabled={isLoadingProducts || isSubmitting} disabled={isLoadingProducts || isSubmitting}
aria-label={t('storeItems.create.productPlaceholder')}
/> />
</div> </div>
{productId && isLoadingVariations && ( {productId && isLoadingVariations && (
<p className={modalStyles.emptyText}>Loading product variations...</p> <p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
)} )}
{productId && !isLoadingVariations && ( {productId && !isLoadingVariations && (
@@ -294,8 +302,8 @@ export function CreateStoreItemsModal({
{formVariations.map((variation) => ( {formVariations.map((variation) => (
<span key={variation.id}>{variation.name}</span> <span key={variation.id}>{variation.name}</span>
))} ))}
<span>Price (IRT)</span> <span>{t('storeItems.create.price')}</span>
<span>Stock</span> <span>{t('storeItems.create.stock')}</span>
<span /> <span />
</div> </div>
@@ -346,13 +354,13 @@ export function CreateStoreItemsModal({
disabled={isSubmitting} disabled={isSubmitting}
/> />
<div className={rowStyles.removeCell}> <div className={rowStyles.removeCell}>
<Tooltip label="Remove row"> <Tooltip label={t('storeItems.create.removeRow')}>
<button <button
type="button" type="button"
className={modalStyles.removeRowBtn} className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)} onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting} disabled={rows.length <= 1 || isSubmitting}
aria-label="Remove row" aria-label={t('storeItems.create.removeRow')}
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -369,7 +377,7 @@ export function CreateStoreItemsModal({
disabled={isSubmitting} disabled={isSubmitting}
> >
<Plus size={16} /> <Plus size={16} />
Add row {t('storeItems.create.addRow')}
</button> </button>
</> </>
)} )}
@@ -383,14 +391,16 @@ export function CreateStoreItemsModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('storeItems.create.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className={modalStyles.submitBtn} className={modalStyles.submitBtn}
disabled={!canSubmit} disabled={!canSubmit}
> >
{isSubmitting ? 'Creating…' : 'Create store items'} {isSubmitting
? t('storeItems.create.submitting')
: t('storeItems.create.submit')}
</button> </button>
</div> </div>
</form> </form>
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getProductVariationValues, getProductVariationValues,
@@ -59,6 +61,9 @@ export function EditStoreItemsModal({
onClose, onClose,
onSaved, onSaved,
}: EditStoreItemsModalProps) { }: EditStoreItemsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [variations, setVariations] = useState<ProductVariationSelection[]>([]) const [variations, setVariations] = useState<ProductVariationSelection[]>([])
@@ -113,7 +118,7 @@ export function EditStoreItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load product variations.') setError(t('storeItems.create.errorVariations'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -203,13 +208,17 @@ export function EditStoreItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save store items.') setError(t('storeItems.edit.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
const subtitle = isFa
? [productNameFa, productTitle].filter(Boolean).join(' / ')
: [productTitle, productNameFa].filter(Boolean).join(' / ')
return createPortal( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -221,25 +230,28 @@ export function EditStoreItemsModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="edit-store-items-title" aria-labelledby="edit-store-items-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h3 id="edit-store-items-title" className={modalStyles.title}> <h3 id="edit-store-items-title" className={modalStyles.title}>
Edit Store Items {t('storeItems.edit.title')}
</h3> </h3>
<p className={modalStyles.subtitle}> <p className={modalStyles.subtitle}>{subtitle}</p>
{productTitle}
{productNameFa ? ` / ${productNameFa}` : ''}
</p>
</div> </div>
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}> <form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
{isLoading ? ( {isLoading ? (
<p className={modalStyles.emptyText}>Loading product variations...</p> <p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
) : ( ) : (
<> <>
<div <div
@@ -249,8 +261,8 @@ export function EditStoreItemsModal({
{formVariations.map((variation) => ( {formVariations.map((variation) => (
<span key={variation.id}>{variation.name}</span> <span key={variation.id}>{variation.name}</span>
))} ))}
<span>Price (IRT)</span> <span>{t('storeItems.create.price')}</span>
<span>Stock</span> <span>{t('storeItems.create.stock')}</span>
<span /> <span />
</div> </div>
@@ -301,13 +313,13 @@ export function EditStoreItemsModal({
disabled={isSubmitting} disabled={isSubmitting}
/> />
<div className={rowStyles.removeCell}> <div className={rowStyles.removeCell}>
<Tooltip label="Remove row"> <Tooltip label={t('storeItems.create.removeRow')}>
<button <button
type="button" type="button"
className={modalStyles.removeRowBtn} className={modalStyles.removeRowBtn}
onClick={() => removeRow(row.id)} onClick={() => removeRow(row.id)}
disabled={rows.length <= 1 || isSubmitting} disabled={rows.length <= 1 || isSubmitting}
aria-label="Remove row" aria-label={t('storeItems.create.removeRow')}
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -324,7 +336,7 @@ export function EditStoreItemsModal({
disabled={isSubmitting} disabled={isSubmitting}
> >
<Plus size={16} /> <Plus size={16} />
Add row {t('storeItems.create.addRow')}
</button> </button>
</> </>
)} )}
@@ -338,10 +350,10 @@ export function EditStoreItemsModal({
onClick={onClose} onClick={onClose}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('storeItems.create.cancel')}
</button> </button>
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}> <button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
{isSubmitting ? 'Saving' : 'Save changes'} {isSubmitting ? t('storeItems.edit.saving') : t('storeItems.edit.save')}
</button> </button>
</div> </div>
</form> </form>
+21 -11
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import Cropper, { type Area } from 'react-easy-crop' import Cropper, { type Area } from 'react-easy-crop'
import { ImagePlus, X } from 'lucide-react' import { ImagePlus, X } from 'lucide-react'
import { getCroppedImage } from '../utils/cropImage' import { getCroppedImage } from '../utils/cropImage'
import { useT } from '../i18n/useT'
import styles from './ImageCropper.module.css' import styles from './ImageCropper.module.css'
interface ImageCropperProps { interface ImageCropperProps {
@@ -22,10 +23,14 @@ export function ImageCropper({
aspect = 1, aspect = 1,
outputFormat = 'jpeg', outputFormat = 'jpeg',
accept = 'image/*', accept = 'image/*',
uploadLabel = 'Upload thumbnail image', uploadLabel,
hint = 'Click to select, then crop', hint,
changeLabel = 'Change thumbnail', changeLabel,
}: ImageCropperProps) { }: ImageCropperProps) {
const t = useT()
const resolvedUploadLabel = uploadLabel ?? t('products.form.thumbnailUpload')
const resolvedHint = hint ?? t('products.form.thumbnailHint')
const resolvedChangeLabel = changeLabel ?? t('products.form.thumbnailChange')
const [imageSrc, setImageSrc] = useState<string | null>(null) const [imageSrc, setImageSrc] = useState<string | null>(null)
const [crop, setCrop] = useState({ x: 0, y: 0 }) const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
@@ -93,8 +98,13 @@ export function ImageCropper({
<div className={styles.wrapper}> <div className={styles.wrapper}>
{value && !imageSrc && ( {value && !imageSrc && (
<div className={styles.preview}> <div className={styles.preview}>
<img src={value} alt="Thumbnail preview" className={styles.previewImgNatural} /> <img src={value} alt={resolvedUploadLabel} className={styles.previewImgNatural} />
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail"> <button
type="button"
className={styles.removeBtn}
onClick={removeThumbnail}
aria-label={t('products.form.thumbnailRemove')}
>
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -103,8 +113,8 @@ export function ImageCropper({
{!value && !imageSrc && ( {!value && !imageSrc && (
<label className={styles.uploadZone} style={frameStyle}> <label className={styles.uploadZone} style={frameStyle}>
<ImagePlus size={28} /> <ImagePlus size={28} />
<span>{uploadLabel}</span> <span>{resolvedUploadLabel}</span>
<span className={styles.hint}>{hint}</span> <span className={styles.hint}>{resolvedHint}</span>
<input type="file" accept={accept} onChange={handleFile} hidden /> <input type="file" accept={accept} onChange={handleFile} hidden />
</label> </label>
)} )}
@@ -125,7 +135,7 @@ export function ImageCropper({
</div> </div>
<div className={styles.cropControls}> <div className={styles.cropControls}>
<label className={styles.zoomLabel}> <label className={styles.zoomLabel}>
Zoom {t('products.form.zoom')}
<input <input
type="range" type="range"
min={1} min={1}
@@ -137,7 +147,7 @@ export function ImageCropper({
</label> </label>
<div className={styles.cropActions}> <div className={styles.cropActions}>
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}> <button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
Cancel {t('products.form.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -145,7 +155,7 @@ export function ImageCropper({
onClick={() => void applyCrop()} onClick={() => void applyCrop()}
disabled={!croppedArea} disabled={!croppedArea}
> >
Apply Crop {t('products.form.applyCrop')}
</button> </button>
</div> </div>
</div> </div>
@@ -154,7 +164,7 @@ export function ImageCropper({
{value && !imageSrc && ( {value && !imageSrc && (
<label className={styles.changeBtn}> <label className={styles.changeBtn}>
{changeLabel} {resolvedChangeLabel}
<input type="file" accept={accept} onChange={handleFile} hidden /> <input type="file" accept={accept} onChange={handleFile} hidden />
</label> </label>
)} )}
@@ -1,5 +1,6 @@
import { useRef } from 'react' import { useRef } from 'react'
import { ImagePlus, X } from 'lucide-react' import { ImagePlus, X } from 'lucide-react'
import { useT } from '../i18n/useT'
import styles from './ImageUploader.module.css' import styles from './ImageUploader.module.css'
interface ImageUploaderProps { interface ImageUploaderProps {
@@ -8,6 +9,7 @@ interface ImageUploaderProps {
} }
export function ImageUploader({ images, onChange }: ImageUploaderProps) { export function ImageUploader({ images, onChange }: ImageUploaderProps) {
const t = useT()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) { function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
@@ -39,12 +41,12 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
<div className={styles.grid}> <div className={styles.grid}>
{images.map((src, index) => ( {images.map((src, index) => (
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}> <div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
<img src={src} alt={`Product image ${index + 1}`} /> <img src={src} alt={t('products.form.images')} />
<button <button
type="button" type="button"
className={styles.removeBtn} className={styles.removeBtn}
onClick={() => removeImage(index)} onClick={() => removeImage(index)}
aria-label={`Remove image ${index + 1}`} aria-label={t('products.form.imagesRemove', { index: index + 1 })}
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -57,7 +59,7 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
<ImagePlus size={24} /> <ImagePlus size={24} />
<span>Add images</span> <span>{t('products.form.imagesAdd')}</span>
</button> </button>
</div> </div>
@@ -69,7 +71,7 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
hidden hidden
onChange={handleFiles} onChange={handleFiles}
/> />
<p className={styles.hint}>Upload multiple product images. Click + to add more.</p> <p className={styles.hint}>{t('products.form.imagesHint')}</p>
</div> </div>
) )
} }
@@ -1,6 +1,6 @@
.filtersPanel { .filtersPanel {
margin-bottom: 12px; margin-bottom: 12px;
padding: 14px 16px; padding: 10px 14px;
background: var(--glass-bg); background: var(--glass-bg);
backdrop-filter: blur(var(--blur-glass)); backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass)); -webkit-backdrop-filter: blur(var(--blur-glass));
@@ -10,25 +10,25 @@
} }
.filtersTitle { .filtersTitle {
font-size: 14px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 10px; margin-bottom: 8px;
} }
.filtersGrid { .filtersGrid {
display: grid; display: grid;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
gap: 10px 12px; gap: 8px 12px;
align-items: end; align-items: center;
} }
.filtersInputs { .filtersInputs {
grid-column: span 10; grid-column: span 10;
display: grid; display: grid;
grid-template-columns: repeat(12, 1fr); grid-template-columns: repeat(12, 1fr);
gap: 10px 12px; gap: 8px 12px;
align-items: end; align-items: center;
} }
.filterActions { .filterActions {
@@ -38,13 +38,13 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
align-self: end; align-self: center;
} }
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 0;
} }
.fieldCol2 { .fieldCol2 {
@@ -98,15 +98,12 @@
.switchField { .switchField {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 0;
justify-content: center;
} }
.switchFieldSpacer { .switchFieldSpacer {
font-size: 12px; display: none;
font-weight: 500;
line-height: 1.3;
visibility: hidden;
user-select: none;
} }
.switchInline { .switchInline {
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService' import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
@@ -43,10 +44,10 @@ function statusClass(status: TransactionStatus) {
} }
} }
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return value if (Number.isNaN(d.getTime())) return value
return d.toLocaleString('en-US', { return d.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: '2-digit', day: '2-digit',
@@ -55,10 +56,10 @@ function formatDateTime(value: string) {
}) })
} }
function transactionDetails(transaction: OrderTransaction) { function transactionDetails(transaction: OrderTransaction, locale: 'en' | 'fa') {
const rows: { label: string; value: string }[] = [ const rows: { label: string; value: string }[] = [
{ label: 'Status', value: transaction.status }, { label: 'Status', value: transaction.status },
{ label: 'Date', value: formatDateTime(transaction.createdAt) }, { label: 'Date', value: formatDateTime(transaction.createdAt, locale) },
] ]
if (transaction.type === 'pos' && transaction.posType) { if (transaction.type === 'pos' && transaction.posType) {
@@ -83,6 +84,8 @@ function transactionDetails(transaction: OrderTransaction) {
} }
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) { export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -147,7 +150,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
) : ( ) : (
<ul className={styles.txList}> <ul className={styles.txList}>
{transactions.map((transaction) => { {transactions.map((transaction) => {
const details = transactionDetails(transaction) const details = transactionDetails(transaction, dateLocale)
return ( return (
<li key={transaction.id} className={styles.txCard}> <li key={transaction.id} className={styles.txCard}>
<div className={styles.txHeader}> <div className={styles.txHeader}>
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllStoreItems } from '../services/storeItemService' import { listAllStoreItems } from '../services/storeItemService'
import { groupStoreItemsByProduct } from '../utils/storeProductGroups' import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
@@ -30,6 +32,9 @@ export function PickStoreSpecialItemsModal({
onConfirm, onConfirm,
isSubmitting = false, isSubmitting = false,
}: PickStoreSpecialItemsModalProps) { }: PickStoreSpecialItemsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([]) const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -97,7 +102,7 @@ export function PickStoreSpecialItemsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load store items.') setError(t('website.specialItems.pickErrorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -117,6 +122,12 @@ export function PickStoreSpecialItemsModal({
onConfirm(selectedIds) onConfirm(selectedIds)
} }
function confirmLabel() {
if (isSubmitting) return t('website.adding')
if (selectedIds.length === 1) return t('website.pick.addOneItem')
return t('website.pick.addItems', { count: selectedIds.length })
}
if (!mounted) return null if (!mounted) return null
return createPortal( return createPortal(
@@ -130,15 +141,22 @@ export function PickStoreSpecialItemsModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="pick-special-items-title" aria-labelledby="pick-special-items-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="pick-special-items-title" className={modalStyles.title}> <h2 id="pick-special-items-title" className={modalStyles.title}>
Add to {specialTitle} {t('website.pick.addTo', { title: specialTitle })}
</h2> </h2>
<p className={modalStyles.subtitle}>Select store items to feature in this category.</p> <p className={modalStyles.subtitle}>{t('website.specialItems.pickSubtitle')}</p>
</div> </div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -147,22 +165,22 @@ export function PickStoreSpecialItemsModal({
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading store items...</p> <p className={styles.empty}>{t('website.specialItems.pickLoading')}</p>
) : options.length === 0 ? ( ) : options.length === 0 ? (
<p className={styles.empty}>All store items are already in this category.</p> <p className={styles.empty}>{t('website.specialItems.pickEmpty')}</p>
) : ( ) : (
<StoreItemMultiSearchSelect <StoreItemMultiSearchSelect
options={options} options={options}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleSelection} onToggle={toggleSelection}
placeholder="Type 3+ characters to search store items" placeholder={t('website.specialItems.pickSearch')}
disabled={isSubmitting} disabled={isSubmitting}
/> />
)} )}
<div className={styles.footer}> <div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}> <button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel {t('products.form.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -170,7 +188,7 @@ export function PickStoreSpecialItemsModal({
onClick={handleConfirm} onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0} disabled={isSubmitting || selectedIds.length === 0}
> >
{isSubmitting ? 'Adding...' : `Add ${selectedIds.length || ''} item${selectedIds.length === 1 ? '' : 's'}`} {confirmLabel()}
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { StoreItem } from '../services/storeItemService' import type { StoreItem } from '../services/storeItemService'
import { StoreItemPrice } from './StoreItemPrice' import { StoreItemPrice } from './StoreItemPrice'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
@@ -25,6 +27,9 @@ export function PickStoreVariantModal({
onClose, onClose,
onSelect, onSelect,
}: PickStoreVariantModalProps) { }: PickStoreVariantModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -53,6 +58,10 @@ export function PickStoreVariantModal({
if (!mounted) return null if (!mounted) return null
const subtitle = isFa
? [productNameFa, productTitle].filter(Boolean).join(' / ')
: [productTitle, productNameFa].filter(Boolean).join(' / ')
return createPortal( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -64,22 +73,21 @@ export function PickStoreVariantModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="pick-variant-title" aria-labelledby="pick-variant-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="pick-variant-title" className={modalStyles.title}> <h2 id="pick-variant-title" className={modalStyles.title}>
Choose variant {t('storeItems.pick.title')}
</h2> </h2>
<p className={modalStyles.subtitle}> <p className={modalStyles.subtitle}>{subtitle}</p>
{productTitle}
{productNameFa ? ` / ${productNameFa}` : ''}
</p>
</div> </div>
<button <button
type="button" type="button"
className={modalStyles.closeBtn} className={modalStyles.closeBtn}
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t('common.close')}
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -98,7 +106,7 @@ export function PickStoreVariantModal({
<span className={styles.optionLabel}>{variant.label}</span> <span className={styles.optionLabel}>{variant.label}</span>
{variant.stockQuantity !== null && ( {variant.stockQuantity !== null && (
<span className={styles.optionStock}> <span className={styles.optionStock}>
{variant.stockQuantity} in stock {t('storeItems.pick.inStock', { count: variant.stockQuantity })}
</span> </span>
)} )}
</div> </div>
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllBrands } from '../services/brandService' import { listAllBrands } from '../services/brandService'
import { import {
@@ -29,6 +31,9 @@ export function PickWebsiteBrandsModal({
onConfirm, onConfirm,
isSubmitting = false, isSubmitting = false,
}: PickWebsiteBrandsModalProps) { }: PickWebsiteBrandsModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([]) const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -92,7 +97,7 @@ export function PickWebsiteBrandsModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load brands.') setError(t('website.specialBrands.pickErrorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -110,6 +115,12 @@ export function PickWebsiteBrandsModal({
onConfirm(selectedIds) onConfirm(selectedIds)
} }
function confirmLabel() {
if (isSubmitting) return t('website.adding')
if (selectedIds.length === 1) return t('website.pick.addOneBrand')
return t('website.pick.addBrands', { count: selectedIds.length })
}
if (!mounted) return null if (!mounted) return null
return createPortal( return createPortal(
@@ -123,15 +134,22 @@ export function PickWebsiteBrandsModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="pick-brands-title" aria-labelledby="pick-brands-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="pick-brands-title" className={modalStyles.title}> <h2 id="pick-brands-title" className={modalStyles.title}>
Add to {groupTitle} {t('website.pick.addTo', { title: groupTitle })}
</h2> </h2>
<p className={modalStyles.subtitle}>Select brands to feature in this group.</p> <p className={modalStyles.subtitle}>{t('website.specialBrands.pickSubtitle')}</p>
</div> </div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -140,22 +158,22 @@ export function PickWebsiteBrandsModal({
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading brands...</p> <p className={styles.empty}>{t('website.specialBrands.pickLoading')}</p>
) : options.length === 0 ? ( ) : options.length === 0 ? (
<p className={styles.empty}>All brands are already in this group.</p> <p className={styles.empty}>{t('website.specialBrands.pickEmpty')}</p>
) : ( ) : (
<StoreItemMultiSearchSelect <StoreItemMultiSearchSelect
options={options} options={options}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleSelection} onToggle={toggleSelection}
placeholder="Type 3+ characters to search brands" placeholder={t('website.specialBrands.pickSearch')}
disabled={isSubmitting} disabled={isSubmitting}
/> />
)} )}
<div className={styles.footer}> <div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}> <button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel {t('products.form.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -163,9 +181,7 @@ export function PickWebsiteBrandsModal({
onClick={handleConfirm} onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0} disabled={isSubmitting || selectedIds.length === 0}
> >
{isSubmitting {confirmLabel()}
? 'Adding...'
: `Add ${selectedIds.length || ''} brand${selectedIds.length === 1 ? '' : 's'}`}
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { import {
@@ -29,6 +31,9 @@ export function PickWebsiteCategoriesModal({
onConfirm, onConfirm,
isSubmitting = false, isSubmitting = false,
}: PickWebsiteCategoriesModalProps) { }: PickWebsiteCategoriesModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [options, setOptions] = useState<StoreItemSearchOption[]>([]) const [options, setOptions] = useState<StoreItemSearchOption[]>([])
@@ -93,7 +98,7 @@ export function PickWebsiteCategoriesModal({
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('website.specialCategories.pickErrorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -111,6 +116,12 @@ export function PickWebsiteCategoriesModal({
onConfirm(selectedIds) onConfirm(selectedIds)
} }
function confirmLabel() {
if (isSubmitting) return t('website.adding')
if (selectedIds.length === 1) return t('website.pick.addOneCategory')
return t('website.pick.addCategories', { count: selectedIds.length })
}
if (!mounted) return null if (!mounted) return null
return createPortal( return createPortal(
@@ -124,15 +135,22 @@ export function PickWebsiteCategoriesModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="pick-categories-title" aria-labelledby="pick-categories-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={modalStyles.header}> <div className={modalStyles.header}>
<div> <div>
<h2 id="pick-categories-title" className={modalStyles.title}> <h2 id="pick-categories-title" className={modalStyles.title}>
Add to {groupTitle} {t('website.pick.addTo', { title: groupTitle })}
</h2> </h2>
<p className={modalStyles.subtitle}>Select product categories to feature in this group.</p> <p className={modalStyles.subtitle}>{t('website.specialCategories.pickSubtitle')}</p>
</div> </div>
<button type="button" className={modalStyles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -141,22 +159,22 @@ export function PickWebsiteCategoriesModal({
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading categories...</p> <p className={styles.empty}>{t('website.specialCategories.pickLoading')}</p>
) : options.length === 0 ? ( ) : options.length === 0 ? (
<p className={styles.empty}>All categories are already in this group.</p> <p className={styles.empty}>{t('website.specialCategories.pickEmpty')}</p>
) : ( ) : (
<StoreItemMultiSearchSelect <StoreItemMultiSearchSelect
options={options} options={options}
selectedIds={selectedIds} selectedIds={selectedIds}
onToggle={toggleSelection} onToggle={toggleSelection}
placeholder="Type 3+ characters to search categories" placeholder={t('website.specialCategories.pickSearch')}
disabled={isSubmitting} disabled={isSubmitting}
/> />
)} )}
<div className={styles.footer}> <div className={styles.footer}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}> <button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
Cancel {t('products.form.cancel')}
</button> </button>
<button <button
type="button" type="button"
@@ -164,9 +182,7 @@ export function PickWebsiteCategoriesModal({
onClick={handleConfirm} onClick={handleConfirm}
disabled={isSubmitting || selectedIds.length === 0} disabled={isSubmitting || selectedIds.length === 0}
> >
{isSubmitting {confirmLabel()}
? 'Adding...'
: `Add ${selectedIds.length || ''} categor${selectedIds.length === 1 ? 'y' : 'ies'}`}
</button> </button>
</div> </div>
</div> </div>
@@ -92,6 +92,10 @@
overflow: hidden; overflow: hidden;
} }
.titleEn[dir='rtl'] {
text-align: right;
}
.abstract { .abstract {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
+32 -14
View File
@@ -1,7 +1,9 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react' import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Portfolio } from '../types/portfolio' import type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService' import { formatPortfolioCardDate } from '../services/portfolioService'
import { useT } from '../i18n/useT'
import { textLocaleAttrs } from '../utils/textLocale' import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import cardStyles from './PortfolioCard.module.css' import cardStyles from './PortfolioCard.module.css'
@@ -28,13 +30,21 @@ export function PortfolioCard({
onComments, onComments,
onRemove, onRemove,
}: PortfolioCardProps) { }: PortfolioCardProps) {
const publishDate = formatPortfolioCardDate(portfolio.publishedAt ?? portfolio.createdAt) const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const publishDate = formatPortfolioCardDate(
portfolio.publishedAt ?? portfolio.createdAt,
isFa ? 'fa' : 'en',
)
const titleFa = (portfolio.titleFa || portfolio.title || '').trim() const titleFa = (portfolio.titleFa || portfolio.title || '').trim()
const titleEn = (portfolio.titleEn || '').trim() const titleEn = (portfolio.titleEn || '').trim()
const showBothTitles = Boolean(titleFa && titleEn) const showBothTitles = Boolean(titleFa && titleEn)
const showAbstract = !showBothTitles const showAbstract = !showBothTitles
const abstractLocale = textLocaleAttrs(portfolio.abstract) const abstractLocale = textLocaleAttrs(portfolio.abstract)
const hasAbstract = Boolean(portfolio.abstract?.trim()) const hasAbstract = Boolean(portfolio.abstract?.trim())
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
return ( return (
<article className={cardStyles.card}> <article className={cardStyles.card}>
@@ -52,14 +62,14 @@ export function PortfolioCard({
)} )}
</div> </div>
<div className={cardStyles.body}> <div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
{titleFa ? ( {titleFa ? (
<h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl"> <h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl">
{titleFa} {titleFa}
</h3> </h3>
) : null} ) : null}
{titleEn ? ( {titleEn ? (
<p className={cardStyles.titleEn} lang="en" dir="ltr"> <p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
{titleEn} {titleEn}
</p> </p>
) : null} ) : null}
@@ -70,13 +80,17 @@ export function PortfolioCard({
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
lang={abstractLocale.lang} lang={abstractLocale.lang}
dir={abstractLocale.dir} dir={abstractDir}
> >
{portfolio.abstract} {portfolio.abstract}
</p> </p>
) : ( ) : (
<p className={cardStyles.abstract} lang="en" dir="ltr"> <p
No summary yet. className={cardStyles.abstract}
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{t('portfolio.card.noSummary')}
</p> </p>
) )
) : null} ) : null}
@@ -93,27 +107,31 @@ export function PortfolioCard({
</Link> </Link>
<div className={controlStyles.controls}> <div className={controlStyles.controls}>
<Tooltip label="Move up"> <Tooltip label={t('portfolio.card.moveUp')}>
<button <button
type="button" type="button"
onClick={() => onMoveUp(portfolio.id)} onClick={() => onMoveUp(portfolio.id)}
disabled={!canMoveUp || isMovingUp} disabled={!canMoveUp || isMovingUp}
aria-label="Move up" aria-label={t('portfolio.card.moveUp')}
> >
<ChevronUp size={16} /> <ChevronUp size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Edit portfolio"> <Tooltip label={t('portfolio.card.edit')}>
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit"> <button
type="button"
onClick={() => onEdit(portfolio.id)}
aria-label={t('portfolio.card.edit')}
>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="View comments"> <Tooltip label={t('portfolio.card.comments')}>
<button <button
type="button" type="button"
className={controlStyles.iconBtn} className={controlStyles.iconBtn}
onClick={() => onComments(portfolio.id)} onClick={() => onComments(portfolio.id)}
aria-label={`Comments (${commentCount})`} aria-label={`${t('portfolio.card.comments')} (${commentCount})`}
> >
<MessageSquare size={16} /> <MessageSquare size={16} />
{commentCount > 0 && ( {commentCount > 0 && (
@@ -121,12 +139,12 @@ export function PortfolioCard({
)} )}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove portfolio"> <Tooltip label={t('portfolio.card.remove')}>
<button <button
type="button" type="button"
className={controlStyles.danger} className={controlStyles.danger}
onClick={() => onRemove(portfolio.id)} onClick={() => onRemove(portfolio.id)}
aria-label="Remove" aria-label={t('portfolio.card.remove')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -30,6 +31,8 @@ export function PortfolioCommentsModal({
onClose, onClose,
onCountChange, onCountChange,
}: PortfolioCommentsModalProps) { }: PortfolioCommentsModalProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
@@ -200,7 +203,7 @@ export function PortfolioCommentsModal({
<div className={styles.commentMeta}> <div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}> <div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)} {formatCommentDate(comment.createdAt, dateLocale)}
</div> </div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -21,6 +22,8 @@ export function PortfolioCommentsSection({
portfolioId, portfolioId,
onCountChange, onCountChange,
}: PortfolioCommentsSectionProps) { }: PortfolioCommentsSectionProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
const [totalComments, setTotalComments] = useState(0) const [totalComments, setTotalComments] = useState(0)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -133,7 +136,9 @@ export function PortfolioCommentsSection({
<div className={styles.commentHeader}> <div className={styles.commentHeader}>
<div className={styles.commentMeta}> <div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}>{formatCommentDate(comment.createdAt)}</div> <div className={styles.dateTime}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
<ThumbsUp size={13} /> <ThumbsUp size={13} />
@@ -24,7 +24,8 @@
cursor: pointer; cursor: pointer;
} }
.clickable:hover .nameEn { .clickable:hover .nameFa,
.clickable:hover .nameEnPrimary {
color: var(--primary); color: var(--primary);
} }
@@ -62,14 +63,48 @@
.body { .body {
padding: 10px 10px 8px; padding: 10px 10px 8px;
flex: 1; flex: 1;
direction: rtl;
text-align: right;
} }
.nameEn { .nameFa {
font-family: var(--font-ui);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
margin-bottom: 3px; margin-bottom: 3px;
direction: rtl;
text-align: right;
unicode-bidi: plaintext;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.2s;
}
.nameEn {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
line-height: 1.3;
margin-bottom: 6px;
direction: ltr;
text-align: right;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.nameEnPrimary {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
margin-bottom: 3px;
direction: ltr;
text-align: left; text-align: left;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -78,9 +113,9 @@
transition: color 0.2s; transition: color 0.2s;
} }
.nameFa { .nameFaSecondary {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 12px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
direction: rtl; direction: rtl;
@@ -96,11 +131,19 @@
.categoryChip { .categoryChip {
display: inline-block; display: inline-block;
padding: 3px 10px; padding: 3px 10px;
font-family: var(--font-ui);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15); background: rgba(148, 163, 184, 0.15);
border-radius: 50px; border-radius: 50px;
direction: rtl;
unicode-bidi: plaintext;
}
.bodyLtr {
direction: ltr;
text-align: left;
} }
.controls { .controls {
+53 -18
View File
@@ -1,6 +1,8 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Pencil, Info, Trash2, Layers, MessageSquare, ClipboardList } from 'lucide-react' import { Pencil, Info, Trash2, Layers, MessageSquare, ClipboardList } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product' import type { Product } from '../types/product'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './ProductCard.module.css' import styles from './ProductCard.module.css'
@@ -27,49 +29,82 @@ export function ProductCard({
onRemove, onRemove,
onVariations, onVariations,
}: ProductCardProps) { }: ProductCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const primaryName = isFa ? product.nameFa || product.nameEn : product.nameEn
const secondaryName = isFa
? product.nameFa
? product.nameEn
: ''
: product.nameFa
const categoryLabel = isFa ? product.categoryFa || product.category : product.category
return ( return (
<article className={styles.card}> <article className={styles.card}>
<Link to={`/products/detail/${product.id}`} className={styles.clickable}> <Link to={`/products/detail/${product.id}`} className={styles.clickable}>
<div className={styles.imageWrap}> <div className={styles.imageWrap}>
<img src={product.image} alt={product.nameEn} className={styles.image} loading="lazy" /> <img
{product.status === 'draft' && <span className={styles.badge}>Draft</span>} src={product.image}
alt={primaryName}
className={styles.image}
loading="lazy"
/>
{product.status === 'draft' && (
<span className={styles.badge}>{t('products.card.draft')}</span>
)}
</div> </div>
<div className={styles.body}> <div className={`${styles.body}${isFa ? '' : ` ${styles.bodyLtr}`}`} dir={isFa ? 'rtl' : 'ltr'}>
<h3 className={styles.nameEn}>{product.nameEn}</h3> {isFa ? (
<p className={styles.nameFa}>{product.nameFa}</p> <>
<span className={styles.categoryChip}>{product.category}</span> <h3 className={styles.nameFa}>{primaryName}</h3>
{secondaryName ? <p className={styles.nameEn}>{secondaryName}</p> : null}
</>
) : (
<>
<h3 className={styles.nameEnPrimary}>{primaryName}</h3>
{secondaryName ? <p className={styles.nameFaSecondary}>{secondaryName}</p> : null}
</>
)}
{categoryLabel ? (
<span className={styles.categoryChip}>{categoryLabel}</span>
) : null}
</div> </div>
</Link> </Link>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label="Edit product"> <Tooltip label={t('products.card.edit')}>
<button type="button" onClick={() => onEdit(product.id)} aria-label="Edit"> <button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Quick info"> <Tooltip label={t('products.card.quickInfo')}>
<button type="button" onClick={() => onQuickInfo(product.id)} aria-label="Quick info"> <button
type="button"
onClick={() => onQuickInfo(product.id)}
aria-label={t('products.card.quickInfo')}
>
<Info size={16} /> <Info size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Manage variations"> <Tooltip label={t('products.card.variations')}>
<button <button
type="button" type="button"
className={styles.iconBtn} className={styles.iconBtn}
onClick={() => onVariations(product.id)} onClick={() => onVariations(product.id)}
aria-label={`Variations (${variantCount})`} aria-label={`${t('products.card.variations')} (${variantCount})`}
> >
<Layers size={16} /> <Layers size={16} />
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>} {variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="View comments"> <Tooltip label={t('products.card.comments')}>
<button <button
type="button" type="button"
className={styles.iconBtn} className={styles.iconBtn}
onClick={() => onComments(product.id)} onClick={() => onComments(product.id)}
aria-label={`Comments (${commentCount})`} aria-label={`${t('products.card.comments')} (${commentCount})`}
> >
<MessageSquare size={16} /> <MessageSquare size={16} />
{commentCount > 0 && ( {commentCount > 0 && (
@@ -77,21 +112,21 @@ export function ProductCard({
)} )}
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Technical data"> <Tooltip label={t('products.card.technical')}>
<button <button
type="button" type="button"
onClick={() => onTechnicalInfo(product.id)} onClick={() => onTechnicalInfo(product.id)}
aria-label="Technical data" aria-label={t('products.card.technical')}
> >
<ClipboardList size={16} /> <ClipboardList size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove product"> <Tooltip label={t('products.card.remove')}>
<button <button
type="button" type="button"
className={styles.danger} className={styles.danger}
onClick={() => onRemove(product.id)} onClick={() => onRemove(product.id)}
aria-label="Remove" aria-label={t('products.card.remove')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -30,6 +31,8 @@ export function ProductCommentsModal({
onClose, onClose,
onCountChange, onCountChange,
}: ProductCommentsModalProps) { }: ProductCommentsModalProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [comments, setComments] = useState<ProductComment[]>([]) const [comments, setComments] = useState<ProductComment[]>([])
@@ -200,7 +203,7 @@ export function ProductCommentsModal({
<div className={styles.commentMeta}> <div className={styles.commentMeta}>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.dateTime}> <div className={styles.dateTime}>
{formatCommentDate(comment.createdAt)} {formatCommentDate(comment.createdAt, dateLocale)}
</div> </div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react' import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -38,6 +39,8 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
} }
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) { export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
const { locale } = useLocale()
const dateLocale = locale === 'fa' ? 'fa' : 'en'
const [activeTab, setActiveTab] = useState<DetailTab>('technical') const [activeTab, setActiveTab] = useState<DetailTab>('technical')
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([]) const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
const [technicalMessage, setTechnicalMessage] = useState('') const [technicalMessage, setTechnicalMessage] = useState('')
@@ -296,7 +299,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<div className={styles.commentHeader}> <div className={styles.commentHeader}>
<div> <div>
<div className={styles.author}>{comment.author}</div> <div className={styles.author}>{comment.author}</div>
<div className={styles.meta}>{formatCommentDate(comment.createdAt)}</div> <div className={styles.meta}>
{formatCommentDate(comment.createdAt, dateLocale)}
</div>
</div> </div>
<span className={styles.likes}> <span className={styles.likes}>
<ThumbsUp size={13} /> <ThumbsUp size={13} />
@@ -363,7 +368,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
<div className={styles.reviewHeader}> <div className={styles.reviewHeader}>
<div> <div>
<div className={styles.reviewTitle}>{review.authorName}</div> <div className={styles.reviewTitle}>{review.authorName}</div>
<div className={styles.meta}>{formatReviewDate(review.createdAt)}</div> <div className={styles.meta}>
{formatReviewDate(review.createdAt, dateLocale)}
</div>
</div> </div>
<div className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}> <div className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}>
<Star size={14} fill="currentColor" /> <Star size={14} fill="currentColor" />
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react' import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './SearchableSelect.module.css' import styles from './SearchableSelect.module.css'
export interface ProductSearchOption { export interface ProductSearchOption {
@@ -16,6 +18,14 @@ interface ProductSearchSelectProps {
disabled?: boolean disabled?: boolean
id?: string id?: string
minSearchLength?: number minSearchLength?: number
'aria-label'?: string
}
function formatProductLabel(option: ProductSearchOption, preferFa: boolean) {
if (preferFa && option.nameFa) {
return option.title ? `${option.nameFa} / ${option.title}` : option.nameFa
}
return option.nameFa ? `${option.title} / ${option.nameFa}` : option.title
} }
export function ProductSearchSelect({ export function ProductSearchSelect({
@@ -26,13 +36,18 @@ export function ProductSearchSelect({
disabled = false, disabled = false,
id, id,
minSearchLength = 3, minSearchLength = 3,
'aria-label': ariaLabel,
}: ProductSearchSelectProps) { }: ProductSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const preferFa = locale === 'fa'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const selected = options.find((option) => option.id === value) const selected = options.find((option) => option.id === value)
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
const filtered = useMemo(() => { const filtered = useMemo(() => {
const trimmed = query.trim() const trimmed = query.trim()
@@ -70,7 +85,7 @@ export function ProductSearchSelect({
const showDropdown = open && query.trim().length >= minSearchLength const showDropdown = open && query.trim().length >= minSearchLength
return ( return (
<div className={styles.wrapper} ref={containerRef}> <div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}> <div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} /> <Search size={16} className={styles.searchIcon} />
<input <input
@@ -78,12 +93,8 @@ export function ProductSearchSelect({
id={id} id={id}
type="text" type="text"
className={styles.input} className={styles.input}
placeholder={ placeholder={selected ? selectedLabel : placeholder}
selected value={open ? query : selectedLabel}
? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}`
: placeholder
}
value={open ? query : selected ? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}` : ''}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
if (!open) setOpen(true) if (!open) setOpen(true)
@@ -91,13 +102,15 @@ export function ProductSearchSelect({
}} }}
onFocus={handleFocus} onFocus={handleFocus}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel ?? placeholder}
dir={preferFa ? 'rtl' : 'ltr'}
/> />
{value && !open && ( {value && !open && (
<button <button
type="button" type="button"
className={styles.clearBtn} className={styles.clearBtn}
onClick={() => onChange('')} onClick={() => onChange('')}
aria-label="Clear selection" aria-label={t('storeItems.search.clear')}
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -106,10 +119,12 @@ export function ProductSearchSelect({
</div> </div>
{showDropdown && ( {showDropdown && (
<ul className={styles.dropdown} role="listbox"> <ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<li className={styles.noResults}> <li className={styles.noResults}>
{options.length === 0 ? 'No products available.' : 'No matching products.'} {options.length === 0
? t('storeItems.search.noProducts')
: t('storeItems.search.noMatch')}
</li> </li>
) : ( ) : (
filtered.map((option) => ( filtered.map((option) => (
@@ -121,11 +136,33 @@ export function ProductSearchSelect({
className={`${styles.option} ${option.id === value ? styles.optionSelected : ''}`} className={`${styles.option} ${option.id === value ? styles.optionSelected : ''}`}
onClick={() => selectOption(option.id)} onClick={() => selectOption(option.id)}
> >
<span className={styles.optionEn}>{option.title}</span> {preferFa && option.nameFa ? (
{option.nameFa && (
<> <>
<span className={styles.optionSep}>/</span> <span className={styles.optionFa} dir="rtl">
<span className={styles.optionFa}>{option.nameFa}</span> {option.nameFa}
</span>
{option.title && (
<>
<span className={styles.optionSep}>/</span>
<span className={styles.optionEn} dir="rtl">
{option.title}
</span>
</>
)}
</>
) : (
<>
<span className={styles.optionEn} dir={preferFa ? 'rtl' : 'ltr'}>
{option.title}
</span>
{option.nameFa && (
<>
<span className={styles.optionSep}>/</span>
<span className={styles.optionFa} dir="rtl">
{option.nameFa}
</span>
</>
)}
</> </>
)} )}
</button> </button>
@@ -136,8 +173,10 @@ export function ProductSearchSelect({
)} )}
{open && query.trim().length > 0 && query.trim().length < minSearchLength && ( {open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={styles.dropdown}> <ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
<li className={styles.noResults}>Type at least {minSearchLength} characters to search</li> <li className={styles.noResults}>
{t('storeItems.search.minChars', { count: minSearchLength })}
</li>
</ul> </ul>
)} )}
</div> </div>
@@ -87,6 +87,17 @@
animation: dropdownIn 0.15s ease; animation: dropdownIn 0.15s ease;
} }
/* Prefer for selects inside overflow-clipped modals — expands parent instead of clipping. */
.dropdownInFlow {
position: static;
top: auto;
left: auto;
right: auto;
max-height: min(50vh, 400px);
margin-top: 6px;
z-index: auto;
}
@keyframes dropdownIn { @keyframes dropdownIn {
from { from {
opacity: 0; opacity: 0;
@@ -105,7 +116,7 @@
width: 100%; width: 100%;
padding: 7px 10px; padding: 7px 10px;
font-size: var(--field-font-size); font-size: var(--field-font-size);
text-align: left; text-align: start;
color: var(--text-primary); color: var(--text-primary);
border-radius: 8px; border-radius: 8px;
transition: background 0.15s; transition: background 0.15s;
@@ -123,6 +134,11 @@
.optionEn { .optionEn {
font-weight: 500; font-weight: 500;
text-align: start;
}
.optionEn[dir='rtl'] {
text-align: right;
} }
.optionSep { .optionSep {
@@ -133,6 +149,7 @@
font-family: var(--font-ui); font-family: var(--font-ui);
font-weight: 500; font-weight: 500;
direction: rtl; direction: rtl;
text-align: right;
} }
.noResults { .noResults {
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react' import { ChevronDown, Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { FlatCategory } from '../types/category' import type { FlatCategory } from '../types/category'
import styles from './SearchableSelect.module.css' import styles from './SearchableSelect.module.css'
@@ -10,18 +12,34 @@ interface SearchableSelectProps {
placeholder?: string placeholder?: string
} }
function categoryLabel(cat: FlatCategory, isFa: boolean) {
if (isFa) {
return cat.nameFa
? `${cat.nameFa}${cat.nameEn ? ` / ${cat.nameEn}` : ''}`
: cat.nameEn
}
return cat.nameEn
? `${cat.nameEn}${cat.nameFa ? ` / ${cat.nameFa}` : ''}`
: cat.nameFa
}
export function SearchableSelect({ export function SearchableSelect({
options, options,
value, value,
onChange, onChange,
placeholder = 'Search categories...', placeholder,
}: SearchableSelectProps) { }: SearchableSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const selected = options.find((o) => o.id === value) const selected = options.find((o) => o.id === value)
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
const searchPlaceholder = placeholder ?? t('categories.select.search')
const filtered = options.filter((cat) => { const filtered = options.filter((cat) => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
@@ -55,27 +73,28 @@ export function SearchableSelect({
} }
return ( return (
<div className={styles.wrapper} ref={containerRef}> <div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}> <div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
<Search size={16} className={styles.searchIcon} /> <Search size={16} className={styles.searchIcon} />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
className={styles.input} className={styles.input}
placeholder={selected ? `${selected.nameEn} / ${selected.nameFa}` : placeholder} placeholder={selected ? selectedLabel : searchPlaceholder}
value={open ? query : selected ? `${selected.nameEn} / ${selected.nameFa}` : ''} value={open ? query : selectedLabel}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
if (!open) setOpen(true) if (!open) setOpen(true)
}} }}
onFocus={handleFocus} onFocus={handleFocus}
dir={isFa ? 'rtl' : 'ltr'}
/> />
{value && !open && ( {value && !open && (
<button <button
type="button" type="button"
className={styles.clearBtn} className={styles.clearBtn}
onClick={() => onChange('')} onClick={() => onChange('')}
aria-label="Clear selection" aria-label={t('categories.select.clear')}
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -91,23 +110,41 @@ export function SearchableSelect({
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`} className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
onClick={() => selectOption('')} onClick={() => selectOption('')}
> >
None (root category) {t('categories.select.none')}
</button> </button>
</li> </li>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<li className={styles.noResults}>No categories found</li> <li className={styles.noResults}>{t('categories.select.empty')}</li>
) : ( ) : (
filtered.map((cat) => ( filtered.map((cat) => (
<li key={cat.id}> <li key={cat.id}>
<button <button
type="button" type="button"
className={`${styles.option} ${value === cat.id ? styles.optionSelected : ''}`} className={`${styles.option} ${value === cat.id ? styles.optionSelected : ''}`}
style={{ paddingLeft: `${14 + cat.depth * 16}px` }} style={{ paddingInlineStart: `${14 + cat.depth * 16}px` }}
onClick={() => selectOption(cat.id)} onClick={() => selectOption(cat.id)}
> >
<span className={styles.optionEn}>{cat.nameEn}</span> {isFa ? (
<span className={styles.optionSep}>·</span> <>
<span className={styles.optionFa}>{cat.nameFa}</span> <span className={styles.optionFa}>{cat.nameFa}</span>
{cat.nameEn ? (
<>
<span className={styles.optionSep}>·</span>
<span className={styles.optionEn}>{cat.nameEn}</span>
</>
) : null}
</>
) : (
<>
<span className={styles.optionEn}>{cat.nameEn}</span>
{cat.nameFa ? (
<>
<span className={styles.optionSep}>·</span>
<span className={styles.optionFa}>{cat.nameFa}</span>
</>
) : null}
</>
)}
</button> </button>
</li> </li>
)) ))
+9 -6
View File
@@ -201,15 +201,18 @@ export function Sidebar() {
}, []) }, [])
useEffect(() => { useEffect(() => {
navItems.forEach((item) => { const activeGroup = navItems.find(
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) { (item) => item.type === 'group' && isGroupActive(item.basePath, pathname),
setOpenGroups((prev) => ({ ...prev, [item.id]: true })) )
} if (!activeGroup || activeGroup.type !== 'group') return
}) setOpenGroups({ [activeGroup.id]: true })
}, [pathname, navItems]) }, [pathname, navItems])
function toggleGroup(id: string) { function toggleGroup(id: string) {
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] })) setOpenGroups((prev) => {
const willOpen = !prev[id]
return willOpen ? { [id]: true } : {}
})
} }
return ( return (
@@ -24,11 +24,12 @@
background: transparent; background: transparent;
border: none; border: none;
padding: 0; padding: 0;
text-align: left; text-align: start;
cursor: pointer; cursor: pointer;
} }
.clickable:hover .nameEn { .clickable:hover .nameEnPrimary,
.clickable:hover .nameFa {
color: var(--primary); color: var(--primary);
} }
@@ -72,35 +73,75 @@
} }
.festivalBadge { .festivalBadge {
left: 8px; inset-inline-start: 8px;
text-transform: uppercase; text-transform: uppercase;
color: #7c3aed; color: #7c3aed;
} }
.stockBadge { .stockBadge {
right: 8px; inset-inline-end: 8px;
color: var(--primary); color: var(--primary);
} }
.body { .body {
padding: 10px 10px 8px; padding: 10px 10px 8px;
flex: 1; flex: 1;
direction: rtl;
text-align: right;
} }
.nameEn { .bodyLtr {
direction: ltr;
text-align: left;
}
.nameFa {
font-family: var(--font-ui);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.3; line-height: 1.3;
margin-bottom: 3px; margin-bottom: 3px;
direction: rtl;
text-align: right;
unicode-bidi: plaintext;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.2s;
}
.nameEn {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
line-height: 1.3;
margin-bottom: 4px;
direction: ltr;
text-align: right;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.nameEnPrimary {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
margin-bottom: 3px;
direction: ltr;
text-align: left; text-align: left;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
transition: color 0.2s;
} }
.nameFa { .nameFaSecondary {
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -173,7 +214,6 @@
color: #ef4444; color: #ef4444;
} }
.addToCartIcon { .addToCartIcon {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
+60 -18
View File
@@ -1,5 +1,7 @@
import { useId } from 'react' import { useId } from 'react'
import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react' import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { import {
formatVariantCount, formatVariantCount,
type StoreProductListing, type StoreProductListing,
@@ -63,9 +65,21 @@ export function StoreItemCard({
onFestival, onFestival,
onRemove, onRemove,
onAddToCart, onAddToCart,
removeTooltip = 'Remove from store', removeTooltip,
}: StoreItemCardProps) { }: StoreItemCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}` const plusGradientId = `add-cart-gradient-${useId().replace(/:/g, '')}`
const removeLabel = removeTooltip ?? t('storeItems.card.remove')
const primaryName = isFa
? listing.productNameFa || listing.productTitle
: listing.productTitle
const secondaryName = isFa
? listing.productNameFa
? listing.productTitle
: ''
: listing.productNameFa
return ( return (
<article className={styles.card}> <article className={styles.card}>
@@ -74,23 +88,43 @@ export function StoreItemCard({
{listing.productImage ? ( {listing.productImage ? (
<img <img
src={listing.productImage} src={listing.productImage}
alt={listing.productTitle} alt={primaryName}
className={styles.image} className={styles.image}
loading="lazy" loading="lazy"
/> />
) : ( ) : (
<div className={styles.imagePlaceholder} /> <div className={styles.imagePlaceholder} />
)} )}
{listing.showFestival && <span className={styles.festivalBadge}>Festival</span>} {listing.showFestival && (
<span className={styles.festivalBadge}>{t('storeItems.card.festival')}</span>
)}
{listing.productTotalStock > 0 && ( {listing.productTotalStock > 0 && (
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span> <span className={styles.stockBadge}>
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
</span>
)} )}
</div> </div>
<div className={styles.body}> <div
<h3 className={styles.nameEn}>{listing.productTitle}</h3> className={`${styles.body}${isFa ? '' : ` ${styles.bodyLtr}`}`}
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>} dir={isFa ? 'rtl' : 'ltr'}
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p> >
{isFa ? (
<>
<h3 className={styles.nameFa}>{primaryName}</h3>
{secondaryName ? <p className={styles.nameEn}>{secondaryName}</p> : null}
</>
) : (
<>
<h3 className={styles.nameEnPrimary}>{primaryName}</h3>
{secondaryName ? (
<p className={styles.nameFaSecondary}>{secondaryName}</p>
) : null}
</>
)}
<p className={styles.variantLabel}>
{formatVariantCount(listing.variantCount, locale)}
</p>
<StoreItemPrice <StoreItemPrice
price={listing.displayPrice} price={listing.displayPrice}
discountedPrice={listing.displayDiscountedPrice} discountedPrice={listing.displayDiscountedPrice}
@@ -100,44 +134,52 @@ export function StoreItemCard({
<div className={styles.controls} onClick={(e) => e.stopPropagation()}> <div className={styles.controls} onClick={(e) => e.stopPropagation()}>
<div className={styles.controlsLeft}> <div className={styles.controlsLeft}>
<Tooltip label="Edit store items"> <Tooltip label={t('storeItems.card.edit')}>
<button type="button" onClick={() => onEdit(listing)} aria-label="Edit store items"> <button
type="button"
onClick={() => onEdit(listing)}
aria-label={t('storeItems.card.edit')}
>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Set discounts"> <Tooltip label={t('storeItems.card.discount')}>
<button type="button" onClick={() => onDiscount(listing)} aria-label="Set discounts"> <button
type="button"
onClick={() => onDiscount(listing)}
aria-label={t('storeItems.card.discount')}
>
<Percent size={16} /> <Percent size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Festival reward points"> <Tooltip label={t('storeItems.card.festivalPoints')}>
<button <button
type="button" type="button"
className={listing.showFestival ? styles.festivalActive : undefined} className={listing.showFestival ? styles.festivalActive : undefined}
onClick={() => onFestival(listing)} onClick={() => onFestival(listing)}
aria-label="Festival reward points" aria-label={t('storeItems.card.festivalPoints')}
> >
<Sparkles size={16} /> <Sparkles size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label={removeTooltip}> <Tooltip label={removeLabel}>
<button <button
type="button" type="button"
className={styles.danger} className={styles.danger}
onClick={() => onRemove(listing)} onClick={() => onRemove(listing)}
aria-label={removeTooltip} aria-label={removeLabel}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<Tooltip label="Add to shopping cart"> <Tooltip label={t('storeItems.card.addToCart')}>
<button <button
type="button" type="button"
className={styles.addToCartBtn} className={styles.addToCartBtn}
onClick={() => onAddToCart(listing)} onClick={() => onAddToCart(listing)}
aria-label="Add to shopping cart" aria-label={t('storeItems.card.addToCart')}
> >
<GradientPlusIcon gradientId={plusGradientId} /> <GradientPlusIcon gradientId={plusGradientId} />
</button> </button>
@@ -15,7 +15,7 @@
width: 100%; width: 100%;
padding: 8px 10px; padding: 8px 10px;
font-size: var(--field-font-size); font-size: var(--field-font-size);
text-align: left; text-align: start;
color: var(--text-primary); color: var(--text-primary);
border-radius: 8px; border-radius: 8px;
transition: background 0.15s; transition: background 0.15s;
@@ -87,6 +87,7 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: start;
} }
.optionFa { .optionFa {
@@ -95,6 +96,19 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: start;
}
.optionMeta .optionFa:first-child {
font-size: inherit;
font-weight: 500;
color: var(--text-primary);
}
.optionMeta .optionEn:not(:first-child) {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
} }
.selectedList { .selectedList {
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Search, X } from 'lucide-react' import { Search, X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import selectStyles from './SearchableSelect.module.css' import selectStyles from './SearchableSelect.module.css'
import styles from './StoreItemMultiSearchSelect.module.css' import styles from './StoreItemMultiSearchSelect.module.css'
@@ -23,16 +25,20 @@ export function StoreItemMultiSearchSelect({
options, options,
selectedIds, selectedIds,
onToggle, onToggle,
placeholder = 'Type 3+ characters to search store items', placeholder,
disabled = false, disabled = false,
minSearchLength = 3, minSearchLength = 3,
}: StoreItemMultiSearchSelectProps) { }: StoreItemMultiSearchSelectProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
const filtered = useMemo(() => { const filtered = useMemo(() => {
const trimmed = query.trim() const trimmed = query.trim()
@@ -65,10 +71,40 @@ export function StoreItemMultiSearchSelect({
if (!disabled) setOpen(true) if (!disabled) setOpen(true)
} }
function renderOptionMeta(option: StoreItemSearchOption) {
const primary = isFa ? option.nameFa || option.title : option.title
const secondary = isFa
? option.nameFa
? option.title
: ''
: option.nameFa
return (
<span className={styles.optionMeta}>
<span
className={isFa && option.nameFa ? styles.optionFa : styles.optionEn}
lang={isFa && option.nameFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
{primary}
</span>
{secondary ? (
<span
className={isFa ? styles.optionEn : `${styles.optionFa} faText`}
lang={isFa ? 'en' : 'fa'}
dir="rtl"
>
{secondary}
</span>
) : null}
</span>
)
}
const showDropdown = open && query.trim().length >= minSearchLength const showDropdown = open && query.trim().length >= minSearchLength
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper} dir={isFa ? 'rtl' : 'ltr'}>
<div className={selectStyles.wrapper} ref={containerRef}> <div className={selectStyles.wrapper} ref={containerRef}>
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}> <div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
<Search size={16} className={selectStyles.searchIcon} /> <Search size={16} className={selectStyles.searchIcon} />
@@ -76,7 +112,7 @@ export function StoreItemMultiSearchSelect({
ref={inputRef} ref={inputRef}
type="text" type="text"
className={selectStyles.input} className={selectStyles.input}
placeholder={placeholder} placeholder={searchPlaceholder}
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value) setQuery(e.target.value)
@@ -84,6 +120,7 @@ export function StoreItemMultiSearchSelect({
}} }}
onFocus={handleFocus} onFocus={handleFocus}
disabled={disabled} disabled={disabled}
dir={isFa ? 'rtl' : 'ltr'}
/> />
</div> </div>
@@ -91,7 +128,9 @@ export function StoreItemMultiSearchSelect({
<ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox"> <ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox">
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<li className={selectStyles.noResults}> <li className={selectStyles.noResults}>
{options.length === 0 ? 'No store items available.' : 'No matching store items.'} {options.length === 0
? t('storeItems.search.noProducts')
: t('storeItems.search.noMatch')}
</li> </li>
) : ( ) : (
filtered.map((option) => { filtered.map((option) => {
@@ -115,10 +154,7 @@ export function StoreItemMultiSearchSelect({
<span className={styles.thumbPlaceholder} /> <span className={styles.thumbPlaceholder} />
)} )}
</span> </span>
<span className={styles.optionMeta}> {renderOptionMeta(option)}
<span className={styles.optionEn}>{option.title}</span>
{option.nameFa && <span className={`${styles.optionFa} faText`}>{option.nameFa}</span>}
</span>
</button> </button>
</li> </li>
) )
@@ -130,7 +166,7 @@ export function StoreItemMultiSearchSelect({
{open && query.trim().length > 0 && query.trim().length < minSearchLength && ( {open && query.trim().length > 0 && query.trim().length < minSearchLength && (
<ul className={selectStyles.dropdown}> <ul className={selectStyles.dropdown}>
<li className={selectStyles.noResults}> <li className={selectStyles.noResults}>
Type at least {minSearchLength} characters to search {t('storeItems.search.minChars', { count: minSearchLength })}
</li> </li>
</ul> </ul>
)} )}
@@ -147,15 +183,12 @@ export function StoreItemMultiSearchSelect({
<span className={styles.thumbPlaceholder} /> <span className={styles.thumbPlaceholder} />
)} )}
</span> </span>
<div className={styles.optionMeta}> {renderOptionMeta(option)}
<span className={styles.optionEn}>{option.title}</span>
{option.nameFa && <span className={`${styles.optionFa} faText`}>{option.nameFa}</span>}
</div>
<button <button
type="button" type="button"
className={styles.removeSelectedBtn} className={styles.removeSelectedBtn}
onClick={() => onToggle(option.id)} onClick={() => onToggle(option.id)}
aria-label={`Remove ${option.title}`} aria-label={t('storeItems.search.clear')}
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -22,6 +22,12 @@
gap: 4px; gap: 4px;
} }
.navPair {
display: flex;
align-items: center;
gap: 4px;
}
.navBtn { .navBtn {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -1,5 +1,7 @@
import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
import { useRef } from 'react' import { useRef } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { StoreSpecial } from '../types/storeSpecial' import type { StoreSpecial } from '../types/storeSpecial'
import { specialItemsToListings } from '../utils/storeSpecialListings' import { specialItemsToListings } from '../utils/storeSpecialListings'
import type { StoreProductListing } from '../utils/storeProductGroups' import type { StoreProductListing } from '../utils/storeProductGroups'
@@ -33,6 +35,9 @@ export function StoreSpecialCarousel({
onRemoveListing, onRemoveListing,
onAddToCart, onAddToCart,
}: StoreSpecialCarouselProps) { }: StoreSpecialCarouselProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const trackRef = useRef<HTMLDivElement>(null) const trackRef = useRef<HTMLDivElement>(null)
const listings = specialItemsToListings(special.items) const listings = specialItemsToListings(special.items)
@@ -43,85 +48,101 @@ export function StoreSpecialCarousel({
track.scrollBy({ left: direction * amount, behavior: 'smooth' }) track.scrollBy({ left: direction * amount, behavior: 'smooth' })
} }
const addCard = (
<div className={styles.cardSlot}>
<Tooltip label={t('website.specialItems.addItemsTo', { title: special.title })}>
<button
type="button"
className={styles.addCard}
onClick={() => onAddItems(special)}
aria-label={t('website.specialItems.addItemsTo', { title: special.title })}
>
<span className={styles.addIcon}>
<Plus size={28} />
</span>
</button>
</Tooltip>
</div>
)
const itemCards = listings.map((listing) => (
<div key={listing.storeItemId} className={styles.cardSlot}>
<StoreItemCard
listing={listing}
onOpen={onOpenListing}
onEdit={onEditListing}
onDiscount={onDiscountListing}
onFestival={onFestivalListing}
onRemove={() => onRemoveListing(special, listing)}
onAddToCart={onAddToCart}
removeTooltip={t('website.specialItems.removeFromSpecial')}
/>
</div>
))
return ( return (
<section className={styles.section}> <section className={styles.section}>
<div className={styles.header}> <div className={styles.header}>
<h3 className={styles.title}>{special.title}</h3> <h3 className={styles.title}>{special.title}</h3>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<Tooltip label="Edit category"> <Tooltip label={t('website.specialItems.editCategory')}>
<button <button
type="button" type="button"
className={controlStyles.controlBtn} className={controlStyles.controlBtn}
onClick={() => onEditSpecial(special)} onClick={() => onEditSpecial(special)}
aria-label="Edit category" aria-label={t('website.specialItems.editCategory')}
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Delete category"> <Tooltip label={t('website.specialItems.deleteCategory')}>
<button <button
type="button" type="button"
className={`${controlStyles.controlBtn} ${controlStyles.danger}`} className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
onClick={() => onDeleteSpecial(special)} onClick={() => onDeleteSpecial(special)}
aria-label="Delete category" aria-label={t('website.specialItems.deleteCategory')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Scroll left"> <div className={styles.navPair} dir="ltr">
<button <Tooltip label={t('website.specialItems.scrollLeft')}>
type="button" <button
className={styles.navBtn} type="button"
onClick={() => scrollBy(-1)} className={styles.navBtn}
aria-label="Scroll left" onClick={() => scrollBy(-1)}
> aria-label={t('website.specialItems.scrollLeft')}
<ChevronLeft size={18} /> >
</button> <ChevronLeft size={18} />
</Tooltip> </button>
<Tooltip label="Scroll right"> </Tooltip>
<button <Tooltip label={t('website.specialItems.scrollRight')}>
type="button" <button
className={styles.navBtn} type="button"
onClick={() => scrollBy(1)} className={styles.navBtn}
aria-label="Scroll right" onClick={() => scrollBy(1)}
> aria-label={t('website.specialItems.scrollRight')}
<ChevronRight size={18} /> >
</button> <ChevronRight size={18} />
</Tooltip> </button>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div className={styles.trackWrap}> <div className={styles.trackWrap}>
<div className={styles.track} ref={trackRef}> <div className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.cardSlot}> {isFa ? (
<Tooltip label={`Add store items to ${special.title}`}> <>
<button {addCard}
type="button" {itemCards}
className={styles.addCard} </>
onClick={() => onAddItems(special)} ) : (
aria-label={`Add store items to ${special.title}`} <>
> {itemCards}
<span className={styles.addIcon}> {addCard}
<Plus size={28} /> </>
</span> )}
</button>
</Tooltip>
</div>
{listings.map((listing) => (
<div key={listing.storeItemId} className={styles.cardSlot}>
<StoreItemCard
listing={listing}
onOpen={onOpenListing}
onEdit={onEditListing}
onDiscount={onDiscountListing}
onFestival={onFestivalListing}
onRemove={() => onRemoveListing(special, listing)}
onAddToCart={onAddToCart}
removeTooltip="Remove from special"
/>
</div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -1,41 +1,54 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './CategoryModal.module.css' import styles from './CategoryModal.module.css'
export interface StoreSpecialModalValues {
key: string
title: string
}
interface StoreSpecialModalProps { interface StoreSpecialModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSubmit: (title: string) => void onSubmit: (values: StoreSpecialModalValues) => void
initialKey?: string
initialTitle?: string initialTitle?: string
title?: string title?: string
submitLabel?: string submitLabel?: string
fieldLabel?: string
isSubmitting?: boolean isSubmitting?: boolean
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
export function StoreSpecialModal({ export function StoreSpecialModal({
open, open,
onClose, onClose,
onSubmit, onSubmit,
initialKey = '',
initialTitle = '', initialTitle = '',
title = 'Add Special Category', title,
submitLabel = 'Create', submitLabel,
fieldLabel = 'Category title',
isSubmitting = false, isSubmitting = false,
}: StoreSpecialModalProps) { }: StoreSpecialModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const formRef = useRef<HTMLFormElement>(null) const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
const [categoryTitle, setCategoryTitle] = useState(initialTitle) const [groupKey, setGroupKey] = useState(initialKey)
const [groupTitle, setGroupTitle] = useState(initialTitle)
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setMounted(true) setMounted(true)
setClosing(false) setClosing(false)
setCategoryTitle(initialTitle) setGroupKey(initialKey)
setGroupTitle(initialTitle)
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -44,7 +57,7 @@ export function StoreSpecialModal({
}, ANIMATION_MS) }, ANIMATION_MS)
return () => clearTimeout(timer) return () => clearTimeout(timer)
} }
}, [open, initialTitle, mounted]) }, [open, initialKey, initialTitle, mounted])
useEffect(() => { useEffect(() => {
if (!mounted || closing) return if (!mounted || closing) return
@@ -57,11 +70,15 @@ export function StoreSpecialModal({
if (!mounted) return null if (!mounted) return null
const trimmedKey = groupKey.trim()
const trimmedTitle = groupTitle.trim()
const keyValid = KEY_PATTERN.test(trimmedKey)
const canSubmit = keyValid && trimmedTitle.length > 0
function handleSubmit(e: React.FormEvent<HTMLFormElement>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault() e.preventDefault()
const trimmed = categoryTitle.trim() if (!canSubmit) return
if (!trimmed) return onSubmit({ key: trimmedKey, title: trimmedTitle })
onSubmit(trimmed)
} }
return createPortal( return createPortal(
@@ -75,39 +92,69 @@ export function StoreSpecialModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="special-modal-title" aria-labelledby="special-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
> >
<div className={styles.header}> <div className={styles.header}>
<h2 id="special-modal-title" className={styles.title}> <h2 id="special-modal-title" className={styles.title}>
{title} {title ?? t('website.specialItems.createTitle')}
</h2> </h2>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}> <form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="special-category-title">{fieldLabel}</label> <label htmlFor="special-group-key">{t('website.groupKey')}</label>
<input <input
id="special-category-title" id="special-group-key"
value={categoryTitle} value={groupKey}
onChange={(e) => setCategoryTitle(e.target.value)} onChange={(e) => setGroupKey(e.target.value)}
placeholder="e.g. Special Sale, Our Best Sellers" placeholder={t('website.groupKeyPlaceholder')}
autoFocus autoFocus
disabled={isSubmitting} disabled={isSubmitting}
dir="ltr"
autoComplete="off"
spellCheck={false}
/>
<p className={styles.hint}>{t('website.groupKeyHint')}</p>
</div>
<div className={styles.field}>
<label htmlFor="special-group-title">{t('website.groupValue')}</label>
<input
id="special-group-title"
value={groupTitle}
onChange={(e) => setGroupTitle(e.target.value)}
placeholder={t('website.groupValuePlaceholder')}
disabled={isSubmitting}
dir={isFa ? 'rtl' : 'ltr'}
/> />
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}> <button
Cancel type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('products.form.cancel')}
</button> </button>
<button <button
type="submit" type="submit"
className={styles.submitBtn} className={styles.submitBtn}
disabled={isSubmitting || !categoryTitle.trim()} disabled={isSubmitting || !canSubmit}
> >
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting
? t('website.saving')
: (submitLabel ?? t('website.create'))}
</button> </button>
</div> </div>
</form> </form>
@@ -9,6 +9,7 @@
left: 50%; left: 50%;
transform: translateX(-50%) translateY(4px); transform: translateX(-50%) translateY(4px);
padding: 6px 10px; padding: 6px 10px;
font-family: var(--font-ui);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
line-height: 1.3; line-height: 1.3;
@@ -22,6 +22,12 @@
gap: 4px; gap: 4px;
} }
.navPair {
display: flex;
align-items: center;
gap: 4px;
}
.navBtn { .navBtn {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -1,5 +1,7 @@
import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
import { useRef, type ReactNode } from 'react' import { useRef, type ReactNode } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import controlStyles from './CategoryRow.module.css' import controlStyles from './CategoryRow.module.css'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './WebsiteGroupCarousel.module.css' import styles from './WebsiteGroupCarousel.module.css'
@@ -26,10 +28,15 @@ export function WebsiteGroupCarousel<T>({
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
addTooltip, addTooltip,
editTooltip = 'Edit group', editTooltip,
deleteTooltip = 'Delete group', deleteTooltip,
}: WebsiteGroupCarouselProps<T>) { }: WebsiteGroupCarouselProps<T>) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const trackRef = useRef<HTMLDivElement>(null) const trackRef = useRef<HTMLDivElement>(null)
const editLabel = editTooltip ?? t('website.editGroup')
const deleteLabel = deleteTooltip ?? t('website.deleteGroup')
function scrollBy(direction: -1 | 1) { function scrollBy(direction: -1 | 1) {
const track = trackRef.current const track = trackRef.current
@@ -38,76 +45,92 @@ export function WebsiteGroupCarousel<T>({
track.scrollBy({ left: direction * amount, behavior: 'smooth' }) track.scrollBy({ left: direction * amount, behavior: 'smooth' })
} }
const addCard = (
<div className={styles.cardSlot}>
<Tooltip label={addTooltip}>
<button
type="button"
className={styles.addCard}
onClick={onAddItems}
aria-label={addTooltip}
>
<span className={styles.addIcon}>
<Plus size={28} />
</span>
</button>
</Tooltip>
</div>
)
const itemCards = items.map((item) => (
<div key={itemKey(item)} className={styles.cardSlot}>
{renderItem(item)}
</div>
))
return ( return (
<section className={styles.section}> <section className={styles.section}>
<div className={styles.header}> <div className={styles.header}>
<h3 className={styles.title}>{title}</h3> <h3 className={styles.title}>{title}</h3>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<Tooltip label={editTooltip}> <Tooltip label={editLabel}>
<button <button
type="button" type="button"
className={controlStyles.controlBtn} className={controlStyles.controlBtn}
onClick={onEditGroup} onClick={onEditGroup}
aria-label={editTooltip} aria-label={editLabel}
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label={deleteTooltip}> <Tooltip label={deleteLabel}>
<button <button
type="button" type="button"
className={`${controlStyles.controlBtn} ${controlStyles.danger}`} className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
onClick={onDeleteGroup} onClick={onDeleteGroup}
aria-label={deleteTooltip} aria-label={deleteLabel}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Scroll left"> <div className={styles.navPair} dir="ltr">
<button <Tooltip label={t('website.scrollLeft')}>
type="button" <button
className={styles.navBtn} type="button"
onClick={() => scrollBy(-1)} className={styles.navBtn}
aria-label="Scroll left" onClick={() => scrollBy(-1)}
> aria-label={t('website.scrollLeft')}
<ChevronLeft size={18} /> >
</button> <ChevronLeft size={18} />
</Tooltip> </button>
<Tooltip label="Scroll right"> </Tooltip>
<button <Tooltip label={t('website.scrollRight')}>
type="button" <button
className={styles.navBtn} type="button"
onClick={() => scrollBy(1)} className={styles.navBtn}
aria-label="Scroll right" onClick={() => scrollBy(1)}
> aria-label={t('website.scrollRight')}
<ChevronRight size={18} /> >
</button> <ChevronRight size={18} />
</Tooltip> </button>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div className={styles.trackWrap}> <div className={styles.trackWrap}>
<div className={styles.track} ref={trackRef}> <div className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.cardSlot}> {isFa ? (
<Tooltip label={addTooltip}> <>
<button {addCard}
type="button" {itemCards}
className={styles.addCard} </>
onClick={onAddItems} ) : (
aria-label={addTooltip} <>
> {itemCards}
<span className={styles.addIcon}> {addCard}
<Plus size={28} /> </>
</span> )}
</button>
</Tooltip>
</div>
{items.map((item) => (
<div key={itemKey(item)} className={styles.cardSlot}>
{renderItem(item)}
</div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -22,22 +22,38 @@
min-width: 0; min-width: 0;
} }
.nameEn { .namePrimary {
margin: 0;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
line-height: 1.35; line-height: 1.35;
text-align: start;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.nameFa { .namePrimary[dir='rtl'] {
margin-top: 4px; text-align: right;
font-family: var(--font-ui); }
font-size: 13px;
.nameSecondary {
margin: 4px 0 0;
font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
text-align: left; line-height: 1.35;
line-height: 1.4; text-align: start;
direction: rtl; display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.nameSecondary[dir='rtl'] {
text-align: right;
} }
.subtitle { .subtitle {
@@ -45,12 +61,17 @@
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.4; line-height: 1.4;
text-align: start;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.card[dir='rtl'] .subtitle {
text-align: right;
}
.controls { .controls {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1,4 +1,6 @@
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './WebsiteGroupItemCard.module.css' import styles from './WebsiteGroupItemCard.module.css'
@@ -15,23 +17,55 @@ export function WebsiteGroupItemCard({
nameFa, nameFa,
subtitle, subtitle,
onRemove, onRemove,
removeTooltip = 'Remove from group', removeTooltip,
}: WebsiteGroupItemCardProps) { }: WebsiteGroupItemCardProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const removeLabel = removeTooltip ?? t('website.removeFromGroup')
const primaryName = isFa ? nameFa?.trim() || title : title
const secondaryName = isFa
? nameFa?.trim()
? title
: ''
: nameFa?.trim() || ''
return ( return (
<article className={styles.card}> <article className={styles.card} dir={isFa ? 'rtl' : 'ltr'}>
<div className={styles.text}> <div className={styles.text}>
<h3 className={styles.nameEn}>{title}</h3> {isFa ? (
{nameFa && <p className={styles.nameFa}>{nameFa}</p>} <>
{subtitle && <p className={styles.subtitle}>{subtitle}</p>} <h3 className={`${styles.namePrimary} faText`} lang="fa" dir="rtl">
{primaryName}
</h3>
{secondaryName ? (
<p className={styles.nameSecondary} lang="en" dir="rtl">
{secondaryName}
</p>
) : null}
</>
) : (
<>
<h3 className={styles.namePrimary} lang="en" dir="ltr">
{primaryName}
</h3>
{secondaryName ? (
<p className={`${styles.nameSecondary} faText`} lang="fa" dir="rtl">
{secondaryName}
</p>
) : null}
</>
)}
{subtitle ? <p className={styles.subtitle}>{subtitle}</p> : null}
</div> </div>
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label={removeTooltip}> <Tooltip label={removeLabel}>
<button <button
type="button" type="button"
className={styles.danger} className={styles.danger}
onClick={onRemove} onClick={onRemove}
aria-label={removeTooltip} aria-label={removeLabel}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
@@ -5,12 +5,12 @@
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-start;
gap: 12px; gap: 12px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.headerActions { .navPair {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
@@ -1,5 +1,7 @@
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { useRef } from 'react' import { useRef } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { WebsiteSliderSlide } from '../types/websiteSlider' import type { WebsiteSliderSlide } from '../types/websiteSlider'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import { WebsiteSliderSlideCard } from './WebsiteSliderSlideCard' import { WebsiteSliderSlideCard } from './WebsiteSliderSlideCard'
@@ -16,6 +18,9 @@ export function WebsiteSliderGallery({
onAddSlide, onAddSlide,
onRemoveSlide, onRemoveSlide,
}: WebsiteSliderGalleryProps) { }: WebsiteSliderGalleryProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const trackRef = useRef<HTMLDivElement>(null) const trackRef = useRef<HTMLDivElement>(null)
const showNav = slides.length > 0 const showNav = slides.length > 0
@@ -26,27 +31,50 @@ export function WebsiteSliderGallery({
track.scrollBy({ left: direction * amount, behavior: 'smooth' }) track.scrollBy({ left: direction * amount, behavior: 'smooth' })
} }
const addCard = (
<div className={`${styles.slideSlot} ${styles.slideSlotAdd}`}>
<Tooltip label={t('website.sliders.addSlide')}>
<button
type="button"
className={styles.addCard}
onClick={onAddSlide}
aria-label={t('website.sliders.addSlide')}
>
<span className={styles.addIcon}>
<Plus size={28} />
</span>
</button>
</Tooltip>
</div>
)
const slideCards = slides.map((slide) => (
<div key={slide.id} className={styles.slideSlot}>
<WebsiteSliderSlideCard slide={slide} onRemove={() => onRemoveSlide(slide)} />
</div>
))
return ( return (
<section className={styles.section}> <section className={styles.section}>
{showNav && ( {showNav && (
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerActions}> <div className={styles.navPair} dir="ltr">
<Tooltip label="Scroll left"> <Tooltip label={t('website.scrollLeft')}>
<button <button
type="button" type="button"
className={styles.navBtn} className={styles.navBtn}
onClick={() => scrollBy(-1)} onClick={() => scrollBy(-1)}
aria-label="Scroll left" aria-label={t('website.scrollLeft')}
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Scroll right"> <Tooltip label={t('website.scrollRight')}>
<button <button
type="button" type="button"
className={styles.navBtn} className={styles.navBtn}
onClick={() => scrollBy(1)} onClick={() => scrollBy(1)}
aria-label="Scroll right" aria-label={t('website.scrollRight')}
> >
<ChevronRight size={18} /> <ChevronRight size={18} />
</button> </button>
@@ -56,27 +84,9 @@ export function WebsiteSliderGallery({
)} )}
<div className={styles.trackWrap}> <div className={styles.trackWrap}>
<div className={styles.track} ref={trackRef}> <div className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
<div className={`${styles.slideSlot} ${styles.slideSlotAdd}`}> {slideCards}
<Tooltip label="Add slide"> {addCard}
<button
type="button"
className={styles.addCard}
onClick={onAddSlide}
aria-label="Add slide"
>
<span className={styles.addIcon}>
<Plus size={28} />
</span>
</button>
</Tooltip>
</div>
{slides.map((slide) => (
<div key={slide.id} className={styles.slideSlot}>
<WebsiteSliderSlideCard slide={slide} onRemove={() => onRemoveSlide(slide)} />
</div>
))}
</div> </div>
</div> </div>
</section> </section>
@@ -1,4 +1,5 @@
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { WebsiteSliderSlide } from '../types/websiteSlider' import type { WebsiteSliderSlide } from '../types/websiteSlider'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './WebsiteSliderSlideCard.module.css' import styles from './WebsiteSliderSlideCard.module.css'
@@ -9,20 +10,27 @@ interface WebsiteSliderSlideCardProps {
} }
export function WebsiteSliderSlideCard({ slide, onRemove }: WebsiteSliderSlideCardProps) { export function WebsiteSliderSlideCard({ slide, onRemove }: WebsiteSliderSlideCardProps) {
const t = useT()
return ( return (
<article className={styles.card}> <article className={styles.card}>
<div className={styles.imageWrap}> <div className={styles.imageWrap}>
<img src={slide.imageUrl} alt={slide.title ?? 'Slider slide'} className={styles.image} loading="lazy" /> <img
src={slide.imageUrl}
alt={slide.title ?? t('website.sliders.slideAlt')}
className={styles.image}
loading="lazy"
/>
</div> </div>
<div className={styles.footer}> <div className={styles.footer}>
<p className={styles.title}>{slide.title?.trim() || 'Untitled slide'}</p> <p className={styles.title}>{slide.title?.trim() || t('website.sliders.untitled')}</p>
<Tooltip label="Remove slide"> <Tooltip label={t('website.sliders.removeSlide')}>
<button <button
type="button" type="button"
className={styles.removeBtn} className={styles.removeBtn}
onClick={onRemove} onClick={onRemove}
aria-label="Remove slide" aria-label={t('website.sliders.removeSlide')}
> >
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
+2 -2
View File
@@ -1,5 +1,5 @@
export function formatCommentDate(iso: string): string { export function formatCommentDate(iso: string, locale: 'en' | 'fa' = 'en'): string {
return new Date(iso).toLocaleString('en-US', { return new Date(iso).toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
File diff suppressed because it is too large Load Diff
+35 -25
View File
@@ -6,6 +6,7 @@ import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
listBlogCategories, listBlogCategories,
@@ -32,6 +33,7 @@ export function AddNewBlogPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
@@ -61,7 +63,7 @@ export function AddNewBlogPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('blog.categories.errorLoad'))
} }
} }
} }
@@ -131,10 +133,10 @@ export function AddNewBlogPage() {
categoryId: categoryId || null, categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId, featuredMediaId: nextFeaturedMediaId,
}) })
showToast('Blog post updated.', 'success') showToast(t('blog.form.toast.updated'), 'success')
} else { } else {
await createBlog(payload) await createBlog(payload)
showToast('Blog post created.', 'success') showToast(t('blog.form.toast.created'), 'success')
} }
navigate('/blog/list') navigate('/blog/list')
@@ -144,7 +146,7 @@ export function AddNewBlogPage() {
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save blog post.') setError(t('blog.form.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -172,12 +174,10 @@ export function AddNewBlogPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}> <h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Blog' : 'Add New Blog'} {isEdit ? t('title.editBlog') : t('title.addBlog')}
</h2> </h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{isEdit {isEdit ? t('blog.form.edit.subtitle') : t('blog.form.add.subtitle')}
? 'Update blog post details and save changes.'
: 'Create and publish a new blog post.'}
</p> </p>
</div> </div>
</div> </div>
@@ -191,19 +191,19 @@ export function AddNewBlogPage() {
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}> <div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
<label>Title Image</label> <label>{t('blog.form.titleImage')}</label>
<ImageCropper <ImageCropper
value={titleImage} value={titleImage}
onChange={setTitleImage} onChange={setTitleImage}
aspect={3 / 2} aspect={3 / 2}
uploadLabel="Upload title image" uploadLabel={t('blog.form.uploadTitleImage')}
hint="Click to select, then crop" hint={t('blog.form.cropHint')}
changeLabel="Change title image" changeLabel={t('blog.form.changeTitleImage')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.col3}`}> <div className={`${styles.field} ${styles.col3}`}>
<label htmlFor="blog-type">Type</label> <label htmlFor="blog-type">{t('blog.form.type')}</label>
<select <select
id="blog-type" id="blog-type"
value={type} value={type}
@@ -212,29 +212,35 @@ export function AddNewBlogPage() {
> >
{BLOG_TYPE_OPTIONS.map((option) => ( {BLOG_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <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> </option>
))} ))}
</select> </select>
</div> </div>
<div className={`${styles.field} ${styles.col6Span}`}> <div className={`${styles.field} ${styles.col6Span}`}>
<label>Category</label> <label>{t('blog.form.category')}</label>
<SearchableSelect <SearchableSelect
options={categoryOptions} options={categoryOptions}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder="Search and select category..." placeholder={t('blog.form.categoryPlaceholder')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.col9}`}> <div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="blog-title">Title</label> <label htmlFor="blog-title">{t('blog.form.title')}</label>
<input <input
id="blog-title" id="blog-title"
name="title" name="title"
type="text" type="text"
placeholder="Blog post title" placeholder={t('blog.form.titlePlaceholder')}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
required required
@@ -243,12 +249,12 @@ export function AddNewBlogPage() {
</div> </div>
<div className={`${styles.field} ${styles.col9}`}> <div className={`${styles.field} ${styles.col9}`}>
<label htmlFor="blog-abstract">Abstract</label> <label htmlFor="blog-abstract">{t('blog.form.abstract')}</label>
<textarea <textarea
id="blog-abstract" id="blog-abstract"
name="abstract" name="abstract"
rows={4} rows={4}
placeholder="Short summary shown in blog listings" placeholder={t('blog.form.abstractPlaceholder')}
value={abstract} value={abstract}
onChange={(e) => setAbstract(e.target.value)} onChange={(e) => setAbstract(e.target.value)}
required required
@@ -257,28 +263,32 @@ export function AddNewBlogPage() {
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Main Text</label> <label>{t('blog.form.mainText')}</label>
<RichTextEditor <RichTextEditor
value={mainTextHtml} value={mainTextHtml}
onChange={setMainTextHtml} onChange={setMainTextHtml}
placeholder="Full blog content with formatting and images..." placeholder={t('blog.form.mainTextPlaceholder')}
allowImages allowImages
editorMinHeight={320} editorMinHeight={320}
/> />
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label> <label>{t('blog.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<Link to="/blog/list" className={styles.cancelBtn}> <Link to="/blog/list" className={styles.cancelBtn}>
Cancel {t('blog.form.cancel')}
</Link> </Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <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> </button>
</div> </div>
</form> </form>
+40 -33
View File
@@ -7,6 +7,7 @@ import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
listPortfolioCategories, listPortfolioCategories,
@@ -32,19 +33,19 @@ type TitleImageAspectId = 'square' | '3:2' | '16:9' | '9:16'
const TITLE_IMAGE_ASPECTS: { const TITLE_IMAGE_ASPECTS: {
id: TitleImageAspectId id: TitleImageAspectId
label: string
aspect: number aspect: number
}[] = [ }[] = [
{ id: 'square', label: 'Square', aspect: 1 }, { id: 'square', aspect: 1 },
{ id: '3:2', label: '3:2', aspect: 3 / 2 }, { id: '3:2', aspect: 3 / 2 },
{ id: '16:9', label: '16:9', aspect: 16 / 9 }, { id: '16:9', aspect: 16 / 9 },
{ id: '9:16', label: '9:16 (Reel)', aspect: 9 / 16 }, { id: '9:16', aspect: 9 / 16 },
] ]
export function AddNewPortfolioPage() { export function AddNewPortfolioPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
@@ -79,7 +80,7 @@ export function AddNewPortfolioPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('portfolio.categories.errorLoad'))
} }
} }
} }
@@ -159,10 +160,10 @@ export function AddNewPortfolioPage() {
categoryId: categoryId || null, categoryId: categoryId || null,
featuredMediaId: nextFeaturedMediaId, featuredMediaId: nextFeaturedMediaId,
}) })
showToast('Portfolio updated.', 'success') showToast(t('portfolio.form.toast.updated'), 'success')
} else { } else {
await createPortfolio(payload) await createPortfolio(payload)
showToast('Portfolio created.', 'success') showToast(t('portfolio.form.toast.created'), 'success')
} }
navigate('/portfolios/list') navigate('/portfolios/list')
@@ -172,7 +173,7 @@ export function AddNewPortfolioPage() {
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save portfolio.') setError(t('portfolio.form.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -200,12 +201,10 @@ export function AddNewPortfolioPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}> <h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit Portfolio' : 'Add New Portfolio'} {isEdit ? t('title.editPortfolio') : t('title.addPortfolio')}
</h2> </h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{isEdit {isEdit ? t('portfolio.form.edit.subtitle') : t('portfolio.form.add.subtitle')}
? 'Update portfolio details and save changes.'
: 'Create and publish a new portfolio project.'}
</p> </p>
</div> </div>
</div> </div>
@@ -219,11 +218,11 @@ export function AddNewPortfolioPage() {
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}> <div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
<label>Title Image</label> <label>{t('portfolio.form.titleImage')}</label>
<div <div
className={styles.aspectOptions} className={styles.aspectOptions}
role="radiogroup" role="radiogroup"
aria-label="Title image aspect ratio" aria-label={t('portfolio.form.aspectAria')}
> >
{TITLE_IMAGE_ASPECTS.map((option) => ( {TITLE_IMAGE_ASPECTS.map((option) => (
<button <button
@@ -245,7 +244,11 @@ export function AddNewPortfolioPage() {
}} }}
disabled={isSubmitting} disabled={isSubmitting}
> >
{option.label} {option.id === 'square'
? t('portfolio.form.aspect.square')
: option.id === '9:16'
? t('portfolio.form.aspect.reel')
: option.id}
</button> </button>
))} ))}
</div> </div>
@@ -253,26 +256,26 @@ export function AddNewPortfolioPage() {
value={titleImage} value={titleImage}
onChange={setTitleImage} onChange={setTitleImage}
aspect={selectedAspect} aspect={selectedAspect}
uploadLabel="Upload title image" uploadLabel={t('portfolio.form.uploadTitleImage')}
hint="Click to select, then crop" hint={t('portfolio.form.cropHint')}
changeLabel="Change title image" changeLabel={t('portfolio.form.changeTitleImage')}
/> />
</div> </div>
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={styles.field}> <div className={styles.field}>
<label>Category</label> <label>{t('portfolio.form.category')}</label>
<SearchableSelect <SearchableSelect
options={categoryOptions} options={categoryOptions}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder="Search and select category..." placeholder={t('portfolio.form.categoryPlaceholder')}
/> />
</div> </div>
<div className={styles.titleRow}> <div className={styles.titleRow}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="portfolio-title-fa">Title (FA)</label> <label htmlFor="portfolio-title-fa">{t('portfolio.form.titleFa')}</label>
<input <input
id="portfolio-title-fa" id="portfolio-title-fa"
name="titleFa" name="titleFa"
@@ -280,7 +283,7 @@ export function AddNewPortfolioPage() {
dir="rtl" dir="rtl"
lang="fa" lang="fa"
className="faText" className="faText"
placeholder="عنوان فارسی" placeholder={t('portfolio.form.titleFaPlaceholder')}
value={titleFa} value={titleFa}
onChange={(e) => setTitleFa(e.target.value)} onChange={(e) => setTitleFa(e.target.value)}
required required
@@ -289,13 +292,13 @@ export function AddNewPortfolioPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="portfolio-title-en">Title (EN)</label> <label htmlFor="portfolio-title-en">{t('portfolio.form.titleEn')}</label>
<input <input
id="portfolio-title-en" id="portfolio-title-en"
name="titleEn" name="titleEn"
type="text" type="text"
dir="ltr" dir="ltr"
placeholder="English title" placeholder={t('portfolio.form.titleEnPlaceholder')}
value={titleEn} value={titleEn}
onChange={(e) => setTitleEn(e.target.value)} onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting} disabled={isSubmitting}
@@ -304,12 +307,12 @@ export function AddNewPortfolioPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="portfolio-abstract">Abstract</label> <label htmlFor="portfolio-abstract">{t('portfolio.form.abstract')}</label>
<textarea <textarea
id="portfolio-abstract" id="portfolio-abstract"
name="abstract" name="abstract"
rows={4} rows={4}
placeholder="Short summary shown in portfolio listings" placeholder={t('portfolio.form.abstractPlaceholder')}
value={abstract} value={abstract}
onChange={(e) => setAbstract(e.target.value)} onChange={(e) => setAbstract(e.target.value)}
required required
@@ -318,11 +321,11 @@ export function AddNewPortfolioPage() {
</div> </div>
<div className={styles.field}> <div className={styles.field}>
<label>Main Text</label> <label>{t('portfolio.form.mainText')}</label>
<RichTextEditor <RichTextEditor
value={mainTextHtml} value={mainTextHtml}
onChange={setMainTextHtml} onChange={setMainTextHtml}
placeholder="Full portfolio content with formatting and images..." placeholder={t('portfolio.form.mainTextPlaceholder')}
allowImages allowImages
editorMinHeight={280} editorMinHeight={280}
/> />
@@ -330,22 +333,26 @@ export function AddNewPortfolioPage() {
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Image Gallery</label> <label>{t('portfolio.form.gallery')}</label>
<ImageUploader images={images} onChange={setImages} /> <ImageUploader images={images} onChange={setImages} />
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label> <label>{t('portfolio.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<Link to="/portfolios/list" className={styles.cancelBtn}> <Link to="/portfolios/list" className={styles.cancelBtn}>
Cancel {t('portfolio.form.cancel')}
</Link> </Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <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> </button>
</div> </div>
</form> </form>
+30 -21
View File
@@ -66,14 +66,14 @@ export function AddNewProductPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load form options.') setError(t('products.form.errorOptions'))
} }
} }
} }
void loadFormOptions() void loadFormOptions()
return () => controller.abort() return () => controller.abort()
}, []) }, [t])
useEffect(() => { useEffect(() => {
if (!isEdit || !id) return if (!isEdit || !id) return
@@ -157,7 +157,7 @@ export function AddNewProductPage() {
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save product.') setError(t('products.form.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -202,43 +202,44 @@ export function AddNewProductPage() {
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}> <div className={`${styles.field} ${styles.col2} ${styles.thumbnailField}`}>
<label>Thumbnail Image</label> <label>{t('products.form.thumbnail')}</label>
<ImageCropper value={thumbnail} onChange={setThumbnail} /> <ImageCropper value={thumbnail} onChange={setThumbnail} />
</div> </div>
<div className={styles.col10}> <div className={styles.col10}>
<div className={styles.topFields}> <div className={styles.topFields}>
<div className={`${styles.field} ${styles.fieldCategory}`}> <div className={`${styles.field} ${styles.fieldCategory}`}>
<label>Category</label> <label>{t('products.form.category')}</label>
<SearchableSelect <SearchableSelect
options={categoryOptions} options={categoryOptions}
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder="Search and select category..." placeholder={t('products.form.categoryPlaceholder')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.fieldBrand}`}> <div className={`${styles.field} ${styles.fieldBrand}`}>
<label> <label>
Brand <span className={styles.optional}>(optional)</span> {t('products.form.brand')}{' '}
<span className={styles.optional}>{t('products.form.optional')}</span>
</label> </label>
<SearchableSelect <SearchableSelect
options={brandOptions} options={brandOptions}
value={brandId} value={brandId}
onChange={setBrandId} onChange={setBrandId}
placeholder="Search and select brand..." placeholder={t('products.form.brandPlaceholder')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.fieldHalf}`}> <div className={`${styles.field} ${styles.fieldHalf}`}>
<label htmlFor="nameFa">Name (FA)</label> <label htmlFor="nameFa">{t('products.form.nameFa')}</label>
<input <input
id="nameFa" id="nameFa"
name="nameFa" name="nameFa"
type="text" type="text"
dir="rtl" dir="rtl"
className="faText" className="faText"
placeholder="نام محصول" placeholder={t('products.form.nameFaPlaceholder')}
value={nameFa} value={nameFa}
onChange={(e) => setNameFa(e.target.value)} onChange={(e) => setNameFa(e.target.value)}
required required
@@ -247,13 +248,13 @@ export function AddNewProductPage() {
</div> </div>
<div className={`${styles.field} ${styles.fieldHalf}`}> <div className={`${styles.field} ${styles.fieldHalf}`}>
<label htmlFor="nameEn">Name (EN)</label> <label htmlFor="nameEn">{t('products.form.nameEn')}</label>
<input <input
id="nameEn" id="nameEn"
name="nameEn" name="nameEn"
type="text" type="text"
dir="ltr" dir="ltr"
placeholder="Product name" placeholder={t('products.form.nameEnPlaceholder')}
value={nameEn} value={nameEn}
onChange={(e) => setNameEn(e.target.value)} onChange={(e) => setNameEn(e.target.value)}
required required
@@ -262,12 +263,12 @@ export function AddNewProductPage() {
</div> </div>
<div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}> <div className={`${styles.field} ${styles.fieldFull} ${styles.summaryField}`}>
<label htmlFor="summary">Summary</label> <label htmlFor="summary">{t('products.form.summary')}</label>
<textarea <textarea
id="summary" id="summary"
name="summary" name="summary"
rows={4} rows={4}
placeholder="Short summary shown in product listings" placeholder={t('products.form.summaryPlaceholder')}
value={summary} value={summary}
onChange={(e) => setSummary(e.target.value)} onChange={(e) => setSummary(e.target.value)}
required required
@@ -278,31 +279,39 @@ export function AddNewProductPage() {
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Description</label> <label>{t('products.form.description')}</label>
<RichTextEditor <RichTextEditor
value={description} value={description}
onChange={setDescription} onChange={setDescription}
placeholder="Full product description with formatting..." placeholder={t('products.form.descriptionPlaceholder')}
/> />
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Product Images</label> <label>{t('products.form.images')}</label>
<ImageUploader images={images} onChange={setImages} /> <ImageUploader images={images} onChange={setImages} />
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>Tags</label> <label>{t('products.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput
tags={tags}
onChange={setTags}
placeholder={t('products.form.tagsPlaceholder')}
/>
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
<Link to="/products/list" className={styles.cancelBtn}> <Link to="/products/list" className={styles.cancelBtn}>
Cancel {t('products.form.cancel')}
</Link> </Link>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}> <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> </button>
</div> </div>
</form> </form>
+17 -17
View File
@@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import { import {
createBlogCategory, createBlogCategory,
deleteBlogCategory, deleteBlogCategory,
@@ -18,11 +19,12 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css' import styles from './CategoriesPage.module.css'
export function BlogCategoriesPage() { export function BlogCategoriesPage() {
const t = useT()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('') 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 [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -46,17 +48,17 @@ export function BlogCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('blog.categories.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
function openCreateModal(parentId = '', title = 'Add Category') { function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null) setEditingCategory(null)
setDefaultParentId(parentId) setDefaultParentId(parentId)
setModalTitle(title) setModalTitle(title ?? t('categories.add'))
setModalOpen(true) setModalOpen(true)
if (parentId) { if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId)) setExpandedIds((prev) => new Set(prev).add(parentId))
@@ -66,7 +68,7 @@ export function BlogCategoriesPage() {
function openEditModal(category: Category) { function openEditModal(category: Category) {
setEditingCategory(category) setEditingCategory(category)
setDefaultParentId(category.parentId ?? '') setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category') setModalTitle(t('categories.edit'))
setModalOpen(true) setModalOpen(true)
} }
@@ -112,7 +114,7 @@ export function BlogCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save category.') setError(t('blog.categories.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -139,7 +141,7 @@ export function BlogCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete category.') setError(t('blog.categories.errorDelete'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -157,10 +159,8 @@ export function BlogCategoriesPage() {
/> />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Blog Categories</h2> <h2 className={pageStyles.pageTitle}>{t('blog.categories.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('blog.categories.subtitle')}</p>
Organize your blog posts into categories and subcategories.
</p>
</div> </div>
</div> </div>
@@ -172,15 +172,15 @@ export function BlogCategoriesPage() {
<div className={styles.list}> <div className={styles.list}>
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading categories...</p> <p className={styles.empty}>{t('categories.loading')}</p>
) : categories.length === 0 ? ( ) : 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 <BlogCategoryTree
categories={categories} categories={categories}
expandedIds={expandedIds} expandedIds={expandedIds}
onToggle={toggleExpanded} onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')} onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => { onEdit={(id) => {
const category = categories.find((item) => item.id === id) const category = categories.find((item) => item.id === id)
if (category) openEditModal(category) if (category) openEditModal(category)
@@ -211,10 +211,10 @@ export function BlogCategoriesPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Category" title={t('categories.deleteTitle')}
message={ message={
deleteTarget deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.` ? t('categories.deleteMessage', { name: deleteTarget.nameEn })
: '' : ''
} }
onConfirm={confirmDelete} onConfirm={confirmDelete}
@@ -226,7 +226,7 @@ export function BlogCategoriesPage() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
aria-label="Add category" aria-label={t('categories.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
+24 -9
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { CalendarDays, User } from 'lucide-react' import { CalendarDays, User } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { BlogCommentsSection } from '../components/BlogCommentsSection' import { BlogCommentsSection } from '../components/BlogCommentsSection'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
formatBlogAuthor, formatBlogAuthor,
@@ -16,6 +18,8 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css' import styles from './BlogDetailsPage.module.css'
export function BlogDetailsPage() { export function BlogDetailsPage() {
const t = useT()
const { locale } = useLocale()
const { id } = useParams() const { id } = useParams()
const [blog, setBlog] = useState<BlogDetail | null>(null) const [blog, setBlog] = useState<BlogDetail | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -38,7 +42,7 @@ export function BlogDetailsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load blog post.') setError(t('blog.details.errorLoad'))
} }
setBlog(null) setBlog(null)
} finally { } finally {
@@ -53,7 +57,7 @@ export function BlogDetailsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<p className={styles.status}>Loading blog post...</p> <p className={styles.status}>{t('blog.details.loading')}</p>
</main> </main>
) )
} }
@@ -61,17 +65,28 @@ export function BlogDetailsPage() {
if (error || !blog || !id) { if (error || !blog || !id) {
return ( return (
<main className={pageStyles.content}> <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}> <Link to="/blog/list" className={styles.backLink}>
Back to My Blogs {t('blog.details.back')}
</Link> </Link>
</main> </main>
) )
} }
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt) const displayDate = formatBlogDate(
const typeLabel = blog.publishedAt ?? blog.createdAt,
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type 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 titleLocale = textLocaleAttrs(blog.title)
const abstractLocale = textLocaleAttrs(blog.abstract) const abstractLocale = textLocaleAttrs(blog.abstract)
const contentLocale = textLocaleAttrs( const contentLocale = textLocaleAttrs(
@@ -155,13 +170,13 @@ export function BlogDetailsPage() {
/> />
) : ( ) : (
<p className={styles.content} lang="en" dir="ltr"> <p className={styles.content} lang="en" dir="ltr">
No content yet. {t('blog.details.noContent')}
</p> </p>
)} )}
<div className={styles.authorRow}> <div className={styles.authorRow}>
<User size={16} /> <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> <span className={styles.authorName}>{formatBlogAuthor(blog.author)}</span>
</div> </div>
</div> </div>
+17 -12
View File
@@ -7,6 +7,7 @@ import { BlogCommentsModal } from '../components/BlogCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
BLOGS_PER_PAGE, BLOGS_PER_PAGE,
@@ -23,6 +24,7 @@ import styles from './BlogPage.module.css'
export function BlogListPage() { export function BlogListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const [blogs, setBlogs] = useState<Blog[]>([]) const [blogs, setBlogs] = useState<Blog[]>([])
const [totalBlogs, setTotalBlogs] = useState(0) const [totalBlogs, setTotalBlogs] = useState(0)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -58,7 +60,7 @@ export function BlogListPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load blog posts.') setError(t('blog.list.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -91,7 +93,7 @@ export function BlogListPage() {
try { try {
await deleteBlog(deleteTarget.id) await deleteBlog(deleteTarget.id)
showToast('Blog post removed.', 'success') showToast(t('blog.list.toast.removed'), 'success')
const nextTotal = totalBlogs - 1 const nextTotal = totalBlogs - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / BLOGS_PER_PAGE)) const nextTotalPages = Math.max(1, Math.ceil(nextTotal / BLOGS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
@@ -102,7 +104,7 @@ export function BlogListPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete blog post.') setError(t('blog.list.errorDelete'))
} }
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
@@ -135,12 +137,15 @@ export function BlogListPage() {
setBlogs((prev) => setBlogs((prev) =>
prev.map((item) => (item.id === id ? mapBlogApiToUi(result.blog) : item)), 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) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to update blog verification.') setError(t('blog.list.errorVerify'))
} }
} finally { } finally {
setVerifyingId(null) setVerifyingId(null)
@@ -159,9 +164,9 @@ export function BlogListPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Blogs</h2> <h2 className={pageStyles.pageTitle}>{t('title.myBlogs')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{totalBlogs} posts · View, edit and manage your blog content. {t('blog.list.subtitle', { count: totalBlogs })}
</p> </p>
</div> </div>
</div> </div>
@@ -173,9 +178,9 @@ export function BlogListPage() {
)} )}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading blog posts...</p> <p className={styles.empty}>{t('blog.list.loading')}</p>
) : blogs.length === 0 ? ( ) : 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}> <div className={pageStyles.grid}>
@@ -205,17 +210,17 @@ export function BlogListPage() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => navigate('/blog/new')} onClick={() => navigate('/blog/new')}
aria-label="Add new blog" aria-label={t('blog.list.addNew')}
> >
<Plus size={26} strokeWidth={2.5} /> <Plus size={26} strokeWidth={2.5} />
</button> </button>
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Blog Post" title={t('blog.list.deleteTitle')}
message={ message={
deleteTarget deleteTarget
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.` ? t('blog.list.deleteMessage', { name: deleteTarget.title })
: '' : ''
} }
onConfirm={confirmDelete} onConfirm={confirmDelete}
+5 -3
View File
@@ -20,7 +20,8 @@
.addFab { .addFab {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
width: 56px; width: 56px;
height: 56px; height: 56px;
@@ -32,7 +33,7 @@
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4); box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
z-index: 50; z-index: 110;
} }
.addFab:hover { .addFab:hover {
@@ -42,7 +43,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.addFab { .addFab {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
width: 52px; width: 52px;
height: 52px; height: 52px;
+33 -18
View File
@@ -1,40 +1,50 @@
import { FileText, PlusCircle, FolderTree, Settings } from 'lucide-react' import { FileText, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard' import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const blogSections = [ const blogSections: {
icon: typeof FileText
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
}[] = [
{ {
icon: FileText, icon: FileText,
title: 'My Blogs', titleKey: 'nav.blog.list',
description: 'View, edit and manage all your blog posts.', descKey: 'blog.card.list.desc',
linkText: 'View blogs', linkKey: 'blog.card.list.link',
href: '/blog/list', href: '/blog/list',
}, },
{ {
icon: PlusCircle, icon: PlusCircle,
title: 'Add New Blog', titleKey: 'nav.blog.new',
description: 'Create and publish a new blog post.', descKey: 'blog.card.new.desc',
linkText: 'Add blog', linkKey: 'blog.card.new.link',
href: '/blog/new', href: '/blog/new',
}, },
{ {
icon: FolderTree, icon: FolderTree,
title: 'Blog Categories', titleKey: 'blog.card.categories.title',
description: 'Organize your blog posts into categories and subcategories.', descKey: 'blog.card.categories.desc',
linkText: 'View categories', linkKey: 'blog.card.categories.link',
href: '/blog/categories', href: '/blog/categories',
}, },
{ {
icon: Settings, icon: Settings,
title: 'Settings', titleKey: 'nav.blog.settings',
description: 'Configure blog comment moderation and display options.', descKey: 'blog.card.settings.desc',
linkText: 'View settings', linkKey: 'blog.card.settings.link',
href: '/blog/settings', href: '/blog/settings',
}, },
] ]
export function BlogPage() { export function BlogPage() {
const t = useT()
return ( return (
<main className={styles.content}> <main className={styles.content}>
<Breadcrumbs <Breadcrumbs
@@ -45,16 +55,21 @@ export function BlogPage() {
/> />
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>Blog</h2> <h2 className={styles.pageTitle}>{t('title.blog')}</h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('blog.page.subtitle')}</p>
Create and manage blog posts and categories.
</p>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{blogSections.map((section) => ( {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> </div>
</main> </main>
+12 -15
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch' import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getSettings, getSettings,
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
} }
export function BlogSettingsPage() { export function BlogSettingsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS) const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -41,7 +43,7 @@ export function BlogSettingsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load settings.') setError(t('productSettings.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -57,12 +59,12 @@ export function BlogSettingsPage() {
comments: { autoApprove: checked }, comments: { autoApprove: checked },
}) })
setSettings(data.settings.dashboard) setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save settings.') setError(t('productSettings.errorSave'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -81,10 +83,8 @@ export function BlogSettingsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Blog settings</h2> <h2 className={pageStyles.pageTitle}>{t('blog.settings.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('blog.settings.subtitle')}</p>
Configure how blog comments are moderated.
</p>
</div> </div>
</div> </div>
@@ -95,27 +95,24 @@ export function BlogSettingsPage() {
)} )}
<section className={styles.panel}> <section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3> <h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
{isLoading ? ( {isLoading ? (
<p className={styles.status}>Loading settings...</p> <p className={styles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<div className={styles.list}> <div className={styles.list}>
<div className={styles.row}> <div className={styles.row}>
<div className={styles.rowText}> <div className={styles.rowText}>
<label htmlFor="blog-comments-auto-approve" className={styles.rowLabel}> <label htmlFor="blog-comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments {t('blog.settings.commentsAuto')}
</label> </label>
<p className={styles.rowDescription}> <p className={styles.rowDescription}>{t('blog.settings.commentsAutoDesc')}</p>
New blog comments are published immediately when submitted. You can still
reject them later if needed.
</p>
</div> </div>
<Switch <Switch
id="blog-comments-auto-approve" id="blog-comments-auto-approve"
checked={settings.comments.autoApprove} checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'} disabled={savingKey === 'comments'}
aria-label="Auto-approve blog comments" aria-label={t('blog.settings.commentsAutoAria')}
onChange={(checked) => void handleCommentsAutoApprove(checked)} onChange={(checked) => void handleCommentsAutoApprove(checked)}
/> />
</div> </div>
@@ -27,9 +27,10 @@
.fabDock { .fabDock {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
z-index: 50; z-index: 110;
} }
.addFab { .addFab {
@@ -52,7 +53,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.fabDock { .fabDock {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
} }
} }
+20 -18
View File
@@ -16,14 +16,16 @@ import {
toBrandFormPayload, toBrandFormPayload,
updateBrand, updateBrand,
} from '../services/brandService' } from '../services/brandService'
import { useT } from '../i18n/useT'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './BrandsPage.module.css' import styles from './BrandsPage.module.css'
export function BrandsPage() { export function BrandsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [brands, setBrands] = useState<Brand[]>([]) const [brands, setBrands] = useState<Brand[]>([])
const [modalOpen, setModalOpen] = useState(false) 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 [editingBrand, setEditingBrand] = useState<Brand | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -47,7 +49,7 @@ export function BrandsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load brands.') setError(t('brands.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -56,13 +58,13 @@ export function BrandsPage() {
function openCreateModal() { function openCreateModal() {
setEditingBrand(null) setEditingBrand(null)
setModalTitle('Add Brand') setModalTitle(t('brands.add'))
setModalOpen(true) setModalOpen(true)
} }
function openEditModal(brand: Brand) { function openEditModal(brand: Brand) {
setEditingBrand(brand) setEditingBrand(brand)
setModalTitle('Edit Brand') setModalTitle(t('brands.edit'))
setModalOpen(true) setModalOpen(true)
} }
@@ -87,11 +89,11 @@ export function BrandsPage() {
brand.id === editingBrand.id ? mapBrandApiToUi(result.brand) : brand, brand.id === editingBrand.id ? mapBrandApiToUi(result.brand) : brand,
), ),
) )
showToast('Brand updated.', 'success') showToast(t('brands.toast.updated'), 'success')
} else { } else {
const result = await createBrand(toBrandFormPayload(data, resolvedImageMediaId)) const result = await createBrand(toBrandFormPayload(data, resolvedImageMediaId))
setBrands((prev) => [...prev, mapBrandApiToUi(result.brand)]) setBrands((prev) => [...prev, mapBrandApiToUi(result.brand)])
showToast('Brand created.', 'success') showToast(t('brands.toast.created'), 'success')
} }
setModalOpen(false) setModalOpen(false)
@@ -100,7 +102,7 @@ export function BrandsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save brand.') setError(t('brands.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -117,12 +119,12 @@ export function BrandsPage() {
await deleteBrand(deleteTarget.id) await deleteBrand(deleteTarget.id)
setBrands((prev) => prev.filter((brand) => brand.id !== deleteTarget.id)) setBrands((prev) => prev.filter((brand) => brand.id !== deleteTarget.id))
setDeleteTarget(null) setDeleteTarget(null)
showToast('Brand deleted.', 'success') showToast(t('brands.toast.deleted'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete brand.') setError(t('brands.errorDelete'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -140,10 +142,8 @@ export function BrandsPage() {
/> />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Brands</h2> <h2 className={pageStyles.pageTitle}>{t('title.brands')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('brands.page.subtitle')}</p>
Manage product brands for your store catalog.
</p>
</div> </div>
</div> </div>
@@ -155,9 +155,9 @@ export function BrandsPage() {
<div className={styles.list}> <div className={styles.list}>
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading brands...</p> <p className={styles.empty}>{t('brands.loading')}</p>
) : brands.length === 0 ? ( ) : 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) => ( brands.map((brand) => (
<BrandRow <BrandRow
@@ -192,10 +192,12 @@ export function BrandsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Brand" title={t('brands.deleteTitle')}
message={ message={
deleteTarget 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} onConfirm={confirmDelete}
@@ -207,7 +209,7 @@ export function BrandsPage() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={openCreateModal} onClick={openCreateModal}
aria-label="Add brand" aria-label={t('brands.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -27,12 +27,13 @@
.fabDock { .fabDock {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
z-index: 50; z-index: 110;
} }
.addFab { .addFab {
@@ -56,7 +57,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.fabDock { .fabDock {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
gap: 8px; gap: 8px;
} }
+23 -17
View File
@@ -33,17 +33,19 @@ import {
toCategoryFormPayload, toCategoryFormPayload,
updateProductCategory, updateProductCategory,
} from '../services/productCategoryService' } from '../services/productCategoryService'
import { useT } from '../i18n/useT'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
import styles from './CategoriesPage.module.css' import styles from './CategoriesPage.module.css'
export function CategoriesPage() { export function CategoriesPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('') 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 [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false) 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) setEditingCategory(null)
setDefaultParentId(parentId) setDefaultParentId(parentId)
setModalTitle(title) setModalTitle(title ?? t('categories.add'))
setModalOpen(true) setModalOpen(true)
if (parentId) { if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId)) setExpandedIds((prev) => new Set(prev).add(parentId))
@@ -108,7 +110,7 @@ export function CategoriesPage() {
function openEditModal(category: Category) { function openEditModal(category: Category) {
setEditingCategory(category) setEditingCategory(category)
setDefaultParentId(category.parentId ?? '') setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category') setModalTitle(t('categories.edit'))
setModalOpen(true) setModalOpen(true)
} }
@@ -224,7 +226,9 @@ export function CategoriesPage() {
setVariationsTarget({ setVariationsTarget({
categoryId, categoryId,
categoryName: `${category.nameEn} · ${category.nameFa}`, categoryName: category.nameFa
? `${category.nameFa} · ${category.nameEn}`
: category.nameEn,
}) })
setVariationsLoading(true) setVariationsLoading(true)
setError('') setError('')
@@ -263,7 +267,9 @@ export function CategoriesPage() {
setTechnicalTarget({ setTechnicalTarget({
categoryId, categoryId,
categoryName: `${category.nameEn} · ${category.nameFa}`, categoryName: category.nameFa
? `${category.nameFa} · ${category.nameEn}`
: category.nameEn,
}) })
setTechnicalLoading(true) setTechnicalLoading(true)
setTechnicalError('') setTechnicalError('')
@@ -421,10 +427,8 @@ export function CategoriesPage() {
/> />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Categories</h2> <h2 className={pageStyles.pageTitle}>{t('title.categories')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('categories.page.subtitle')}</p>
Manage your digital product categories and subcategories.
</p>
</div> </div>
</div> </div>
@@ -436,9 +440,9 @@ export function CategoriesPage() {
<div className={styles.list}> <div className={styles.list}>
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading categories...</p> <p className={styles.empty}>{t('categories.loading')}</p>
) : categories.length === 0 ? ( ) : 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 <CategoryTree
categories={categories} categories={categories}
@@ -446,7 +450,7 @@ export function CategoriesPage() {
variationCounts={variationCountsMap()} variationCounts={variationCountsMap()}
technicalFieldCounts={technicalFieldCountsMap()} technicalFieldCounts={technicalFieldCountsMap()}
onToggle={toggleExpanded} onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')} onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onVariations={openVariations} onVariations={openVariations}
onTechnicalForm={openTechnicalForm} onTechnicalForm={openTechnicalForm}
onOptions={(id) => { onOptions={(id) => {
@@ -479,10 +483,12 @@ export function CategoriesPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Category" title={t('categories.deleteTitle')}
message={ message={
deleteTarget 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} onConfirm={confirmDelete}
@@ -558,13 +564,13 @@ export function CategoriesPage() {
onClick={() => setAiModalOpen(true)} onClick={() => setAiModalOpen(true)}
> >
<Sparkles size={18} /> <Sparkles size={18} />
<span>Fill categories by AI</span> <span>{t('categories.addByAi')}</span>
</button> </button>
<button <button
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
aria-label="Add category" aria-label={t('categories.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -41,27 +41,23 @@
} }
.colName { .colName {
width: 16%; width: 22%;
} }
.colCell { .colCell {
width: 11%;
}
.colEmail {
width: 14%; width: 14%;
} }
.colOrders { .colOrders {
width: 9%; width: 12%;
} }
.colTransactions { .colTransactions {
width: 11%; width: 14%;
} }
.colDate { .colDate {
width: 10%; width: 12%;
} }
.colActions { .colActions {
@@ -71,7 +67,7 @@
.th, .th,
.td { .td {
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16); border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle; vertical-align: middle;
} }
@@ -104,15 +100,10 @@
.customerName[dir='rtl'] { .customerName[dir='rtl'] {
font-weight: 400; font-weight: 400;
text-align: left;
} }
.emailCell { .nameAlignFa {
overflow: hidden; text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
max-width: 0;
} }
.dateCell { .dateCell {
@@ -126,14 +117,14 @@
} }
.thActions { .thActions {
text-align: center; text-align: right;
white-space: nowrap; white-space: nowrap;
} }
.tdActions { .tdActions {
text-align: right; text-align: right;
white-space: nowrap; white-space: nowrap;
padding-right: 10px; padding-inline-end: 10px;
} }
.rowActions { .rowActions {
@@ -141,6 +132,19 @@
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 0; 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 { .toggleInActions {
@@ -243,9 +247,10 @@
.fab { .fab {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
z-index: 50; z-index: 110;
width: 56px; width: 56px;
height: 56px; height: 56px;
display: flex; display: flex;
@@ -265,7 +270,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.fab { .fab {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
width: 52px; width: 52px;
height: 52px; height: 52px;
+124 -105
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react' import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { AddCustomerModal } from '../components/AddCustomerModal' import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
@@ -8,6 +9,7 @@ import { Pagination } from '../components/Pagination'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import {
@@ -24,12 +26,13 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './CustomersPage.module.css' import styles from './CustomersPage.module.css'
const PAGE_SIZE = 24 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) const d = new Date(value)
if (Number.isNaN(d.getTime())) return 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) { function displayName(customer: BusinessCustomerListItem) {
@@ -37,13 +40,6 @@ function displayName(customer: BusinessCustomerListItem) {
return name || '—' 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) { function formatTransactionTotal(total: number | null | undefined) {
if (total == null || total === 0) { if (total == null || total === 0) {
return <span className={styles.subText}></span> return <span className={styles.subText}></span>
@@ -52,6 +48,8 @@ function formatTransactionTotal(total: number | null | undefined) {
} }
export function CustomersPage() { export function CustomersPage() {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast() const { showToast } = useToast()
const [data, setData] = useState<CustomersListResponse | null>(null) const [data, setData] = useState<CustomersListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -88,7 +86,7 @@ export function CustomersPage() {
setData(result) setData(result)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return 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 { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -99,7 +97,7 @@ export function CustomersPage() {
return () => { return () => {
controller.abort() controller.abort()
} }
}, [page, appliedFilters.name, appliedFilters.cellNumber]) }, [page, appliedFilters.name, appliedFilters.cellNumber, t])
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
const total = data?.total ?? 0 const total = data?.total ?? 0
@@ -147,12 +145,12 @@ export function CustomersPage() {
}) })
showToast( showToast(
isEnabled isEnabled
? `"${displayName(customer)}" has been enabled.` ? t('customers.toast.enabled', { name: displayName(customer) })
: `"${displayName(customer)}" has been disabled.`, : t('customers.toast.disabled', { name: displayName(customer) }),
'success', 'success',
) )
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update customer.') setError(err instanceof ApiError ? err.message : t('customers.error.update'))
} finally { } finally {
setTogglingId(null) setTogglingId(null)
} }
@@ -172,21 +170,24 @@ export function CustomersPage() {
items: prev.items.filter((item) => item.id !== removeTarget.id), 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) setRemoveTarget(null)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove customer.') setError(err instanceof ApiError ? err.message : t('customers.error.remove'))
} finally { } finally {
setRemoving(false) setRemoving(false)
} }
} }
function handleSendSms(customer: BusinessCustomerListItem) { 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) { 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) { function handleCustomerSaved(updated: BusinessCustomerListItem) {
@@ -197,7 +198,7 @@ export function CustomersPage() {
items: prev.items.map((item) => (item.id === updated.id ? updated : item)), 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) { 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 ( return (
@@ -235,33 +236,31 @@ export function CustomersPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Customers</h2> <h2 className={pageStyles.pageTitle}>{t('customers.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('customers.subtitle')}</p>
View customers who have registered or ordered from your business.
</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <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.filtersGrid}>
<div className={filterStyles.filtersInputs}> <div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<label htmlFor="filter-customer-name">Name</label>
<input <input
id="filter-customer-name" id="filter-customer-name"
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by name" placeholder={t('customers.filter.name')}
aria-label={t('customers.filter.name')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCellNarrow}`}> <div className={`${filterStyles.field} ${styles.filterCellNarrow}`}>
<label htmlFor="filter-customer-cell">Cell number</label>
<input <input
id="filter-customer-cell" id="filter-customer-cell"
value={draftCell} value={draftCell}
onChange={(e) => setDraftCell(e.target.value)} onChange={(e) => setDraftCell(e.target.value)}
placeholder="0912..." placeholder={t('customers.filter.cell')}
aria-label={t('customers.filter.cell')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -272,8 +271,8 @@ export function CustomersPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
aria-label="Search" aria-label={t('customers.search')}
title="Search" title={t('customers.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -282,8 +281,8 @@ export function CustomersPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={loading} disabled={loading}
aria-label="Clear filters" aria-label={t('customers.clearFilters')}
title="Clear filters" title={t('customers.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -294,15 +293,17 @@ export function CustomersPage() {
<div className={styles.tablePanel}> <div className={styles.tablePanel}>
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Customer list</div> <div className={styles.tableHeaderTitle}>{t('customers.listTitle')}</div>
<div className={styles.meta}> <div className={styles.meta}>
{data ? ( {data ? (
data.total > 0 ? ( data.total > 0 ? (
<> t('customers.showing', {
Showing {showingFrom} - {showingTo} of {data.total} 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>} {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> <colgroup>
<col className={styles.colName} /> <col className={styles.colName} />
<col className={styles.colCell} /> <col className={styles.colCell} />
<col className={styles.colEmail} />
<col className={styles.colOrders} /> <col className={styles.colOrders} />
<col className={styles.colTransactions} /> <col className={styles.colTransactions} />
<col className={styles.colDate} /> <col className={styles.colDate} />
@@ -324,123 +327,133 @@ export function CustomersPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className={styles.th}>Name</th> <th className={styles.th}>{t('customers.col.name')}</th>
<th className={styles.th}>Cell number</th> <th className={styles.th}>{t('customers.col.cell')}</th>
<th className={styles.th}>Email</th> <th className={styles.th}>{t('customers.col.orders')}</th>
<th className={styles.th}>Orders</th> <th className={styles.th}>{t('customers.col.transactions')}</th>
<th className={styles.th}>Transactions</th> <th className={styles.th}>{t('customers.col.date')}</th>
<th className={styles.th}>Date joined</th> <th className={`${styles.th} ${styles.thActions}`}>{t('customers.col.actions')}</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
Loading... {t('customers.loading')}
</td> </td>
</tr> </tr>
)} )}
{!loading && data?.items?.length === 0 && ( {!loading && data?.items?.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
No results found. {t('customers.empty')}
</td> </td>
</tr> </tr>
)} )}
{!loading && {!loading &&
data?.items?.map((customer) => ( data?.items?.map((customer) => {
const name = displayName(customer)
const nameLocale = textLocaleAttrs(name)
return (
<tr <tr
key={customer.id} key={customer.id}
className={!customer.isEnabled ? styles.inactiveRow : undefined} className={!customer.isEnabled ? styles.inactiveRow : undefined}
> >
<td className={styles.td}> <td className={styles.td}>
{(() => { <div
const name = displayName(customer) className={[
const locale = textLocaleAttrs(name) styles.customerName,
return ( nameLocale.className,
<div locale === 'fa' ? styles.nameAlignFa : '',
className={[ ]
styles.customerName, .filter(Boolean)
locale.className, .join(' ')}
] lang={nameLocale.lang}
.filter(Boolean) dir={nameLocale.dir}
.join(' ')} >
lang={locale.lang} {name}
dir={locale.dir} </div>
>
{name}
</div>
)
})()}
{!customer.isEnabled && ( {!customer.isEnabled && (
<div className={styles.statusDisabled}>Disabled</div> <div className={styles.statusDisabled}>{t('customers.disabled')}</div>
)} )}
</td> </td>
<td className={styles.td}>{formatCellForDisplay(customer.cellNumber)}</td> <td className={styles.td}>{formatCellForDisplay(customer.cellNumber)}</td>
<td className={`${styles.td} ${styles.emailCell}`}> <td className={styles.td}>
{customer.email ?? <span className={styles.subText}></span>} {customer.orderCount == null || customer.orderCount === 0 ? (
<span className={styles.subText}>{t('customers.noOrders')}</span>
) : (
customer.orderCount
)}
</td> </td>
<td className={styles.td}>{formatOrderCount(customer.orderCount)}</td>
<td className={styles.td}> <td className={styles.td}>
{formatTransactionTotal(customer.totalTransactionsIrt)} {formatTransactionTotal(customer.totalTransactionsIrt)}
</td> </td>
<td className={`${styles.td} ${styles.dateCell}`}> <td className={`${styles.td} ${styles.dateCell}`}>
{formatDate(customer.createdAt)} {formatDate(customer.createdAt, locale)}
</td> </td>
<td className={`${styles.td} ${styles.tdActions}`}> <td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}> <div className={styles.rowActions}>
<Tooltip <Tooltip
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`} label={
customer.isEnabled
? t('customers.disable')
: t('customers.enable')
}
> >
<span className={styles.toggleInActions}> <span className={styles.toggleInActions}>
<ToggleSwitch <ToggleSwitch
checked={customer.isEnabled} checked={customer.isEnabled}
disabled={togglingId === customer.id || removing} disabled={togglingId === customer.id || removing}
size="compact" size="compact"
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`} ariaLabel={
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)} customer.isEnabled
? t('customers.disableAria', { name })
: t('customers.enableAria', { name })
}
onChange={(isEnabled) =>
void handleToggleEnabled(customer, isEnabled)
}
/> />
</span> </span>
</Tooltip> </Tooltip>
<Tooltip label="Edit customer"> <Tooltip label={t('customers.edit')}>
<button <button
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => setEditTarget(customer)} onClick={() => setEditTarget(customer)}
aria-label="Edit customer" aria-label={t('customers.edit')}
> >
<Pencil size={15} /> <Pencil size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Send SMS"> <Tooltip label={t('customers.sms')}>
<button <button
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => handleSendSms(customer)} onClick={() => handleSendSms(customer)}
aria-label="Send SMS" aria-label={t('customers.sms')}
> >
<MessageSquare size={15} /> <MessageSquare size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Tickets"> <Tooltip label={t('customers.tickets')}>
<button <button
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => handleTickets(customer)} onClick={() => handleTickets(customer)}
aria-label="Tickets" aria-label={t('customers.tickets')}
> >
<Ticket size={15} /> <Ticket size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove customer"> <Tooltip label={t('customers.remove')}>
<button <button
type="button" type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`} className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(customer)} onClick={() => setRemoveTarget(customer)}
aria-label="Remove customer" aria-label={t('customers.remove')}
disabled={removing} disabled={removing}
> >
<Trash2 size={15} /> <Trash2 size={15} />
@@ -449,13 +462,19 @@ export function CustomersPage() {
</div> </div>
</td> </td>
</tr> </tr>
))} )
</tbody> })}
</table> </tbody>
</table>
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <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> </div>
<Pagination <Pagination
currentPage={page} currentPage={page}
@@ -483,10 +502,10 @@ export function CustomersPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={removeTarget !== null} open={removeTarget !== null}
title="Remove customer?" title={t('customers.deleteTitle')}
message={ message={
removeTarget removeTarget
? `Remove "${displayName(removeTarget)}" from your business? Their account will not be deleted.` ? t('customers.deleteMessage', { name: displayName(removeTarget) })
: '' : ''
} }
onCancel={() => setRemoveTarget(null)} onCancel={() => setRemoveTarget(null)}
@@ -497,7 +516,7 @@ export function CustomersPage() {
type="button" type="button"
className={styles.fab} className={styles.fab}
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="Add customer" aria-label={t('customers.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -33,14 +33,23 @@
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
direction: ltr;
} }
.pagination > div:first-child { .paginationMeta {
justify-self: start; grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
height: 38px;
line-height: 1;
font-family: var(--font-ui);
} }
.pagination > nav { .pagination > nav {
grid-column: 2;
justify-self: center; justify-self: center;
align-self: center;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
@@ -83,17 +92,19 @@
.fabDock { .fabDock {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
z-index: 50; z-index: 110;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.fabDock { .fabDock {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
gap: 8px; gap: 8px;
} }
+30 -24
View File
@@ -9,6 +9,7 @@ import { ProductVariantsModal } from '../components/ProductVariantsModal'
import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal' import { ProductTechnicalInfoModal } from '../components/ProductTechnicalInfoModal'
import { ProductCommentsModal } from '../components/ProductCommentsModal' import { ProductCommentsModal } from '../components/ProductCommentsModal'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
deleteProduct, deleteProduct,
@@ -22,6 +23,7 @@ import styles from './MyProductsPage.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
export function MyProductsPage() { export function MyProductsPage() {
const t = useT()
const navigate = useNavigate() const navigate = useNavigate()
const [products, setProducts] = useState<Product[]>([]) const [products, setProducts] = useState<Product[]>([])
const [totalProducts, setTotalProducts] = useState(0) const [totalProducts, setTotalProducts] = useState(0)
@@ -75,7 +77,7 @@ export function MyProductsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load products.') setError(t('products.list.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -89,7 +91,7 @@ export function MyProductsPage() {
function handleComments(id: string) { function handleComments(id: string) {
const product = products.find((p) => p.id === id) const product = products.find((p) => p.id === id)
if (product) { if (product) {
setCommentsTarget({ id, name: product.nameEn }) setCommentsTarget({ id, name: product.nameFa || product.nameEn })
} }
} }
@@ -98,7 +100,7 @@ export function MyProductsPage() {
if (product) { if (product) {
setVariantsTarget({ setVariantsTarget({
id, id,
name: product.nameEn, name: product.nameFa || product.nameEn,
categoryId: product.categoryId, categoryId: product.categoryId,
}) })
} }
@@ -109,7 +111,7 @@ export function MyProductsPage() {
if (product) { if (product) {
setTechnicalTarget({ setTechnicalTarget({
id, id,
name: product.nameEn, name: product.nameFa || product.nameEn,
categoryId: product.categoryId, categoryId: product.categoryId,
}) })
} }
@@ -118,7 +120,7 @@ export function MyProductsPage() {
function handleRemoveRequest(id: string) { function handleRemoveRequest(id: string) {
const product = products.find((p) => p.id === id) const product = products.find((p) => p.id === id)
if (product) { 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) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete product.') setError(t('products.list.errorDelete'))
} }
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
@@ -182,24 +184,24 @@ export function MyProductsPage() {
/> />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Products</h2> <h2 className={pageStyles.pageTitle}>{t('title.myProducts')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{totalProducts} products · View, edit and manage your catalog. {t('products.list.subtitle', { count: totalProducts })}
</p> </p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <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.filtersGrid}>
<div className={filterStyles.filtersInputs}> <div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
<label htmlFor="filter-product-name">Name</label>
<input <input
id="filter-product-name" id="filter-product-name"
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by product name" placeholder={t('products.list.filterNamePlaceholder')}
aria-label={t('products.list.filterName')}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
@@ -210,8 +212,8 @@ export function MyProductsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={isLoading} disabled={isLoading}
aria-label="Search" aria-label={t('products.list.search')}
title="Search" title={t('products.list.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -220,8 +222,8 @@ export function MyProductsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={isLoading} disabled={isLoading}
aria-label="Clear filters" aria-label={t('products.list.clearFilters')}
title="Clear filters" title={t('products.list.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -236,10 +238,10 @@ export function MyProductsPage() {
)} )}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading products...</p> <p className={styles.empty}>{t('products.list.loading')}</p>
) : products.length === 0 ? ( ) : products.length === 0 ? (
<p className={styles.empty}> <p className={styles.empty}>
{appliedName ? 'No products match your filters.' : 'No products found.'} {appliedName ? t('products.list.emptyFiltered') : t('products.list.empty')}
</p> </p>
) : ( ) : (
<> <>
@@ -261,9 +263,13 @@ export function MyProductsPage() {
</div> </div>
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div className={styles.paginationMeta}>
Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '} {t('products.list.pagination', {
{totalProducts} total page: currentPage,
totalPages,
perPage: PRODUCTS_PER_PAGE,
total: totalProducts,
})}
</div> </div>
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
@@ -278,10 +284,10 @@ export function MyProductsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Product" title={t('products.list.deleteTitle')}
message={ message={
deleteTarget deleteTarget
? `Are you sure you want to delete "${deleteTarget.name}"? This action cannot be undone.` ? t('products.list.deleteMessage', { name: deleteTarget.name })
: '' : ''
} }
onConfirm={confirmDelete} onConfirm={confirmDelete}
@@ -337,9 +343,9 @@ export function MyProductsPage() {
onClick={() => setAiModalOpen(true)} onClick={() => setAiModalOpen(true)}
> >
<Sparkles size={18} /> <Sparkles size={18} />
<span>Add product by AI</span> <span>{t('products.list.addByAi')}</span>
</button> </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} /> <Plus size={24} />
</Link> </Link>
</div> </div>
@@ -75,7 +75,7 @@
.th, .th,
.td { .td {
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: start;
border-bottom: 1px solid rgba(148, 163, 184, 0.16); border-bottom: 1px solid rgba(148, 163, 184, 0.16);
vertical-align: middle; vertical-align: middle;
} }
@@ -226,9 +226,9 @@
} }
.tdActions { .tdActions {
text-align: right; text-align: end;
white-space: nowrap; white-space: nowrap;
padding-right: 10px; padding-inline-end: 10px;
} }
.rowActions { .rowActions {
+118 -80
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react' import { CreditCard, Eye, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderItemsModal } from '../components/OrderItemsModal'
@@ -8,6 +9,7 @@ import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import {
@@ -45,12 +47,13 @@ function displayName(order: Order) {
return name || '—' return name || '—'
} }
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '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) return order.items.reduce((sum, item) => sum + item.quantity, 0)
} }
function sourceLabel(source: OrderSource) { function defaultFaForLabel(label: string | null | undefined) {
switch (source) { const trimmed = label?.trim()
case 'admin': if (!trimmed) return undefined
return 'Operator' return DEFAULT_ORDER_PROCESS_STEPS.find(
case 'app': (step) => step.label.toLowerCase() === trimmed.toLowerCase(),
return 'Application' )?.labelFa
case 'website': }
default:
return 'Website' 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) { 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( function stepColor(
steps: OrderProcessStep[], steps: OrderProcessStep[],
processStepId: string, processStepId: string,
@@ -112,6 +129,8 @@ function stepColor(
} }
export function OrdersPage() { export function OrdersPage() {
const t = useT()
const { locale } = useLocale()
const { showToast } = useToast() const { showToast } = useToast()
const [data, setData] = useState<OrdersListResponse | null>(null) const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -134,6 +153,18 @@ export function OrdersPage() {
const [removeTarget, setRemoveTarget] = useState<Order | null>(null) const [removeTarget, setRemoveTarget] = useState<Order | null>(null)
const [removing, setRemoving] = useState(false) 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(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -169,7 +200,7 @@ export function OrdersPage() {
setData(result) setData(result)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return 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 { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -188,6 +219,7 @@ export function OrdersPage() {
appliedFilters.dateTo, appliedFilters.dateTo,
appliedFilters.minTotal, appliedFilters.minTotal,
appliedFilters.maxTotal, appliedFilters.maxTotal,
t,
]) ])
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
@@ -210,7 +242,7 @@ export function OrdersPage() {
const maxTotal = parseIrtInput(draftMaxCost) const maxTotal = parseIrtInput(draftMaxCost)
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) { if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
setError('Minimum cost cannot be greater than maximum cost.') setError(t('orders.error.minMax'))
return return
} }
@@ -261,10 +293,10 @@ export function OrdersPage() {
items: prev.items.filter((item) => item.id !== removeTarget.id), 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) setRemoveTarget(null)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove order.') setError(err instanceof ApiError ? err.message : t('orders.error.remove'))
} finally { } finally {
setRemoving(false) setRemoving(false)
} }
@@ -282,75 +314,75 @@ export function OrdersPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Orders</h2> <h2 className={pageStyles.pageTitle}>{t('orders.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('orders.subtitle')}</p>
Track customer orders placed through your website, application, or by operators.
</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div> <div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
<div className={filterStyles.filtersGrid}> <div className={filterStyles.filtersGrid}>
<div className={styles.ordersFiltersRow}> <div className={styles.ordersFiltersRow}>
<div className={`${filterStyles.field} ${styles.filterOrderId}`}> <div className={`${filterStyles.field} ${styles.filterOrderId}`}>
<label htmlFor="filter-order-id">Order ID</label>
<input <input
id="filter-order-id" id="filter-order-id"
value={draftOrderId} value={draftOrderId}
onChange={(e) => setDraftOrderId(e.target.value)} onChange={(e) => setDraftOrderId(e.target.value)}
placeholder="ORD-..." placeholder={t('orders.filter.orderId')}
aria-label={t('orders.filter.orderId')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCustomer}`}> <div className={`${filterStyles.field} ${styles.filterCustomer}`}>
<label htmlFor="filter-order-customer">Customer name or number</label>
<input <input
id="filter-order-customer" id="filter-order-customer"
value={draftCustomer} value={draftCustomer}
onChange={(e) => setDraftCustomer(e.target.value)} onChange={(e) => setDraftCustomer(e.target.value)}
placeholder="Name or phone" placeholder={t('orders.filter.customer')}
aria-label={t('orders.filter.customer')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterDate}`}> <div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-order-date-from">Date from</label>
<input <input
id="filter-order-date-from" id="filter-order-date-from"
type="date" type="date"
value={draftDateFrom} value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)} onChange={(e) => setDraftDateFrom(e.target.value)}
aria-label={t('orders.filter.dateFrom')}
title={t('orders.filter.dateFrom')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterDate}`}> <div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-order-date-to">Date to</label>
<input <input
id="filter-order-date-to" id="filter-order-date-to"
type="date" type="date"
value={draftDateTo} value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)} onChange={(e) => setDraftDateTo(e.target.value)}
aria-label={t('orders.filter.dateTo')}
title={t('orders.filter.dateTo')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCost}`}> <div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-order-min-cost">Min cost (IRT)</label>
<input <input
id="filter-order-min-cost" id="filter-order-min-cost"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={draftMinCost} value={draftMinCost}
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))} onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('orders.filter.minCost')}
aria-label={t('orders.filter.minCost')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCost}`}> <div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-order-max-cost">Max cost (IRT)</label>
<input <input
id="filter-order-max-cost" id="filter-order-max-cost"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={draftMaxCost} value={draftMaxCost}
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))} onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('orders.filter.maxCost')}
aria-label={t('orders.filter.maxCost')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -361,8 +393,8 @@ export function OrdersPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
aria-label="Search" aria-label={t('orders.search')}
title="Search" title={t('orders.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -371,8 +403,8 @@ export function OrdersPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={loading} disabled={loading}
aria-label="Clear filters" aria-label={t('orders.clearFilters')}
title="Clear filters" title={t('orders.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -383,15 +415,17 @@ export function OrdersPage() {
<div className={styles.tablePanel}> <div className={styles.tablePanel}>
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Order list</div> <div className={styles.tableHeaderTitle}>{t('orders.listTitle')}</div>
<div className={styles.meta}> <div className={styles.meta}>
{data ? ( {data ? (
data.total > 0 ? ( data.total > 0 ? (
<> t('orders.showing', {
Showing {showingFrom} - {showingTo} of {data.total} 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>} {error && <div className={styles.errorBanner}>{error}</div>}
<table className={styles.table}> <table className={styles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<colgroup> <colgroup>
<col className={styles.colOrderId} /> <col className={styles.colOrderId} />
<col className={styles.colCustomer} /> <col className={styles.colCustomer} />
@@ -414,21 +448,21 @@ export function OrdersPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className={styles.th}>Order ID</th> <th className={styles.th}>{t('orders.col.orderId')}</th>
<th className={styles.th}>Customer</th> <th className={styles.th}>{t('orders.col.customer')}</th>
<th className={styles.th}>Items</th> <th className={styles.th}>{t('orders.col.items')}</th>
<th className={styles.th}>Total cost</th> <th className={styles.th}>{t('orders.col.total')}</th>
<th className={styles.th}>Date & time</th> <th className={styles.th}>{t('orders.col.date')}</th>
<th className={styles.th}>Step</th> <th className={styles.th}>{t('orders.col.step')}</th>
<th className={styles.th}>Registered by</th> <th className={styles.th}>{t('orders.col.source')}</th>
<th className={`${styles.th} ${styles.thActions}`}>Actions</th> <th className={`${styles.th} ${styles.thActions}`}>{t('orders.col.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
Loading... {t('orders.loading')}
</td> </td>
</tr> </tr>
)} )}
@@ -436,15 +470,22 @@ export function OrdersPage() {
{!loading && data?.items?.length === 0 && ( {!loading && data?.items?.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
No results found. {t('orders.empty')}
</td> </td>
</tr> </tr>
)} )}
{!loading && {!loading &&
data?.items?.map((order) => { data?.items?.map((order) => {
const { date, time } = formatDateTime(order.createdAt) const { date, time } = formatDateTime(order.createdAt, locale)
const itemQty = totalItemQuantity(order) const itemQty = totalItemQuantity(order)
const currentStepLabel = stepLabel(
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepLabel,
order.processStepLabelFa,
locale,
)
return ( return (
<tr key={order.id}> <tr key={order.id}>
@@ -477,17 +518,9 @@ export function OrdersPage() {
), ),
)} )}
onClick={() => setStepOrder(order)} onClick={() => setStepOrder(order)}
aria-label={`Change step: ${stepLabel( aria-label={t('orders.changeStep', { step: currentStepLabel })}
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepLabel,
)}`}
> >
{stepLabel( {currentStepLabel}
processSteps,
order.processStepId ?? processSteps[0]?.id ?? 'processing',
order.processStepLabel,
)}
</button> </button>
</td> </td>
<td className={styles.td}> <td className={styles.td}>
@@ -497,32 +530,32 @@ export function OrdersPage() {
</td> </td>
<td className={`${styles.td} ${styles.tdActions}`}> <td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}> <div className={styles.rowActions}>
<Tooltip label="View items"> <Tooltip label={t('orders.viewItems')}>
<button <button
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => setViewOrder(order)} onClick={() => setViewOrder(order)}
aria-label="View items" aria-label={t('orders.viewItems')}
> >
<Eye size={15} /> <Eye size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Transaction details"> <Tooltip label={t('orders.transactions')}>
<button <button
type="button" type="button"
className={styles.actionBtn} className={styles.actionBtn}
onClick={() => setTransactionsOrder(order)} onClick={() => setTransactionsOrder(order)}
aria-label="Transaction details" aria-label={t('orders.transactions')}
> >
<CreditCard size={15} /> <CreditCard size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove order"> <Tooltip label={t('orders.remove')}>
<button <button
type="button" type="button"
className={`${styles.actionBtn} ${styles.actionBtnDanger}`} className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={() => setRemoveTarget(order)} onClick={() => setRemoveTarget(order)}
aria-label="Remove order" aria-label={t('orders.remove')}
disabled={removing} disabled={removing}
> >
<Trash2 size={15} /> <Trash2 size={15} />
@@ -538,7 +571,12 @@ export function OrdersPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <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> </div>
<Pagination <Pagination
currentPage={page} currentPage={page}
@@ -573,10 +611,10 @@ export function OrdersPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={removeTarget !== null} open={removeTarget !== null}
title="Remove order?" title={t('orders.deleteTitle')}
message={ message={
removeTarget removeTarget
? `Remove order ${removeTarget.orderNumber}? This cannot be undone.` ? t('orders.deleteMessage', { orderNumber: removeTarget.orderNumber })
: '' : ''
} }
onCancel={() => setRemoveTarget(null)} onCancel={() => setRemoveTarget(null)}
@@ -6,6 +6,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useT } from '../i18n/useT'
import { import {
createPortfolioCategory, createPortfolioCategory,
deletePortfolioCategory, deletePortfolioCategory,
@@ -18,11 +19,12 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './CategoriesPage.module.css' import styles from './CategoriesPage.module.css'
export function PortfolioCategoriesPage() { export function PortfolioCategoriesPage() {
const t = useT()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [defaultParentId, setDefaultParentId] = useState('') 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 [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -46,17 +48,17 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load categories.') setError(t('portfolio.categories.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
} }
function openCreateModal(parentId = '', title = 'Add Category') { function openCreateModal(parentId = '', title?: string) {
setEditingCategory(null) setEditingCategory(null)
setDefaultParentId(parentId) setDefaultParentId(parentId)
setModalTitle(title) setModalTitle(title ?? t('categories.add'))
setModalOpen(true) setModalOpen(true)
if (parentId) { if (parentId) {
setExpandedIds((prev) => new Set(prev).add(parentId)) setExpandedIds((prev) => new Set(prev).add(parentId))
@@ -66,7 +68,7 @@ export function PortfolioCategoriesPage() {
function openEditModal(category: Category) { function openEditModal(category: Category) {
setEditingCategory(category) setEditingCategory(category)
setDefaultParentId(category.parentId ?? '') setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category') setModalTitle(t('categories.edit'))
setModalOpen(true) setModalOpen(true)
} }
@@ -112,7 +114,7 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save category.') setError(t('portfolio.categories.errorSave'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -139,7 +141,7 @@ export function PortfolioCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete category.') setError(t('portfolio.categories.errorDelete'))
} }
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
@@ -157,10 +159,8 @@ export function PortfolioCategoriesPage() {
/> />
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Portfolio Categories</h2> <h2 className={pageStyles.pageTitle}>{t('portfolio.categories.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('portfolio.categories.subtitle')}</p>
Organize your portfolio items into categories and subcategories.
</p>
</div> </div>
</div> </div>
@@ -172,15 +172,15 @@ export function PortfolioCategoriesPage() {
<div className={styles.list}> <div className={styles.list}>
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading categories...</p> <p className={styles.empty}>{t('categories.loading')}</p>
) : categories.length === 0 ? ( ) : 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 <BlogCategoryTree
categories={categories} categories={categories}
expandedIds={expandedIds} expandedIds={expandedIds}
onToggle={toggleExpanded} onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')} onAddSub={(id) => openCreateModal(id, t('categories.addSub'))}
onEdit={(id) => { onEdit={(id) => {
const category = categories.find((item) => item.id === id) const category = categories.find((item) => item.id === id)
if (category) openEditModal(category) if (category) openEditModal(category)
@@ -211,10 +211,10 @@ export function PortfolioCategoriesPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Category" title={t('categories.deleteTitle')}
message={ message={
deleteTarget deleteTarget
? `Are you sure you want to delete "${deleteTarget.nameEn}"? Subcategories will also be removed.` ? t('categories.deleteMessage', { name: deleteTarget.nameEn })
: '' : ''
} }
onConfirm={confirmDelete} onConfirm={confirmDelete}
@@ -226,7 +226,7 @@ export function PortfolioCategoriesPage() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => openCreateModal()} onClick={() => openCreateModal()}
aria-label="Add category" aria-label={t('categories.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { CalendarDays } from 'lucide-react' import { CalendarDays } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ImageLightbox } from '../components/ImageLightbox' import { ImageLightbox } from '../components/ImageLightbox'
import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection' import { PortfolioCommentsSection } from '../components/PortfolioCommentsSection'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService' import { formatPortfolioDate, getPortfolioDetail } from '../services/portfolioService'
import type { PortfolioDetail } from '../types/portfolio' import type { PortfolioDetail } from '../types/portfolio'
@@ -11,6 +13,8 @@ import pageStyles from '../components/PageContent.module.css'
import styles from './PortfolioDetailsPage.module.css' import styles from './PortfolioDetailsPage.module.css'
export function PortfolioDetailsPage() { export function PortfolioDetailsPage() {
const t = useT()
const { locale } = useLocale()
const { id } = useParams() const { id } = useParams()
const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null) const [portfolio, setPortfolio] = useState<PortfolioDetail | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -35,7 +39,7 @@ export function PortfolioDetailsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load portfolio.') setError(t('portfolio.details.errorLoad'))
} }
setPortfolio(null) setPortfolio(null)
} finally { } finally {
@@ -60,7 +64,7 @@ export function PortfolioDetailsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<p className={styles.status}>Loading portfolio...</p> <p className={styles.status}>{t('portfolio.details.loading')}</p>
</main> </main>
) )
} }
@@ -68,15 +72,18 @@ export function PortfolioDetailsPage() {
if (error || !portfolio || !id) { if (error || !portfolio || !id) {
return ( return (
<main className={pageStyles.content}> <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}> <Link to="/portfolios/list" className={styles.backLink}>
Back to My Portfolios {t('portfolio.details.back')}
</Link> </Link>
</main> </main>
) )
} }
const displayDate = formatPortfolioDate(portfolio.publishedAt ?? portfolio.createdAt) const displayDate = formatPortfolioDate(
portfolio.publishedAt ?? portfolio.createdAt,
locale === 'fa' ? 'fa' : 'en',
)
return ( return (
<> <>
@@ -145,16 +152,16 @@ export function PortfolioDetailsPage() {
dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }} dangerouslySetInnerHTML={{ __html: portfolio.mainTextHtml }}
/> />
) : ( ) : (
<p className={styles.content}>No content yet.</p> <p className={styles.content}>{t('portfolio.details.noContent')}</p>
)} )}
</div> </div>
</article> </article>
</div> </div>
{galleryImages.length > 0 && ( {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}> <div className={styles.galleryInner}>
<h3 className={styles.galleryTitle}>Gallery</h3> <h3 className={styles.galleryTitle}>{t('portfolio.details.gallery')}</h3>
<div className={styles.galleryGrid}> <div className={styles.galleryGrid}>
{galleryImages.map((src, index) => ( {galleryImages.map((src, index) => (
<button <button
@@ -162,7 +169,7 @@ export function PortfolioDetailsPage() {
type="button" type="button"
className={styles.galleryItem} className={styles.galleryItem}
onClick={() => openLightbox(index)} 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" /> <img src={src} alt={`${portfolio.title} gallery ${index + 1}`} loading="lazy" />
</button> </button>
+14 -13
View File
@@ -7,6 +7,7 @@ import { PortfolioCommentsModal } from '../components/PortfolioCommentsModal'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
PORTFOLIOS_PER_PAGE, PORTFOLIOS_PER_PAGE,
@@ -21,6 +22,7 @@ import styles from './BlogPage.module.css'
export function PortfolioListPage() { export function PortfolioListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const t = useT()
const [portfolios, setPortfolios] = useState<Portfolio[]>([]) const [portfolios, setPortfolios] = useState<Portfolio[]>([])
const [totalPortfolios, setTotalPortfolios] = useState(0) const [totalPortfolios, setTotalPortfolios] = useState(0)
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
@@ -58,7 +60,7 @@ export function PortfolioListPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load portfolios.') setError(t('portfolio.list.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -94,7 +96,6 @@ export function PortfolioListPage() {
try { try {
if (index === 0) { if (index === 0) {
// First on this page, but not global first — bump above the current band.
await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 }) await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 })
} else { } else {
const previous = portfolios[index - 1] 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) await loadPortfolios(currentPage)
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to move portfolio.') setError(t('portfolio.list.errorMove'))
} }
} finally { } finally {
setMovingUpId(null) setMovingUpId(null)
@@ -128,7 +129,7 @@ export function PortfolioListPage() {
try { try {
await deletePortfolio(deleteTarget.id) await deletePortfolio(deleteTarget.id)
showToast('Portfolio removed.', 'success') showToast(t('portfolio.list.toast.removed'), 'success')
const nextTotal = totalPortfolios - 1 const nextTotal = totalPortfolios - 1
const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE)) const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE))
const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage
@@ -139,7 +140,7 @@ export function PortfolioListPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete portfolio.') setError(t('portfolio.list.errorDelete'))
} }
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
@@ -171,9 +172,9 @@ export function PortfolioListPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Portfolios</h2> <h2 className={pageStyles.pageTitle}>{t('title.myPortfolios')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
{totalPortfolios} items · View, edit and manage your portfolio content. {t('portfolio.list.subtitle', { count: totalPortfolios })}
</p> </p>
</div> </div>
</div> </div>
@@ -185,9 +186,9 @@ export function PortfolioListPage() {
)} )}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading portfolios...</p> <p className={styles.empty}>{t('portfolio.list.loading')}</p>
) : portfolios.length === 0 ? ( ) : 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}> <div className={pageStyles.gridCols4}>
@@ -218,17 +219,17 @@ export function PortfolioListPage() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => navigate('/portfolios/new')} onClick={() => navigate('/portfolios/new')}
aria-label="Add new portfolio" aria-label={t('portfolio.list.addNew')}
> >
<Plus size={26} strokeWidth={2.5} /> <Plus size={26} strokeWidth={2.5} />
</button> </button>
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Delete Portfolio" title={t('portfolio.list.deleteTitle')}
message={ message={
deleteTarget deleteTarget
? `Are you sure you want to delete "${deleteTarget.title}"? This action cannot be undone.` ? t('portfolio.list.deleteMessage', { name: deleteTarget.title })
: '' : ''
} }
onConfirm={confirmDelete} onConfirm={confirmDelete}
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch' import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getSettings, getSettings,
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
} }
export function PortfolioSettingsPage() { export function PortfolioSettingsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS) const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -41,7 +43,7 @@ export function PortfolioSettingsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load settings.') setError(t('productSettings.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -57,12 +59,12 @@ export function PortfolioSettingsPage() {
comments: { autoApprove: checked }, comments: { autoApprove: checked },
}) })
setSettings(data.settings.dashboard) setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save settings.') setError(t('productSettings.errorSave'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -81,10 +83,8 @@ export function PortfolioSettingsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Portfolio settings</h2> <h2 className={pageStyles.pageTitle}>{t('portfolio.settings.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('portfolio.settings.subtitle')}</p>
Configure how portfolio comments are moderated.
</p>
</div> </div>
</div> </div>
@@ -95,27 +95,24 @@ export function PortfolioSettingsPage() {
)} )}
<section className={styles.panel}> <section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3> <h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
{isLoading ? ( {isLoading ? (
<p className={styles.status}>Loading settings...</p> <p className={styles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<div className={styles.list}> <div className={styles.list}>
<div className={styles.row}> <div className={styles.row}>
<div className={styles.rowText}> <div className={styles.rowText}>
<label htmlFor="portfolio-comments-auto-approve" className={styles.rowLabel}> <label htmlFor="portfolio-comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments {t('portfolio.settings.commentsAuto')}
</label> </label>
<p className={styles.rowDescription}> <p className={styles.rowDescription}>{t('portfolio.settings.commentsAutoDesc')}</p>
New portfolio comments are published immediately when submitted. You can still
reject them later if needed.
</p>
</div> </div>
<Switch <Switch
id="portfolio-comments-auto-approve" id="portfolio-comments-auto-approve"
checked={settings.comments.autoApprove} checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'} disabled={savingKey === 'comments'}
aria-label="Auto-approve portfolio comments" aria-label={t('portfolio.settings.commentsAutoAria')}
onChange={(checked) => void handleCommentsAutoApprove(checked)} onChange={(checked) => void handleCommentsAutoApprove(checked)}
/> />
</div> </div>
+33 -18
View File
@@ -1,40 +1,50 @@
import { Briefcase, PlusCircle, FolderTree, Settings } from 'lucide-react' import { Briefcase, PlusCircle, FolderTree, Settings } from 'lucide-react'
import { SectionCard } from '../components/SectionCard' import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const portfolioSections = [ const portfolioSections: {
icon: typeof Briefcase
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
}[] = [
{ {
icon: Briefcase, icon: Briefcase,
title: 'My Portfolios', titleKey: 'nav.portfolios.list',
description: 'View, edit and manage all your portfolio items.', descKey: 'portfolio.card.list.desc',
linkText: 'View portfolios', linkKey: 'portfolio.card.list.link',
href: '/portfolios/list', href: '/portfolios/list',
}, },
{ {
icon: PlusCircle, icon: PlusCircle,
title: 'Add New Portfolio', titleKey: 'nav.portfolios.new',
description: 'Create and publish a new portfolio project.', descKey: 'portfolio.card.new.desc',
linkText: 'Add portfolio', linkKey: 'portfolio.card.new.link',
href: '/portfolios/new', href: '/portfolios/new',
}, },
{ {
icon: FolderTree, icon: FolderTree,
title: 'Portfolio Categories', titleKey: 'portfolio.card.categories.title',
description: 'Organize portfolio items into categories and subcategories.', descKey: 'portfolio.card.categories.desc',
linkText: 'View categories', linkKey: 'portfolio.card.categories.link',
href: '/portfolios/categories', href: '/portfolios/categories',
}, },
{ {
icon: Settings, icon: Settings,
title: 'Settings', titleKey: 'nav.portfolios.settings',
description: 'Configure portfolio comment moderation and display options.', descKey: 'portfolio.card.settings.desc',
linkText: 'View settings', linkKey: 'portfolio.card.settings.link',
href: '/portfolios/settings', href: '/portfolios/settings',
}, },
] ]
export function PortfoliosPage() { export function PortfoliosPage() {
const t = useT()
return ( return (
<main className={styles.content}> <main className={styles.content}>
<Breadcrumbs <Breadcrumbs
@@ -45,16 +55,21 @@ export function PortfoliosPage() {
/> />
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>Portfolios</h2> <h2 className={styles.pageTitle}>{t('title.portfolios')}</h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('portfolio.page.subtitle')}</p>
Manage your portfolio items and showcase projects.
</p>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{portfolioSections.map((section) => ( {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> </div>
</main> </main>
+17 -23
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { Switch } from '../components/Switch' import { Switch } from '../components/Switch'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getSettings, getSettings,
@@ -17,6 +18,7 @@ const DEFAULT_SETTINGS: DashboardSettings = {
} }
export function ProductSettingsPage() { export function ProductSettingsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS) const [settings, setSettings] = useState<DashboardSettings>(DEFAULT_SETTINGS)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -41,7 +43,7 @@ export function ProductSettingsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load settings.') setError(t('productSettings.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -57,12 +59,12 @@ export function ProductSettingsPage() {
comments: { autoApprove: checked }, comments: { autoApprove: checked },
}) })
setSettings(data.settings.dashboard) setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save settings.') setError(t('productSettings.errorSave'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -78,12 +80,12 @@ export function ProductSettingsPage() {
expertReviews: { autoApprove: checked }, expertReviews: { autoApprove: checked },
}) })
setSettings(data.settings.dashboard) setSettings(data.settings.dashboard)
showToast('Settings saved.', 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save settings.') setError(t('productSettings.errorSave'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -102,10 +104,8 @@ export function ProductSettingsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Product settings</h2> <h2 className={pageStyles.pageTitle}>{t('productSettings.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('productSettings.subtitle')}</p>
Configure how comments and expert reviews are moderated.
</p>
</div> </div>
</div> </div>
@@ -116,27 +116,24 @@ export function ProductSettingsPage() {
)} )}
<section className={styles.panel}> <section className={styles.panel}>
<h3 className={styles.sectionTitle}>Moderation</h3> <h3 className={styles.sectionTitle}>{t('productSettings.moderation')}</h3>
{isLoading ? ( {isLoading ? (
<p className={styles.status}>Loading settings...</p> <p className={styles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<div className={styles.list}> <div className={styles.list}>
<div className={styles.row}> <div className={styles.row}>
<div className={styles.rowText}> <div className={styles.rowText}>
<label htmlFor="comments-auto-approve" className={styles.rowLabel}> <label htmlFor="comments-auto-approve" className={styles.rowLabel}>
Auto-approve comments {t('productSettings.commentsAuto')}
</label> </label>
<p className={styles.rowDescription}> <p className={styles.rowDescription}>{t('productSettings.commentsAutoDesc')}</p>
New comments are published immediately when submitted. You can still reject
them later if needed.
</p>
</div> </div>
<Switch <Switch
id="comments-auto-approve" id="comments-auto-approve"
checked={settings.comments.autoApprove} checked={settings.comments.autoApprove}
disabled={savingKey === 'comments'} disabled={savingKey === 'comments'}
aria-label="Auto-approve comments" aria-label={t('productSettings.commentsAuto')}
onChange={(checked) => void handleCommentsAutoApprove(checked)} onChange={(checked) => void handleCommentsAutoApprove(checked)}
/> />
</div> </div>
@@ -144,18 +141,15 @@ export function ProductSettingsPage() {
<div className={styles.row}> <div className={styles.row}>
<div className={styles.rowText}> <div className={styles.rowText}>
<label htmlFor="expert-reviews-auto-approve" className={styles.rowLabel}> <label htmlFor="expert-reviews-auto-approve" className={styles.rowLabel}>
Auto-approve expert reviews {t('productSettings.reviewsAuto')}
</label> </label>
<p className={styles.rowDescription}> <p className={styles.rowDescription}>{t('productSettings.reviewsAutoDesc')}</p>
New expert reviews are published immediately when submitted. You can still
reject them later if needed.
</p>
</div> </div>
<Switch <Switch
id="expert-reviews-auto-approve" id="expert-reviews-auto-approve"
checked={settings.expertReviews.autoApprove} checked={settings.expertReviews.autoApprove}
disabled={savingKey === 'expertReviews'} disabled={savingKey === 'expertReviews'}
aria-label="Auto-approve expert reviews" aria-label={t('productSettings.reviewsAuto')}
onChange={(checked) => void handleExpertReviewsAutoApprove(checked)} onChange={(checked) => void handleExpertReviewsAutoApprove(checked)}
/> />
</div> </div>
+60 -45
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Play, RotateCcw, Search, Trash2 } from 'lucide-react' import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import {
@@ -40,12 +42,13 @@ function displayName(card: ShoppingCard) {
return name || '—' return name || '—'
} }
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
} }
} }
@@ -54,6 +57,8 @@ function totalItemQuantity(card: ShoppingCard) {
} }
export function ShoppingCardsPage() { export function ShoppingCardsPage() {
const t = useT()
const { locale } = useLocale()
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
const [data, setData] = useState<ShoppingCardsListResponse | null>(null) const [data, setData] = useState<ShoppingCardsListResponse | null>(null)
@@ -91,7 +96,7 @@ export function ShoppingCardsPage() {
setData(result) setData(result)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return 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 { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -109,6 +114,7 @@ export function ShoppingCardsPage() {
appliedFilters.dateTo, appliedFilters.dateTo,
appliedFilters.minTotal, appliedFilters.minTotal,
appliedFilters.maxTotal, appliedFilters.maxTotal,
t,
]) ])
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
@@ -131,7 +137,7 @@ export function ShoppingCardsPage() {
const maxTotal = parseIrtInput(draftMaxCost) const maxTotal = parseIrtInput(draftMaxCost)
if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) { if (minTotal !== null && maxTotal !== null && minTotal > maxTotal) {
setError('Minimum cost cannot be greater than maximum cost.') setError(t('orders.error.minMax'))
return return
} }
@@ -175,10 +181,10 @@ export function ShoppingCardsPage() {
items: prev.items.filter((item) => item.id !== removeTarget.id), items: prev.items.filter((item) => item.id !== removeTarget.id),
} }
}) })
showToast('Shopping card removed.', 'success') showToast(t('shoppingCards.removed'), 'success')
setRemoveTarget(null) setRemoveTarget(null)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to remove shopping card.') setError(err instanceof ApiError ? err.message : t('shoppingCards.error.remove'))
} finally { } finally {
setRemoving(false) setRemoving(false)
} }
@@ -196,66 +202,66 @@ export function ShoppingCardsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Shopping cards</h2> <h2 className={pageStyles.pageTitle}>{t('shoppingCards.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('shoppingCards.subtitle')}</p>
Saved operator carts waiting to be completed as orders.
</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <div className={filterStyles.filtersPanel}>
<div className={filterStyles.filtersTitle}>Filters</div> <div className={filterStyles.filtersTitle}>{t('orders.filters')}</div>
<div className={filterStyles.filtersGrid}> <div className={filterStyles.filtersGrid}>
<div className={styles.filtersRow}> <div className={styles.filtersRow}>
<div className={`${filterStyles.field} ${styles.filterCustomer}`}> <div className={`${filterStyles.field} ${styles.filterCustomer}`}>
<label htmlFor="filter-card-customer">Customer name or number</label>
<input <input
id="filter-card-customer" id="filter-card-customer"
value={draftCustomer} value={draftCustomer}
onChange={(e) => setDraftCustomer(e.target.value)} onChange={(e) => setDraftCustomer(e.target.value)}
placeholder="Name or phone" placeholder={t('orders.filter.customer')}
aria-label={t('orders.filter.customer')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterDate}`}> <div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-card-date-from">Date from</label>
<input <input
id="filter-card-date-from" id="filter-card-date-from"
type="date" type="date"
value={draftDateFrom} value={draftDateFrom}
onChange={(e) => setDraftDateFrom(e.target.value)} onChange={(e) => setDraftDateFrom(e.target.value)}
aria-label={t('orders.filter.dateFrom')}
title={t('orders.filter.dateFrom')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterDate}`}> <div className={`${filterStyles.field} ${styles.filterDate}`}>
<label htmlFor="filter-card-date-to">Date to</label>
<input <input
id="filter-card-date-to" id="filter-card-date-to"
type="date" type="date"
value={draftDateTo} value={draftDateTo}
onChange={(e) => setDraftDateTo(e.target.value)} onChange={(e) => setDraftDateTo(e.target.value)}
aria-label={t('orders.filter.dateTo')}
title={t('orders.filter.dateTo')}
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCost}`}> <div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-card-min-cost">Min cost (IRT)</label>
<input <input
id="filter-card-min-cost" id="filter-card-min-cost"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={draftMinCost} value={draftMinCost}
onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))} onChange={(e) => setDraftMinCost(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('orders.filter.minCost')}
aria-label={t('orders.filter.minCost')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<div className={`${filterStyles.field} ${styles.filterCost}`}> <div className={`${filterStyles.field} ${styles.filterCost}`}>
<label htmlFor="filter-card-max-cost">Max cost (IRT)</label>
<input <input
id="filter-card-max-cost" id="filter-card-max-cost"
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={draftMaxCost} value={draftMaxCost}
onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))} onChange={(e) => setDraftMaxCost(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('orders.filter.maxCost')}
aria-label={t('orders.filter.maxCost')}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -266,8 +272,8 @@ export function ShoppingCardsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
aria-label="Search" aria-label={t('orders.search')}
title="Search" title={t('orders.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -276,8 +282,8 @@ export function ShoppingCardsPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={loading} disabled={loading}
aria-label="Clear filters" aria-label={t('orders.clearFilters')}
title="Clear filters" title={t('orders.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -288,15 +294,17 @@ export function ShoppingCardsPage() {
<div className={tableStyles.tablePanel}> <div className={tableStyles.tablePanel}>
<div className={tableStyles.tableWrap}> <div className={tableStyles.tableWrap}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<div className={tableStyles.tableHeaderTitle}>Shopping card list</div> <div className={tableStyles.tableHeaderTitle}>{t('shoppingCards.listTitle')}</div>
<div className={tableStyles.meta}> <div className={tableStyles.meta}>
{data ? ( {data ? (
data.total > 0 ? ( data.total > 0 ? (
<> t('orders.showing', {
Showing {showingFrom} - {showingTo} of {data.total} 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>} {error && <div className={tableStyles.errorBanner}>{error}</div>}
<table className={tableStyles.table}> <table className={tableStyles.table} dir={locale === 'fa' ? 'rtl' : 'ltr'}>
<colgroup> <colgroup>
<col className={styles.colCustomer} /> <col className={styles.colCustomer} />
<col className={styles.colItems} /> <col className={styles.colItems} />
@@ -316,18 +324,20 @@ export function ShoppingCardsPage() {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className={tableStyles.th}>Customer</th> <th className={tableStyles.th}>{t('orders.col.customer')}</th>
<th className={tableStyles.th}>Items</th> <th className={tableStyles.th}>{t('orders.col.items')}</th>
<th className={tableStyles.th}>Total cost</th> <th className={tableStyles.th}>{t('orders.col.total')}</th>
<th className={tableStyles.th}>Date & time</th> <th className={tableStyles.th}>{t('orders.col.date')}</th>
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th> <th className={`${tableStyles.th} ${tableStyles.thActions}`}>
{t('orders.col.actions')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={tableStyles.td} colSpan={COLUMN_COUNT}> <td className={tableStyles.td} colSpan={COLUMN_COUNT}>
Loading... {t('shoppingCards.loading')}
</td> </td>
</tr> </tr>
)} )}
@@ -335,14 +345,14 @@ export function ShoppingCardsPage() {
{!loading && data?.items?.length === 0 && ( {!loading && data?.items?.length === 0 && (
<tr> <tr>
<td className={tableStyles.td} colSpan={COLUMN_COUNT}> <td className={tableStyles.td} colSpan={COLUMN_COUNT}>
No results found. {t('shoppingCards.empty')}
</td> </td>
</tr> </tr>
)} )}
{!loading && {!loading &&
data?.items?.map((card) => { data?.items?.map((card) => {
const { date, time } = formatDateTime(card.createdAt) const { date, time } = formatDateTime(card.createdAt, locale)
const itemQty = totalItemQuantity(card) const itemQty = totalItemQuantity(card)
return ( return (
@@ -363,22 +373,22 @@ export function ShoppingCardsPage() {
</td> </td>
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}> <td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
<div className={tableStyles.rowActions}> <div className={tableStyles.rowActions}>
<Tooltip label="Continue"> <Tooltip label={t('shoppingCards.continue')}>
<button <button
type="button" type="button"
className={tableStyles.actionBtn} className={tableStyles.actionBtn}
onClick={() => handleContinue(card)} onClick={() => handleContinue(card)}
aria-label="Continue shopping card" aria-label={t('shoppingCards.continueAria')}
> >
<Play size={15} /> <Play size={15} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove card"> <Tooltip label={t('shoppingCards.remove')}>
<button <button
type="button" type="button"
className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`} className={`${tableStyles.actionBtn} ${tableStyles.actionBtnDanger}`}
onClick={() => setRemoveTarget(card)} onClick={() => setRemoveTarget(card)}
aria-label="Remove shopping card" aria-label={t('shoppingCards.removeAria')}
disabled={removing} disabled={removing}
> >
<Trash2 size={15} /> <Trash2 size={15} />
@@ -394,7 +404,12 @@ export function ShoppingCardsPage() {
<div className={tableStyles.pagination}> <div className={tableStyles.pagination}>
<div> <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> </div>
<Pagination <Pagination
currentPage={page} currentPage={page}
@@ -409,10 +424,10 @@ export function ShoppingCardsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={removeTarget !== null} open={removeTarget !== null}
title="Remove shopping card?" title={t('shoppingCards.deleteTitle')}
message={ message={
removeTarget removeTarget
? `Remove this shopping card for ${displayName(removeTarget)}? This cannot be undone.` ? t('shoppingCards.deleteMessage', { name: displayName(removeTarget) })
: '' : ''
} }
onCancel={() => setRemoveTarget(null)} onCancel={() => setRemoveTarget(null)}
@@ -26,12 +26,13 @@
.fabDock { .fabDock {
position: fixed; position: fixed;
right: 32px; inset-inline-end: 32px;
inset-inline-start: auto;
bottom: 32px; bottom: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
z-index: 50; z-index: 110;
} }
.cartFabStrip { .cartFabStrip {
@@ -118,7 +119,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.fabDock { .fabDock {
right: 20px; inset-inline-end: 20px;
inset-inline-start: auto;
bottom: 20px; bottom: 20px;
gap: 8px; gap: 8px;
} }
+34 -34
View File
@@ -31,6 +31,8 @@ import {
type StoreProductListing, type StoreProductListing,
} from '../utils/storeProductGroups' } from '../utils/storeProductGroups'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' 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 filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './StoreItemsPage.module.css' import styles from './StoreItemsPage.module.css'
@@ -40,6 +42,8 @@ export function StoreItemsPage() {
} }
function StoreItemsPageContent() { function StoreItemsPageContent() {
const t = useT()
const { locale } = useLocale()
const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart() const { itemCount, hasItems, addVariant, loadShoppingCard } = useDraftCart()
const { showToast } = useToast() const { showToast } = useToast()
const [items, setItems] = useState<StoreItem[]>([]) const [items, setItems] = useState<StoreItem[]>([])
@@ -139,7 +143,7 @@ function StoreItemsPageContent() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load store items.') setError(t('storeItems.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -188,7 +192,7 @@ function StoreItemsPageContent() {
return return
} }
showToast('Added to shopping cart.', 'success') showToast(t('storeItems.addedToCart'), 'success')
} }
function handleVariantPicked(variant: StoreItem) { function handleVariantPicked(variant: StoreItem) {
@@ -198,7 +202,7 @@ function StoreItemsPageContent() {
showToast(feedback, 'error') showToast(feedback, 'error')
return return
} }
showToast('Added to shopping cart.', 'success') showToast(t('storeItems.addedToCart'), 'success')
} }
async function confirmDelete() { async function confirmDelete() {
@@ -215,7 +219,7 @@ function StoreItemsPageContent() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to remove store item.') setError(t('storeItems.errorRemove'))
} }
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
@@ -234,65 +238,60 @@ function StoreItemsPageContent() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>My Store Items</h2> <h2 className={pageStyles.pageTitle}>{t('title.storeItems')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('storeItems.subtitle')}</p>
Products for sale with one or more priced variants.
</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <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.filtersGrid}>
<div className={filterStyles.filtersInputs}> <div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol3}`}>
<label htmlFor="filter-store-name">Name</label>
<input <input
id="filter-store-name" id="filter-store-name"
value={draftName} value={draftName}
onChange={(e) => setDraftName(e.target.value)} onChange={(e) => setDraftName(e.target.value)}
placeholder="Search by product name" placeholder={t('storeItems.filterNamePlaceholder')}
aria-label={t('storeItems.filterName')}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<label htmlFor="filter-store-min-price">Min price (IRT)</label>
<input <input
id="filter-store-min-price" id="filter-store-min-price"
inputMode="numeric" inputMode="numeric"
value={draftMinPrice} value={draftMinPrice}
onChange={(e) => setDraftMinPrice(formatIrtInput(e.target.value))} onChange={(e) => setDraftMinPrice(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('storeItems.minPrice')}
aria-label={t('storeItems.minPrice')}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol2} ${filterStyles.fieldHalfWidth}`}>
<label htmlFor="filter-store-max-price">Max price (IRT)</label>
<input <input
id="filter-store-max-price" id="filter-store-max-price"
inputMode="numeric" inputMode="numeric"
value={draftMaxPrice} value={draftMaxPrice}
onChange={(e) => setDraftMaxPrice(formatIrtInput(e.target.value))} onChange={(e) => setDraftMaxPrice(formatIrtInput(e.target.value))}
placeholder="0" placeholder={t('storeItems.maxPrice')}
aria-label={t('storeItems.maxPrice')}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<div className={`${filterStyles.switchField} ${filterStyles.fieldCol3}`}> <div className={`${filterStyles.switchField} ${filterStyles.fieldCol3}`}>
<span className={filterStyles.switchFieldSpacer} aria-hidden="true">
Only discounted
</span>
<div className={filterStyles.switchInline}> <div className={filterStyles.switchInline}>
<ToggleSwitch <ToggleSwitch
checked={draftOnlyDiscounted} checked={draftOnlyDiscounted}
onChange={setDraftOnlyDiscounted} onChange={setDraftOnlyDiscounted}
disabled={isLoading} disabled={isLoading}
ariaLabel="Only discounted" ariaLabel={t('storeItems.onlyDiscounted')}
/> />
<span <span
className={filterStyles.switchLabel} className={filterStyles.switchLabel}
onClick={() => !isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)} onClick={() => !isLoading && setDraftOnlyDiscounted(!draftOnlyDiscounted)}
> >
Only discounted {t('storeItems.onlyDiscounted')}
</span> </span>
</div> </div>
</div> </div>
@@ -303,8 +302,8 @@ function StoreItemsPageContent() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={isLoading} disabled={isLoading}
aria-label="Search" aria-label={t('storeItems.search')}
title="Search" title={t('storeItems.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -313,8 +312,8 @@ function StoreItemsPageContent() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={isLoading} disabled={isLoading}
aria-label="Clear filters" aria-label={t('storeItems.clearFilters')}
title="Clear filters" title={t('storeItems.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -325,13 +324,11 @@ function StoreItemsPageContent() {
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading store items...</p> <p className={styles.empty}>{t('storeItems.loading')}</p>
) : listings.length === 0 ? ( ) : listings.length === 0 ? (
<p className={styles.empty}> <p className={styles.empty}>{t('storeItems.empty')}</p>
No store items yet. Use the + button to add products from your catalog.
</p>
) : filteredListings.length === 0 ? ( ) : 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}> <div className={styles.grid}>
{filteredListings.map((listing) => ( {filteredListings.map((listing) => (
@@ -355,11 +352,11 @@ function StoreItemsPageContent() {
type="button" type="button"
className={styles.cartFabStrip} className={styles.cartFabStrip}
onClick={() => setCartOpen(true)} 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.cartFabCount}>{itemCount}</span>
<span className={styles.cartFabLabel}> <span className={styles.cartFabLabel}>
{itemCount === 1 ? 'item in cart' : 'items in cart'} {itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
</span> </span>
</button> </button>
)} )}
@@ -367,7 +364,7 @@ function StoreItemsPageContent() {
type="button" type="button"
className={styles.addFab} className={styles.addFab}
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="Add store items" aria-label={t('storeItems.add')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -408,10 +405,13 @@ function StoreItemsPageContent() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteTarget} open={!!deleteTarget}
title="Remove from Store" title={t('storeItems.deleteTitle')}
message={ message={
deleteTarget 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()} onConfirm={() => void confirmDelete()}
+29 -21
View File
@@ -7,47 +7,51 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { SectionCard } from '../components/SectionCard' import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const storeSections = [ const storeSections: {
icon: typeof Package
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
href: string
}[] = [
{ {
icon: Package, icon: Package,
title: 'My Store Items', titleKey: 'nav.store.items',
description: 'View and manage all items listed in your store.', descKey: 'store.card.items.desc',
linkText: 'View items',
href: '/store/items', href: '/store/items',
}, },
{ {
icon: ShoppingCart, icon: ShoppingCart,
title: 'My Orders', titleKey: 'nav.store.orders',
description: 'Track and manage customer orders and fulfillment.', descKey: 'store.card.orders.desc',
linkText: 'View orders',
href: '/store/orders', href: '/store/orders',
}, },
{ {
icon: Truck, icon: Truck,
title: 'Shipping Fees', titleKey: 'nav.store.shipping',
description: 'Configure shipping rates, zones and delivery options.', descKey: 'store.card.shipping.desc',
linkText: 'Manage shipping',
href: '/store/shipping', href: '/store/shipping',
}, },
{ {
icon: CreditCard, icon: CreditCard,
title: 'Shopping Cards', titleKey: 'nav.store.cards',
description: 'Manage saved shopping cards and payment methods.', descKey: 'store.card.cards.desc',
linkText: 'View cards',
href: '/store/cards', href: '/store/cards',
}, },
{ {
icon: Settings, icon: Settings,
title: 'Settings', titleKey: 'nav.store.settings',
description: 'Configure store preferences, pages and themes.', descKey: 'store.card.settings.desc',
linkText: 'View settings',
href: '/store/settings', href: '/store/settings',
}, },
] ]
export function StorePage() { export function StorePage() {
const t = useT()
return ( return (
<main className={styles.content}> <main className={styles.content}>
<Breadcrumbs <Breadcrumbs
@@ -58,16 +62,20 @@ export function StorePage() {
/> />
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>Store</h2> <h2 className={styles.pageTitle}>{t('title.store')}</h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('store.page.subtitle')}</p>
Manage your store items, orders and settings.
</p>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{storeSections.map((section) => ( {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> </div>
</main> </main>
+35 -39
View File
@@ -5,6 +5,7 @@ import { StepColorPicker } from '../components/StepColorPicker'
import { Switch } from '../components/Switch' import { Switch } from '../components/Switch'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
getSettings, getSettings,
@@ -53,6 +54,7 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
} }
export function StoreSettingsPage() { export function StoreSettingsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [settings, setSettings] = useState<StoreSettings>(DEFAULT_STORE_SETTINGS) const [settings, setSettings] = useState<StoreSettings>(DEFAULT_STORE_SETTINGS)
const [draftSteps, setDraftSteps] = useState<OrderProcessStep[]>( const [draftSteps, setDraftSteps] = useState<OrderProcessStep[]>(
@@ -81,7 +83,7 @@ export function StoreSettingsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load settings.') setError(t('productSettings.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -95,12 +97,12 @@ export function StoreSettingsPage() {
try { try {
const data = await updateStoreSettings({ onlineSellEnabled: checked }) const data = await updateStoreSettings({ onlineSellEnabled: checked })
setSettings(data.settings.store) setSettings(data.settings.store)
showToast('Settings saved.', 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save settings.') setError(t('productSettings.errorSave'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -163,11 +165,11 @@ export function StoreSettingsPage() {
const normalized = normalizeSteps(draftSteps) const normalized = normalizeSteps(draftSteps)
const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa) const hasEmptyLabel = normalized.some((step) => !step.label || !step.labelFa)
if (!normalized.length) { if (!normalized.length) {
setError('Add at least one order process step.') setError(t('storeSettings.error.minSteps'))
return return
} }
if (hasEmptyLabel) { if (hasEmptyLabel) {
setError('Every order step needs an English and Farsi label.') setError(t('storeSettings.error.labels'))
return return
} }
@@ -178,12 +180,12 @@ export function StoreSettingsPage() {
const data = await updateStoreSettings({ orderProcessSteps: normalized }) const data = await updateStoreSettings({ orderProcessSteps: normalized })
setSettings(data.settings.store) setSettings(data.settings.store)
setDraftSteps(data.settings.store.orderProcessSteps) setDraftSteps(data.settings.store.orderProcessSteps)
showToast('Order process steps saved.', 'success') showToast(t('storeSettings.stepsSaved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to save order process steps.') setError(t('storeSettings.error.saveSteps'))
} }
} finally { } finally {
setSavingKey(null) setSavingKey(null)
@@ -213,10 +215,8 @@ export function StoreSettingsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Store settings</h2> <h2 className={pageStyles.pageTitle}>{t('storeSettings.title')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('storeSettings.subtitle')}</p>
Control online sales and define how orders move through fulfillment.
</p>
</div> </div>
</div> </div>
@@ -227,27 +227,26 @@ export function StoreSettingsPage() {
)} )}
<section className={sharedStyles.panel}> <section className={sharedStyles.panel}>
<h3 className={sharedStyles.sectionTitle}>Sales</h3> <h3 className={sharedStyles.sectionTitle}>{t('storeSettings.sales')}</h3>
{isLoading ? ( {isLoading ? (
<p className={sharedStyles.status}>Loading settings...</p> <p className={sharedStyles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<div className={sharedStyles.list}> <div className={sharedStyles.list}>
<div className={sharedStyles.row}> <div className={sharedStyles.row}>
<div className={sharedStyles.rowText}> <div className={sharedStyles.rowText}>
<label htmlFor="online-sell" className={sharedStyles.rowLabel}> <label htmlFor="online-sell" className={sharedStyles.rowLabel}>
Online sell {t('storeSettings.onlineSell')}
</label> </label>
<p className={sharedStyles.rowDescription}> <p className={sharedStyles.rowDescription}>
When disabled, all sales on your website are turned off. Customers {t('storeSettings.onlineSellDesc')}
will not be able to place new orders online.
</p> </p>
</div> </div>
<Switch <Switch
id="online-sell" id="online-sell"
checked={settings.onlineSellEnabled} checked={settings.onlineSellEnabled}
disabled={savingKey === 'onlineSell'} disabled={savingKey === 'onlineSell'}
aria-label="Online sell" aria-label={t('storeSettings.onlineSell')}
onChange={(checked) => void handleOnlineSellChange(checked)} onChange={(checked) => void handleOnlineSellChange(checked)}
/> />
</div> </div>
@@ -258,16 +257,13 @@ export function StoreSettingsPage() {
<section className={`${sharedStyles.panel} ${styles.stepsPanel}`}> <section className={`${sharedStyles.panel} ${styles.stepsPanel}`}>
<div className={styles.stepsHeader}> <div className={styles.stepsHeader}>
<div> <div>
<h3 className={sharedStyles.sectionTitle}>Order process</h3> <h3 className={sharedStyles.sectionTitle}>{t('storeSettings.orderProcess')}</h3>
<p className={styles.stepsDescription}> <p className={styles.stepsDescription}>{t('storeSettings.orderProcessDesc')}</p>
Define the steps an order can move through for example: under
processing, ready for shipping, shipped, delivered.
</p>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
<p className={sharedStyles.status}>Loading settings...</p> <p className={sharedStyles.status}>{t('productSettings.loading')}</p>
) : ( ) : (
<> <>
<div className={styles.stepsList}> <div className={styles.stepsList}>
@@ -277,14 +273,14 @@ export function StoreSettingsPage() {
<StepColorPicker <StepColorPicker
value={normalizeStepColor(step.color, defaultStepColorForId(step.id, index))} value={normalizeStepColor(step.color, defaultStepColorForId(step.id, index))}
onChange={(color) => updateStepColor(step.id, color)} onChange={(color) => updateStepColor(step.id, color)}
ariaLabel={`Color for step ${index + 1}`} ariaLabel={t('storeSettings.stepColorAria', { index: index + 1 })}
/> />
<input <input
type="text" type="text"
className={styles.stepInput} className={styles.stepInput}
value={step.label} value={step.label}
placeholder="Label (EN)" placeholder={t('storeSettings.labelEn')}
aria-label={`Order step ${index + 1} English label`} aria-label={t('storeSettings.stepEnAria', { index: index + 1 })}
dir="ltr" dir="ltr"
lang="en" lang="en"
onChange={(e) => updateStepLabel(step.id, e.target.value)} onChange={(e) => updateStepLabel(step.id, e.target.value)}
@@ -293,42 +289,42 @@ export function StoreSettingsPage() {
type="text" type="text"
className={`${styles.stepInput} ${styles.stepInputFa}`} className={`${styles.stepInput} ${styles.stepInputFa}`}
value={step.labelFa ?? ''} value={step.labelFa ?? ''}
placeholder="عنوان (فارسی)" placeholder={t('storeSettings.labelFa')}
aria-label={`Order step ${index + 1} Farsi label`} aria-label={t('storeSettings.stepFaAria', { index: index + 1 })}
dir="rtl" dir="rtl"
lang="fa" lang="fa"
onChange={(e) => updateStepLabelFa(step.id, e.target.value)} onChange={(e) => updateStepLabelFa(step.id, e.target.value)}
/> />
<div className={styles.stepControls}> <div className={styles.stepControls}>
<Tooltip label="Move step up"> <Tooltip label={t('storeSettings.moveUp')}>
<button <button
type="button" type="button"
className={controlStyles.controlBtn} className={controlStyles.controlBtn}
onClick={() => moveStep(step.id, -1)} onClick={() => moveStep(step.id, -1)}
disabled={index === 0} disabled={index === 0}
aria-label="Move step up" aria-label={t('storeSettings.moveUp')}
> >
<ChevronUp size={16} /> <ChevronUp size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Move step down"> <Tooltip label={t('storeSettings.moveDown')}>
<button <button
type="button" type="button"
className={controlStyles.controlBtn} className={controlStyles.controlBtn}
onClick={() => moveStep(step.id, 1)} onClick={() => moveStep(step.id, 1)}
disabled={index === draftSteps.length - 1} disabled={index === draftSteps.length - 1}
aria-label="Move step down" aria-label={t('storeSettings.moveDown')}
> >
<ChevronDown size={16} /> <ChevronDown size={16} />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip label="Remove step"> <Tooltip label={t('storeSettings.removeStep')}>
<button <button
type="button" type="button"
className={removeStyles.removeRowBtn} className={removeStyles.removeRowBtn}
onClick={() => removeStep(step.id)} onClick={() => removeStep(step.id)}
disabled={draftSteps.length <= 1} disabled={draftSteps.length <= 1}
aria-label="Remove step" aria-label={t('storeSettings.removeStep')}
> >
<Trash2 size={15} /> <Trash2 size={15} />
</button> </button>
@@ -338,15 +334,13 @@ export function StoreSettingsPage() {
))} ))}
{!draftSteps.length && ( {!draftSteps.length && (
<p className={styles.emptyText}> <p className={styles.emptyText}>{t('storeSettings.emptySteps')}</p>
No order steps yet. Add the first step to define your workflow.
</p>
)} )}
</div> </div>
<button type="button" className={styles.addStepBtn} onClick={addStep}> <button type="button" className={styles.addStepBtn} onClick={addStep}>
<Plus size={18} /> <Plus size={18} />
Add step {t('storeSettings.addStep')}
</button> </button>
<div className={styles.stepsActions}> <div className={styles.stepsActions}>
@@ -356,7 +350,9 @@ export function StoreSettingsPage() {
onClick={() => void handleSaveSteps()} onClick={() => void handleSaveSteps()}
disabled={!canSaveSteps || savingKey === 'orderSteps'} disabled={!canSaveSteps || savingKey === 'orderSteps'}
> >
{savingKey === 'orderSteps' ? 'Saving...' : 'Save steps'} {savingKey === 'orderSteps'
? t('storeSettings.saving')
: t('storeSettings.saveSteps')}
</button> </button>
</div> </div>
</> </>
+42 -35
View File
@@ -12,6 +12,7 @@ import { StoreSpecialCarousel } from '../components/StoreSpecialCarousel'
import { StoreSpecialModal } from '../components/StoreSpecialModal' import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { useDraftCart } from '../context/DraftCartContext' import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
createStoreSpecial, createStoreSpecial,
@@ -27,6 +28,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css' import styles from './StoreSpecialsPage.module.css'
export function StoreSpecialsPage() { export function StoreSpecialsPage() {
const t = useT()
const { itemCount, hasItems, addVariant } = useDraftCart() const { itemCount, hasItems, addVariant } = useDraftCart()
const { showToast } = useToast() const { showToast } = useToast()
const [specials, setSpecials] = useState<StoreSpecial[]>([]) const [specials, setSpecials] = useState<StoreSpecial[]>([])
@@ -73,7 +75,7 @@ export function StoreSpecialsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load special categories.') setError(t('website.specialItems.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -141,45 +143,49 @@ export function StoreSpecialsPage() {
) )
} }
async function handleCreateSpecial(title: string) { async function handleCreateSpecial(values: { key: string; title: string }) {
setIsSaving(true) setIsSaving(true)
setError('') setError('')
try { try {
const result = await createStoreSpecial({ const result = await createStoreSpecial({
title, key: values.key,
title: values.title,
sortOrder: specials.length, sortOrder: specials.length,
}) })
setSpecials((prev) => [...prev, result.special]) setSpecials((prev) => [...prev, result.special])
setCreateOpen(false) setCreateOpen(false)
showToast('Special category created.', 'success') showToast(t('website.specialItems.toastCreated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create special category.') setError(t('website.specialItems.errorCreate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
} }
async function handleEditSpecial(title: string) { async function handleEditSpecial(values: { key: string; title: string }) {
if (!editSpecialTarget) return if (!editSpecialTarget) return
setIsSaving(true) setIsSaving(true)
setError('') setError('')
try { try {
const result = await updateStoreSpecial(editSpecialTarget.id, { title }) const result = await updateStoreSpecial(editSpecialTarget.id, {
key: values.key,
title: values.title,
})
replaceSpecial(result.special) replaceSpecial(result.special)
setEditSpecialTarget(null) setEditSpecialTarget(null)
showToast('Special category updated.', 'success') showToast(t('website.specialItems.toastUpdated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to update special category.') setError(t('website.specialItems.errorUpdate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -196,12 +202,12 @@ export function StoreSpecialsPage() {
await deleteStoreSpecial(deleteSpecialTarget.id) await deleteStoreSpecial(deleteSpecialTarget.id)
setSpecials((prev) => prev.filter((entry) => entry.id !== deleteSpecialTarget.id)) setSpecials((prev) => prev.filter((entry) => entry.id !== deleteSpecialTarget.id))
setDeleteSpecialTarget(null) setDeleteSpecialTarget(null)
showToast('Special category deleted.', 'success') showToast(t('website.specialItems.toastDeleted'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete special category.') setError(t('website.specialItems.errorDelete'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -223,12 +229,12 @@ export function StoreSpecialsPage() {
}) })
replaceSpecial(result.special) replaceSpecial(result.special)
setPickItemsTarget(null) setPickItemsTarget(null)
showToast('Store items added to special category.', 'success') showToast(t('website.specialItems.toastAdded'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to add store items.') setError(t('website.specialItems.errorAdd'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -253,12 +259,12 @@ export function StoreSpecialsPage() {
}) })
replaceSpecial(result.special) replaceSpecial(result.special)
setRemoveTarget(null) setRemoveTarget(null)
showToast('Removed from special category.', 'success') showToast(t('website.specialItems.toastRemoved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to remove store item.') setError(t('website.specialItems.errorRemove'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -280,7 +286,7 @@ export function StoreSpecialsPage() {
return return
} }
showToast('Added to shopping cart.', 'success') showToast(t('storeItems.addedToCart'), 'success')
} }
function handleVariantPicked(variant: StoreItem) { function handleVariantPicked(variant: StoreItem) {
@@ -290,7 +296,7 @@ export function StoreSpecialsPage() {
showToast(feedback, 'error') showToast(feedback, 'error')
return return
} }
showToast('Added to shopping cart.', 'success') showToast(t('storeItems.addedToCart'), 'success')
} }
return ( return (
@@ -305,22 +311,17 @@ export function StoreSpecialsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Special Items</h2> <h2 className={pageStyles.pageTitle}>{t('title.specialItems')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('website.specialItems.subtitle')}</p>
Curate featured store items into categories for your website carousels.
</p>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading special categories...</p> <p className={styles.empty}>{t('website.specialItems.loading')}</p>
) : specials.length === 0 ? ( ) : specials.length === 0 ? (
<p className={styles.empty}> <p className={styles.empty}>{t('website.specialItems.empty')}</p>
No special categories yet. Use the + button to create one, then add store items to each
carousel.
</p>
) : ( ) : (
<div className={styles.carousels}> <div className={styles.carousels}>
{specials.map((special) => ( {specials.map((special) => (
@@ -349,11 +350,11 @@ export function StoreSpecialsPage() {
type="button" type="button"
className={fabStyles.cartFabStrip} className={fabStyles.cartFabStrip}
onClick={() => setCartOpen(true)} 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.cartFabCount}>{itemCount}</span>
<span className={fabStyles.cartFabLabel}> <span className={fabStyles.cartFabLabel}>
{itemCount === 1 ? 'item in cart' : 'items in cart'} {itemCount === 1 ? t('storeItems.cartOne') : t('storeItems.cartMany')}
</span> </span>
</button> </button>
)} )}
@@ -361,7 +362,7 @@ export function StoreSpecialsPage() {
type="button" type="button"
className={fabStyles.addFab} className={fabStyles.addFab}
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="Add special category" aria-label={t('website.specialItems.addFab')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -371,6 +372,8 @@ export function StoreSpecialsPage() {
open={createOpen} open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)} onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateSpecial} onSubmit={handleCreateSpecial}
title={t('website.specialItems.createTitle')}
submitLabel={t('website.create')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -378,9 +381,10 @@ export function StoreSpecialsPage() {
open={!!editSpecialTarget} open={!!editSpecialTarget}
onClose={() => !isSaving && setEditSpecialTarget(null)} onClose={() => !isSaving && setEditSpecialTarget(null)}
onSubmit={handleEditSpecial} onSubmit={handleEditSpecial}
initialKey={editSpecialTarget?.key ?? ''}
initialTitle={editSpecialTarget?.title ?? ''} initialTitle={editSpecialTarget?.title ?? ''}
title="Edit Special Category" title={t('website.specialItems.editTitle')}
submitLabel="Save" submitLabel={t('website.save')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -420,10 +424,10 @@ export function StoreSpecialsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteSpecialTarget} open={!!deleteSpecialTarget}
title="Delete Special Category" title={t('website.specialItems.deleteTitle')}
message={ message={
deleteSpecialTarget deleteSpecialTarget
? `Delete "${deleteSpecialTarget.title}"? Store items will remain in your catalog.` ? t('website.specialItems.deleteMessage', { title: deleteSpecialTarget.title })
: '' : ''
} }
onConfirm={() => void confirmDeleteSpecial()} onConfirm={() => void confirmDeleteSpecial()}
@@ -432,10 +436,13 @@ export function StoreSpecialsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTarget} open={!!removeTarget}
title="Remove from Special" title={t('website.specialItems.removeTitle')}
message={ message={
removeTarget removeTarget
? `Remove "${removeTarget.listing.productTitle}" from "${removeTarget.special.title}"?` ? t('website.specialItems.removeMessage', {
name: removeTarget.listing.productTitle,
group: removeTarget.special.title,
})
: '' : ''
} }
onConfirm={() => void confirmRemoveFromSpecial()} onConfirm={() => void confirmRemoveFromSpecial()}
+10 -11
View File
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell' import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css' import styles from './WebsitePage.module.css'
export function WebsiteBadgesPage() { export function WebsiteBadgesPage() {
const t = useT()
return ( return (
<WebsiteSectionShell <WebsiteSectionShell
sectionLabel="Badges" sectionLabel="Badges"
title="Badges" title={t('title.badges')}
subtitle="Show trust badges and highlights on your public website." subtitle={t('website.badges.subtitle')}
> >
<p className={styles.placeholderLead}> <p className={styles.placeholderLead}>{t('website.badges.lead')}</p>
Display certifications, guarantees, and trust signals to website visitors. <p className={styles.placeholderNote}>{t('website.badges.note')}</p>
</p>
<p className={styles.placeholderNote}>
Badge management will be connected here. Planned options include:
</p>
<ul className={styles.featureList}> <ul className={styles.featureList}>
<li>Upload badge images with title and link</li> <li>{t('website.badges.feature.upload')}</li>
<li>Reorder badges for homepage or footer display</li> <li>{t('website.badges.feature.reorder')}</li>
<li>Toggle visibility per badge</li> <li>{t('website.badges.feature.toggle')}</li>
</ul> </ul>
</WebsiteSectionShell> </WebsiteSectionShell>
) )
+42 -32
View File
@@ -1,13 +1,13 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { RotateCcw, Search } from 'lucide-react' import { RotateCcw, Search } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal' import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { useT } from '../i18n/useT'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import { listContactSubmissions } from '../services/contactSubmissionService'
listContactSubmissions,
} from '../services/contactSubmissionService'
import type { import type {
ContactSubmission, ContactSubmission,
ContactSubmissionsListResponse, ContactSubmissionsListResponse,
@@ -19,16 +19,19 @@ import styles from './WebsiteContactPage.module.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const COLUMN_COUNT = 6 const COLUMN_COUNT = 6
function formatDateTime(value: string) { function formatDateTime(value: string, locale: 'en' | 'fa') {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
return { return {
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }), date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
} }
} }
export function WebsiteContactPage() { export function WebsiteContactPage() {
const t = useT()
const { locale } = useLocale()
const [data, setData] = useState<ContactSubmissionsListResponse | null>(null) const [data, setData] = useState<ContactSubmissionsListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -58,7 +61,7 @@ export function WebsiteContactPage() {
setData(result) setData(result)
} catch (err) { } catch (err) {
if (isAbortError(err) || controller.signal.aborted) return 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 { } finally {
if (!controller.signal.aborted) setLoading(false) if (!controller.signal.aborted) setLoading(false)
} }
@@ -109,24 +112,22 @@ export function WebsiteContactPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Contact us form</h2> <h2 className={pageStyles.pageTitle}>{t('title.contactForm')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('website.contact.subtitle')}</p>
Submissions received from your website contact form.
</p>
</div> </div>
</div> </div>
<div className={filterStyles.filtersPanel}> <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.filtersGrid}>
<div className={filterStyles.filtersInputs}> <div className={filterStyles.filtersInputs}>
<div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}> <div className={`${filterStyles.field} ${filterStyles.fieldCol4}`}>
<label htmlFor="filter-contact-q">Search</label>
<input <input
id="filter-contact-q" id="filter-contact-q"
value={draftQuery} value={draftQuery}
onChange={(e) => setDraftQuery(e.target.value)} 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) => { onKeyDown={(e) => {
if (e.key === 'Enter') applyFilters() if (e.key === 'Enter') applyFilters()
}} }}
@@ -139,8 +140,8 @@ export function WebsiteContactPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
onClick={applyFilters} onClick={applyFilters}
disabled={loading} disabled={loading}
aria-label="Search" aria-label={t('website.contact.search')}
title="Search" title={t('website.contact.search')}
> >
<Search size={18} /> <Search size={18} />
</button> </button>
@@ -149,8 +150,8 @@ export function WebsiteContactPage() {
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`} className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
onClick={clearFilters} onClick={clearFilters}
disabled={loading} disabled={loading}
aria-label="Clear filters" aria-label={t('website.contact.clearFilters')}
title="Clear filters" title={t('website.contact.clearFilters')}
> >
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
@@ -161,15 +162,19 @@ export function WebsiteContactPage() {
<div className={styles.tablePanel}> <div className={styles.tablePanel}>
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
<div className={styles.tableHeader}> <div className={styles.tableHeader}>
<div className={styles.tableHeaderTitle}>Submissions</div> <div className={styles.tableHeaderTitle}>{t('website.contact.listTitle')}</div>
<div className={styles.meta}> <div className={styles.meta}>
{data ? ( {data ? (
data.total > 0 ? ( 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}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th className={styles.th}>Title</th> <th className={styles.th}>{t('website.contact.col.title')}</th>
<th className={styles.th}>Name</th> <th className={styles.th}>{t('website.contact.col.name')}</th>
<th className={styles.th}>Email</th> <th className={styles.th}>{t('website.contact.col.email')}</th>
<th className={styles.th}>Cell number</th> <th className={styles.th}>{t('website.contact.col.cell')}</th>
<th className={styles.th}>Date</th> <th className={styles.th}>{t('website.contact.col.date')}</th>
<th className={styles.th}>Time</th> <th className={styles.th}>{t('website.contact.col.time')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading && ( {loading && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
Loading... {t('website.contact.loading')}
</td> </td>
</tr> </tr>
)} )}
@@ -203,14 +208,14 @@ export function WebsiteContactPage() {
{!loading && data?.items.length === 0 && ( {!loading && data?.items.length === 0 && (
<tr> <tr>
<td className={styles.td} colSpan={COLUMN_COUNT}> <td className={styles.td} colSpan={COLUMN_COUNT}>
No submissions found. {t('website.contact.empty')}
</td> </td>
</tr> </tr>
)} )}
{!loading && {!loading &&
data?.items.map((item) => { data?.items.map((item) => {
const { date, time } = formatDateTime(item.createdAt) const { date, time } = formatDateTime(item.createdAt, locale)
return ( return (
<tr <tr
key={item.id} key={item.id}
@@ -224,7 +229,7 @@ export function WebsiteContactPage() {
}} }}
tabIndex={0} tabIndex={0}
role="button" 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.title}</td>
<td className={styles.td}>{item.name}</td> <td className={styles.td}>{item.name}</td>
@@ -243,7 +248,12 @@ export function WebsiteContactPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <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> </div>
<Pagination <Pagination
currentPage={page} currentPage={page}
+10 -11
View File
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell' import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css' import styles from './WebsitePage.module.css'
export function WebsiteEPaymentPage() { export function WebsiteEPaymentPage() {
const t = useT()
return ( return (
<WebsiteSectionShell <WebsiteSectionShell
sectionLabel="E-Payment" sectionLabel="E-Payment"
title="E-Payment" title={t('title.ePayment')}
subtitle="Configure online payment methods for your website checkout." subtitle={t('website.ePayment.subtitle')}
> >
<p className={styles.placeholderLead}> <p className={styles.placeholderLead}>{t('website.ePayment.lead')}</p>
Connect payment gateways so customers can pay online through your store. <p className={styles.placeholderNote}>{t('website.ePayment.note')}</p>
</p>
<p className={styles.placeholderNote}>
E-payment settings will be connected here. Planned options include:
</p>
<ul className={styles.featureList}> <ul className={styles.featureList}>
<li>Enable or disable online payments</li> <li>{t('website.ePayment.feature.enable')}</li>
<li>Configure payment provider credentials</li> <li>{t('website.ePayment.feature.credentials')}</li>
<li>Set supported payment methods and test mode</li> <li>{t('website.ePayment.feature.methods')}</li>
</ul> </ul>
</WebsiteSectionShell> </WebsiteSectionShell>
) )
+10 -11
View File
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell' import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css' import styles from './WebsitePage.module.css'
export function WebsiteFaqPage() { export function WebsiteFaqPage() {
const t = useT()
return ( return (
<WebsiteSectionShell <WebsiteSectionShell
sectionLabel="FAQ" sectionLabel="FAQ"
title="FAQ" title={t('title.faq')}
subtitle="Create and organize frequently asked questions for your website." subtitle={t('website.faq.subtitle')}
> >
<p className={styles.placeholderLead}> <p className={styles.placeholderLead}>{t('website.faq.lead')}</p>
Build an FAQ section to answer common customer questions before they contact you. <p className={styles.placeholderNote}>{t('website.faq.note')}</p>
</p>
<p className={styles.placeholderNote}>
FAQ editor will be connected here. Planned options include:
</p>
<ul className={styles.featureList}> <ul className={styles.featureList}>
<li>Add, edit, and reorder questions and answers</li> <li>{t('website.faq.feature.crud')}</li>
<li>Group items by category</li> <li>{t('website.faq.feature.groups')}</li>
<li>Publish or hide individual entries</li> <li>{t('website.faq.feature.publish')}</li>
</ul> </ul>
</WebsiteSectionShell> </WebsiteSectionShell>
) )
+48 -33
View File
@@ -11,75 +11,85 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { SectionCard } from '../components/SectionCard' import { SectionCard } from '../components/SectionCard'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import styles from '../components/PageContent.module.css' import styles from '../components/PageContent.module.css'
const websiteSections = [ const websiteSections: {
icon: typeof Images
titleKey: BusinessMessageKey
descKey: BusinessMessageKey
linkKey: BusinessMessageKey
href: string
}[] = [
{ {
icon: Images, icon: Images,
title: 'Sliders', titleKey: 'nav.website.sliders',
description: 'Manage homepage banner sliders and promotional image carousels.', descKey: 'website.card.sliders.desc',
linkText: 'Manage sliders', linkKey: 'website.card.sliders.link',
href: '/website/sliders', href: '/website/sliders',
}, },
{ {
icon: LayoutGrid, icon: LayoutGrid,
title: 'Special Categories', titleKey: 'nav.website.specialCategories',
description: 'Highlight selected product categories on your website homepage.', descKey: 'website.card.specialCategories.desc',
linkText: 'Manage categories', linkKey: 'website.card.specialCategories.link',
href: '/website/special-categories', href: '/website/special-categories',
}, },
{ {
icon: Building2, icon: Building2,
title: 'Special Brands', titleKey: 'nav.website.specialBrands',
description: 'Showcase partner or featured brands on your website homepage.', descKey: 'website.card.specialBrands.desc',
linkText: 'Manage brands', linkKey: 'website.card.specialBrands.link',
href: '/website/special-brands', href: '/website/special-brands',
}, },
{ {
icon: Sparkles, icon: Sparkles,
title: 'Special Items', titleKey: 'nav.website.specialItems',
description: 'Curate featured store items into categories for your website carousels.', descKey: 'website.card.specialItems.desc',
linkText: 'Manage special items', linkKey: 'website.card.specialItems.link',
href: '/website/special-items', href: '/website/special-items',
}, },
{ {
icon: Mail, icon: Mail,
title: 'Contact Us Form', titleKey: 'nav.website.contact',
description: 'View submissions from your website contact form.', descKey: 'website.card.contact.desc',
linkText: 'View submissions', linkKey: 'website.card.contact.link',
href: '/website/contact', href: '/website/contact',
}, },
{ {
icon: BellRing, icon: BellRing,
title: 'Subscriptions', titleKey: 'nav.website.subscriptions',
description: 'Manage newsletter sign-ups and subscription options for visitors.', descKey: 'website.card.subscriptions.desc',
linkText: 'Manage subscriptions', linkKey: 'website.card.subscriptions.link',
href: '/website/subscriptions', href: '/website/subscriptions',
}, },
{ {
icon: CircleHelp, icon: CircleHelp,
title: 'FAQ', titleKey: 'nav.website.faq',
description: 'Create and organize frequently asked questions for your website.', descKey: 'website.card.faq.desc',
linkText: 'Manage FAQ', linkKey: 'website.card.faq.link',
href: '/website/faq', href: '/website/faq',
}, },
{ {
icon: Award, icon: Award,
title: 'Badges', titleKey: 'nav.website.badges',
description: 'Show trust badges, certifications, and highlights on your website.', descKey: 'website.card.badges.desc',
linkText: 'Manage badges', linkKey: 'website.card.badges.link',
href: '/website/badges', href: '/website/badges',
}, },
{ {
icon: CreditCard, icon: CreditCard,
title: 'E-Payment', titleKey: 'nav.website.ePayment',
description: 'Configure online payment gateways and checkout payment options.', descKey: 'website.card.ePayment.desc',
linkText: 'Manage e-payment', linkKey: 'website.card.ePayment.link',
href: '/website/e-payment', href: '/website/e-payment',
}, },
] ]
export function WebsitePage() { export function WebsitePage() {
const t = useT()
return ( return (
<main className={styles.content}> <main className={styles.content}>
<Breadcrumbs <Breadcrumbs
@@ -90,16 +100,21 @@ export function WebsitePage() {
/> />
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<div> <div>
<h2 className={styles.pageTitle}>Website</h2> <h2 className={styles.pageTitle}>{t('title.website')}</h2>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>{t('website.page.subtitle')}</p>
Manage public website content, forms, and customer-facing settings.
</p>
</div> </div>
</div> </div>
<div className={styles.gridHome}> <div className={styles.gridHome}>
{websiteSections.map((section) => ( {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> </div>
</main> </main>
+14 -16
View File
@@ -7,6 +7,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { WebsiteSliderGallery } from '../components/WebsiteSliderGallery' import { WebsiteSliderGallery } from '../components/WebsiteSliderGallery'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { resolveDataUrlToMediaId } from '../services/mediaService' import { resolveDataUrlToMediaId } from '../services/mediaService'
import { import {
@@ -19,9 +20,8 @@ import type { WebsiteSlider, WebsiteSliderSlide } from '../types/websiteSlider'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './StoreSpecialsPage.module.css' import styles from './StoreSpecialsPage.module.css'
const DEFAULT_SLIDER_TITLE = 'Homepage slider'
export function WebsiteSlidersPage() { export function WebsiteSlidersPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [slider, setSlider] = useState<WebsiteSlider | null>(null) const [slider, setSlider] = useState<WebsiteSlider | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -48,7 +48,7 @@ export function WebsiteSlidersPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load slides.') setError(t('website.sliders.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -62,7 +62,7 @@ export function WebsiteSlidersPage() {
try { try {
const imageMediaId = await resolveDataUrlToMediaId(data.image, 'slider-slide.jpg') const imageMediaId = await resolveDataUrlToMediaId(data.image, 'slider-slide.jpg')
if (!imageMediaId) { if (!imageMediaId) {
throw new Error('Unable to upload slide image.') throw new Error(t('website.sliders.errorUpload'))
} }
const slideInput = { const slideInput = {
@@ -79,21 +79,21 @@ export function WebsiteSlidersPage() {
setSlider(result.slider) setSlider(result.slider)
} else { } else {
const result = await createWebsiteSlider({ const result = await createWebsiteSlider({
title: DEFAULT_SLIDER_TITLE, title: t('website.sliders.defaultTitle'),
slides: [slideInput], slides: [slideInput],
}) })
setSlider(result.slider) setSlider(result.slider)
} }
setAddSlideOpen(false) setAddSlideOpen(false)
showToast('Slide added.', 'success') showToast(t('website.sliders.toastAdded'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else if (err instanceof Error) { } else if (err instanceof Error) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to add slide.') setError(t('website.sliders.errorAdd'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -114,12 +114,12 @@ export function WebsiteSlidersPage() {
const result = await updateWebsiteSlider(slider.id, { slides: nextSlides }) const result = await updateWebsiteSlider(slider.id, { slides: nextSlides })
setSlider(result.slider) setSlider(result.slider)
setRemoveTarget(null) setRemoveTarget(null)
showToast('Slide removed.', 'success') showToast(t('website.sliders.toastRemoved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to remove slide.') setError(t('website.sliders.errorRemove'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -138,17 +138,15 @@ export function WebsiteSlidersPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Sliders</h2> <h2 className={pageStyles.pageTitle}>{t('title.sliders')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('website.sliders.subtitle')}</p>
Manage homepage banner slides in a 9:4 gallery.
</p>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading slides...</p> <p className={styles.empty}>{t('website.sliders.loading')}</p>
) : ( ) : (
<WebsiteSliderGallery <WebsiteSliderGallery
slides={slider?.slides ?? []} slides={slider?.slides ?? []}
@@ -166,8 +164,8 @@ export function WebsiteSlidersPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTarget} open={!!removeTarget}
title="Remove Slide" title={t('website.sliders.removeTitle')}
message="Remove this slide from the homepage slider?" message={t('website.sliders.removeMessage')}
onConfirm={() => void confirmRemoveSlide()} onConfirm={() => void confirmRemoveSlide()}
onCancel={() => !isSaving && setRemoveTarget(null)} onCancel={() => !isSaving && setRemoveTarget(null)}
/> />
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel' import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard' import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
createWebsiteBrandGroup, createWebsiteBrandGroup,
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css' import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialBrandsPage() { export function WebsiteSpecialBrandsPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [groups, setGroups] = useState<WebsiteBrandGroup[]>([]) const [groups, setGroups] = useState<WebsiteBrandGroup[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -54,7 +56,7 @@ export function WebsiteSpecialBrandsPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load special brand groups.') setError(t('website.specialBrands.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -65,45 +67,49 @@ export function WebsiteSpecialBrandsPage() {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry))) 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) setIsSaving(true)
setError('') setError('')
try { try {
const result = await createWebsiteBrandGroup({ const result = await createWebsiteBrandGroup({
title, key: values.key,
title: values.title,
sortOrder: groups.length, sortOrder: groups.length,
}) })
setGroups((prev) => [...prev, result.group]) setGroups((prev) => [...prev, result.group])
setCreateOpen(false) setCreateOpen(false)
showToast('Special brand group created.', 'success') showToast(t('website.specialBrands.toastCreated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create special brand group.') setError(t('website.specialBrands.errorCreate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
} }
async function handleEditGroup(title: string) { async function handleEditGroup(values: { key: string; title: string }) {
if (!editGroupTarget) return if (!editGroupTarget) return
setIsSaving(true) setIsSaving(true)
setError('') setError('')
try { try {
const result = await updateWebsiteBrandGroup(editGroupTarget.id, { title }) const result = await updateWebsiteBrandGroup(editGroupTarget.id, {
key: values.key,
title: values.title,
})
replaceGroup(result.group) replaceGroup(result.group)
setEditGroupTarget(null) setEditGroupTarget(null)
showToast('Special brand group updated.', 'success') showToast(t('website.specialBrands.toastUpdated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to update special brand group.') setError(t('website.specialBrands.errorUpdate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -120,12 +126,12 @@ export function WebsiteSpecialBrandsPage() {
await deleteWebsiteBrandGroup(deleteGroupTarget.id) await deleteWebsiteBrandGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id)) setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null) setDeleteGroupTarget(null)
showToast('Special brand group deleted.', 'success') showToast(t('website.specialBrands.toastDeleted'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete special brand group.') setError(t('website.specialBrands.errorDelete'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -147,12 +153,12 @@ export function WebsiteSpecialBrandsPage() {
}) })
replaceGroup(result.group) replaceGroup(result.group)
setPickItemsTarget(null) setPickItemsTarget(null)
showToast('Brands added to group.', 'success') showToast(t('website.specialBrands.toastAdded'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to add brands.') setError(t('website.specialBrands.errorAdd'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -174,12 +180,12 @@ export function WebsiteSpecialBrandsPage() {
}) })
replaceGroup(result.group) replaceGroup(result.group)
setRemoveTarget(null) setRemoveTarget(null)
showToast('Removed from special brand group.', 'success') showToast(t('website.specialBrands.toastRemoved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to remove brand.') setError(t('website.specialBrands.errorRemove'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -198,22 +204,17 @@ export function WebsiteSpecialBrandsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Special Brands</h2> <h2 className={pageStyles.pageTitle}>{t('title.specialBrands')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('website.specialBrands.subtitle')}</p>
Curate featured brands into groups for your website homepage.
</p>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading special brand groups...</p> <p className={styles.empty}>{t('website.specialBrands.loading')}</p>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<p className={styles.empty}> <p className={styles.empty}>{t('website.specialBrands.empty')}</p>
No special brand groups yet. Use the + button to create one, then add brands to each
carousel.
</p>
) : ( ) : (
<div className={styles.carousels}> <div className={styles.carousels}>
{groups.map((group) => ( {groups.map((group) => (
@@ -225,9 +226,7 @@ export function WebsiteSpecialBrandsPage() {
onAddItems={() => setPickItemsTarget(group)} onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)} onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)} onDeleteGroup={() => setDeleteGroupTarget(group)}
addTooltip={`Add brands to ${group.title}`} addTooltip={t('website.specialBrands.addTooltip', { title: group.title })}
editTooltip="Edit group"
deleteTooltip="Delete group"
renderItem={(item) => ( renderItem={(item) => (
<WebsiteGroupItemCard <WebsiteGroupItemCard
title={item.nameEn} title={item.nameEn}
@@ -240,7 +239,6 @@ export function WebsiteSpecialBrandsPage() {
brandName: item.nameEn, brandName: item.nameEn,
}) })
} }
removeTooltip="Remove from group"
/> />
)} )}
/> />
@@ -253,7 +251,7 @@ export function WebsiteSpecialBrandsPage() {
type="button" type="button"
className={fabStyles.addFab} className={fabStyles.addFab}
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="Add special brand group" aria-label={t('website.specialBrands.addFab')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -263,7 +261,8 @@ export function WebsiteSpecialBrandsPage() {
open={createOpen} open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)} onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup} onSubmit={handleCreateGroup}
title="Add Special Brand Group" title={t('website.specialBrands.createTitle')}
submitLabel={t('website.create')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -271,9 +270,10 @@ export function WebsiteSpecialBrandsPage() {
open={!!editGroupTarget} open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)} onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup} onSubmit={handleEditGroup}
initialKey={editGroupTarget?.key ?? ''}
initialTitle={editGroupTarget?.title ?? ''} initialTitle={editGroupTarget?.title ?? ''}
title="Edit Special Brand Group" title={t('website.specialBrands.editTitle')}
submitLabel="Save" submitLabel={t('website.save')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -288,10 +288,10 @@ export function WebsiteSpecialBrandsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteGroupTarget} open={!!deleteGroupTarget}
title="Delete Special Brand Group" title={t('website.specialBrands.deleteTitle')}
message={ message={
deleteGroupTarget deleteGroupTarget
? `Delete "${deleteGroupTarget.title}"? Brands will remain in your catalog.` ? t('website.specialBrands.deleteMessage', { title: deleteGroupTarget.title })
: '' : ''
} }
onConfirm={() => void confirmDeleteGroup()} onConfirm={() => void confirmDeleteGroup()}
@@ -300,10 +300,13 @@ export function WebsiteSpecialBrandsPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTarget} open={!!removeTarget}
title="Remove from Group" title={t('website.removeFromGroupTitle')}
message={ message={
removeTarget removeTarget
? `Remove "${removeTarget.brandName}" from "${removeTarget.group.title}"?` ? t('website.removeFromGroupMessage', {
name: removeTarget.brandName,
group: removeTarget.group.title,
})
: '' : ''
} }
onConfirm={() => void confirmRemoveFromGroup()} onConfirm={() => void confirmRemoveFromGroup()}
@@ -7,6 +7,7 @@ import { StoreSpecialModal } from '../components/StoreSpecialModal'
import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel' import { WebsiteGroupCarousel } from '../components/WebsiteGroupCarousel'
import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard' import { WebsiteGroupItemCard } from '../components/WebsiteGroupItemCard'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
createWebsiteCategoryGroup, createWebsiteCategoryGroup,
@@ -20,6 +21,7 @@ import fabStyles from './StoreItemsPage.module.css'
import styles from './StoreSpecialsPage.module.css' import styles from './StoreSpecialsPage.module.css'
export function WebsiteSpecialCategoriesPage() { export function WebsiteSpecialCategoriesPage() {
const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
const [groups, setGroups] = useState<WebsiteCategoryGroup[]>([]) const [groups, setGroups] = useState<WebsiteCategoryGroup[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -54,7 +56,7 @@ export function WebsiteSpecialCategoriesPage() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to load special category groups.') setError(t('website.specialCategories.errorLoad'))
} }
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -65,45 +67,49 @@ export function WebsiteSpecialCategoriesPage() {
setGroups((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry))) 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) setIsSaving(true)
setError('') setError('')
try { try {
const result = await createWebsiteCategoryGroup({ const result = await createWebsiteCategoryGroup({
title, key: values.key,
title: values.title,
sortOrder: groups.length, sortOrder: groups.length,
}) })
setGroups((prev) => [...prev, result.group]) setGroups((prev) => [...prev, result.group])
setCreateOpen(false) setCreateOpen(false)
showToast('Special category group created.', 'success') showToast(t('website.specialCategories.toastCreated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to create special category group.') setError(t('website.specialCategories.errorCreate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
} }
async function handleEditGroup(title: string) { async function handleEditGroup(values: { key: string; title: string }) {
if (!editGroupTarget) return if (!editGroupTarget) return
setIsSaving(true) setIsSaving(true)
setError('') setError('')
try { try {
const result = await updateWebsiteCategoryGroup(editGroupTarget.id, { title }) const result = await updateWebsiteCategoryGroup(editGroupTarget.id, {
key: values.key,
title: values.title,
})
replaceGroup(result.group) replaceGroup(result.group)
setEditGroupTarget(null) setEditGroupTarget(null)
showToast('Special category group updated.', 'success') showToast(t('website.specialCategories.toastUpdated'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to update special category group.') setError(t('website.specialCategories.errorUpdate'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -120,12 +126,12 @@ export function WebsiteSpecialCategoriesPage() {
await deleteWebsiteCategoryGroup(deleteGroupTarget.id) await deleteWebsiteCategoryGroup(deleteGroupTarget.id)
setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id)) setGroups((prev) => prev.filter((entry) => entry.id !== deleteGroupTarget.id))
setDeleteGroupTarget(null) setDeleteGroupTarget(null)
showToast('Special category group deleted.', 'success') showToast(t('website.specialCategories.toastDeleted'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to delete special category group.') setError(t('website.specialCategories.errorDelete'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -147,12 +153,12 @@ export function WebsiteSpecialCategoriesPage() {
}) })
replaceGroup(result.group) replaceGroup(result.group)
setPickItemsTarget(null) setPickItemsTarget(null)
showToast('Categories added to group.', 'success') showToast(t('website.specialCategories.toastAdded'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to add categories.') setError(t('website.specialCategories.errorAdd'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -174,12 +180,12 @@ export function WebsiteSpecialCategoriesPage() {
}) })
replaceGroup(result.group) replaceGroup(result.group)
setRemoveTarget(null) setRemoveTarget(null)
showToast('Removed from special category group.', 'success') showToast(t('website.specialCategories.toastRemoved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError(err.message) setError(err.message)
} else { } else {
setError('Unable to remove category.') setError(t('website.specialCategories.errorRemove'))
} }
} finally { } finally {
setIsSaving(false) setIsSaving(false)
@@ -198,22 +204,17 @@ export function WebsiteSpecialCategoriesPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>Special Categories</h2> <h2 className={pageStyles.pageTitle}>{t('title.specialCategories')}</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>{t('website.specialCategories.subtitle')}</p>
Curate featured product categories into groups for your website homepage.
</p>
</div> </div>
</div> </div>
{error && <p className={styles.error}>{error}</p>} {error && <p className={styles.error}>{error}</p>}
{isLoading ? ( {isLoading ? (
<p className={styles.empty}>Loading special category groups...</p> <p className={styles.empty}>{t('website.specialCategories.loading')}</p>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<p className={styles.empty}> <p className={styles.empty}>{t('website.specialCategories.empty')}</p>
No special category groups yet. Use the + button to create one, then add categories to
each carousel.
</p>
) : ( ) : (
<div className={styles.carousels}> <div className={styles.carousels}>
{groups.map((group) => ( {groups.map((group) => (
@@ -225,9 +226,7 @@ export function WebsiteSpecialCategoriesPage() {
onAddItems={() => setPickItemsTarget(group)} onAddItems={() => setPickItemsTarget(group)}
onEditGroup={() => setEditGroupTarget(group)} onEditGroup={() => setEditGroupTarget(group)}
onDeleteGroup={() => setDeleteGroupTarget(group)} onDeleteGroup={() => setDeleteGroupTarget(group)}
addTooltip={`Add categories to ${group.title}`} addTooltip={t('website.specialCategories.addTooltip', { title: group.title })}
editTooltip="Edit group"
deleteTooltip="Delete group"
renderItem={(item) => ( renderItem={(item) => (
<WebsiteGroupItemCard <WebsiteGroupItemCard
title={item.name} title={item.name}
@@ -240,7 +239,6 @@ export function WebsiteSpecialCategoriesPage() {
categoryName: item.name, categoryName: item.name,
}) })
} }
removeTooltip="Remove from group"
/> />
)} )}
/> />
@@ -253,7 +251,7 @@ export function WebsiteSpecialCategoriesPage() {
type="button" type="button"
className={fabStyles.addFab} className={fabStyles.addFab}
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
aria-label="Add special category group" aria-label={t('website.specialCategories.addFab')}
> >
<Plus size={24} /> <Plus size={24} />
</button> </button>
@@ -263,7 +261,8 @@ export function WebsiteSpecialCategoriesPage() {
open={createOpen} open={createOpen}
onClose={() => !isSaving && setCreateOpen(false)} onClose={() => !isSaving && setCreateOpen(false)}
onSubmit={handleCreateGroup} onSubmit={handleCreateGroup}
title="Add Special Category Group" title={t('website.specialCategories.createTitle')}
submitLabel={t('website.create')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -271,9 +270,10 @@ export function WebsiteSpecialCategoriesPage() {
open={!!editGroupTarget} open={!!editGroupTarget}
onClose={() => !isSaving && setEditGroupTarget(null)} onClose={() => !isSaving && setEditGroupTarget(null)}
onSubmit={handleEditGroup} onSubmit={handleEditGroup}
initialKey={editGroupTarget?.key ?? ''}
initialTitle={editGroupTarget?.title ?? ''} initialTitle={editGroupTarget?.title ?? ''}
title="Edit Special Category Group" title={t('website.specialCategories.editTitle')}
submitLabel="Save" submitLabel={t('website.save')}
isSubmitting={isSaving} isSubmitting={isSaving}
/> />
@@ -288,10 +288,10 @@ export function WebsiteSpecialCategoriesPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!deleteGroupTarget} open={!!deleteGroupTarget}
title="Delete Special Category Group" title={t('website.specialCategories.deleteTitle')}
message={ message={
deleteGroupTarget deleteGroupTarget
? `Delete "${deleteGroupTarget.title}"? Product categories will remain in your catalog.` ? t('website.specialCategories.deleteMessage', { title: deleteGroupTarget.title })
: '' : ''
} }
onConfirm={() => void confirmDeleteGroup()} onConfirm={() => void confirmDeleteGroup()}
@@ -300,10 +300,13 @@ export function WebsiteSpecialCategoriesPage() {
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTarget} open={!!removeTarget}
title="Remove from Group" title={t('website.removeFromGroupTitle')}
message={ message={
removeTarget removeTarget
? `Remove "${removeTarget.categoryName}" from "${removeTarget.group.title}"?` ? t('website.removeFromGroupMessage', {
name: removeTarget.categoryName,
group: removeTarget.group.title,
})
: '' : ''
} }
onConfirm={() => void confirmRemoveFromGroup()} onConfirm={() => void confirmRemoveFromGroup()}
@@ -1,23 +1,22 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell' import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css' import styles from './WebsitePage.module.css'
export function WebsiteSubscriptionsPage() { export function WebsiteSubscriptionsPage() {
const t = useT()
return ( return (
<WebsiteSectionShell <WebsiteSectionShell
sectionLabel="Subscriptions" sectionLabel="Subscriptions"
title="Subscriptions" title={t('title.subscriptions')}
subtitle="Manage newsletter and subscription sign-ups on your website." subtitle={t('website.subscriptions.subtitle')}
> >
<p className={styles.placeholderLead}> <p className={styles.placeholderLead}>{t('website.subscriptions.lead')}</p>
Control how visitors subscribe to updates from your business. <p className={styles.placeholderNote}>{t('website.subscriptions.note')}</p>
</p>
<p className={styles.placeholderNote}>
Subscription management will be connected here. Planned options include:
</p>
<ul className={styles.featureList}> <ul className={styles.featureList}>
<li>Enable or disable subscription forms</li> <li>{t('website.subscriptions.feature.enable')}</li>
<li>Custom welcome message and consent text</li> <li>{t('website.subscriptions.feature.welcome')}</li>
<li>Export or view subscriber list</li> <li>{t('website.subscriptions.feature.export')}</li>
</ul> </ul>
</WebsiteSectionShell> </WebsiteSectionShell>
) )
+11 -5
View File
@@ -111,22 +111,28 @@ export function formatBlogAuthor(author: BlogAuthor | null): string {
return name || author.email || 'Unknown author' return name || author.email || 'Unknown author'
} }
export function formatBlogDate(value: string | null): string { export function formatBlogDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—' if (!value) return '—'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', { return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
}) })
} }
export function formatBlogCardDate(value: string | null): string { export function formatBlogCardDate(
if (!value) return 'Not published' value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', { return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@@ -50,8 +50,8 @@ export function mapExpertReviewApiToUi(review: ExpertReviewApi): ProductExpertRe
} }
} }
export function formatReviewDate(iso: string): string { export function formatReviewDate(iso: string, locale: 'en' | 'fa' = 'en'): string {
return new Date(iso).toLocaleDateString('en-US', { return new Date(iso).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
+11 -5
View File
@@ -113,22 +113,28 @@ export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
} }
} }
export function formatPortfolioCardDate(value: string | null): string { export function formatPortfolioCardDate(
if (!value) return 'Not published' value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', { return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}) })
} }
export function formatPortfolioDate(value: string | null): string { export function formatPortfolioDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—' if (!value) return '—'
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString('en-US', { return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric', day: 'numeric',
@@ -15,6 +15,7 @@ export interface ProductApi {
status: 'draft' | 'published' | 'archived' status: 'draft' | 'published' | 'archived'
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
categoryNameFa?: string
brandId: string | null brandId: string | null
brand: { brand: {
id: string id: string
@@ -84,6 +85,7 @@ export function mapProductApiToUi(product: ProductApi): Product {
nameFa: product.nameFa, nameFa: product.nameFa,
categoryId: product.categoryId ?? '', categoryId: product.categoryId ?? '',
category: product.categoryName, category: product.categoryName,
categoryFa: product.categoryNameFa || '',
summary: product.summary, summary: product.summary,
description: product.descriptionHtml, description: product.descriptionHtml,
image: product.image || product.thumbnail || '', image: product.image || product.thumbnail || '',
@@ -10,6 +10,7 @@ export interface StoreSpecialsListResponse {
} }
export interface CreateStoreSpecialPayload { export interface CreateStoreSpecialPayload {
key: string
title: string title: string
storeItemIds?: string[] storeItemIds?: string[]
sortOrder?: number sortOrder?: number
@@ -17,6 +18,7 @@ export interface CreateStoreSpecialPayload {
} }
export interface UpdateStoreSpecialPayload { export interface UpdateStoreSpecialPayload {
key?: string
title?: string title?: string
storeItemIds?: string[] storeItemIds?: string[]
sortOrder?: number sortOrder?: number
+1
View File
@@ -4,6 +4,7 @@ export interface Product {
nameFa: string nameFa: string
categoryId: string categoryId: string
category: string category: string
categoryFa?: string
summary: string summary: string
description: string description: string
image: string image: string
+1
View File
@@ -25,6 +25,7 @@ export interface StoreSpecialStoreItem {
export interface StoreSpecial { export interface StoreSpecial {
id: string id: string
key: string
title: string title: string
sortOrder: number sortOrder: number
isActive: boolean isActive: boolean
@@ -11,6 +11,7 @@ export interface WebsiteBrandGroupItem {
export interface WebsiteBrandGroup { export interface WebsiteBrandGroup {
id: string id: string
key: string
title: string title: string
sortOrder: number sortOrder: number
isActive: boolean isActive: boolean
@@ -27,6 +28,7 @@ export interface WebsiteBrandGroupsListResponse {
} }
export interface CreateWebsiteBrandGroupPayload { export interface CreateWebsiteBrandGroupPayload {
key: string
title: string title: string
brandIds?: string[] brandIds?: string[]
sortOrder?: number sortOrder?: number
@@ -34,6 +36,7 @@ export interface CreateWebsiteBrandGroupPayload {
} }
export interface UpdateWebsiteBrandGroupPayload { export interface UpdateWebsiteBrandGroupPayload {
key?: string
title?: string title?: string
brandIds?: string[] brandIds?: string[]
sortOrder?: number sortOrder?: number

Some files were not shown because too many files have changed in this diff Show More