mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-12 06:40:57 +04:30
Polish business FA layout and wire special-group keys plus SSL sync.
RTL carousels/FABs, special key+title fields, slider gallery fixes, and dashboard SSL sync agent for super-admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
66004a0fba
commit
672091d1f5
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -19,13 +21,16 @@ interface AddCustomerModalProps {
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [cellNumber, setCellNumber] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -37,7 +42,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
setLastName('')
|
||||
setCellNumber('')
|
||||
setPassword('')
|
||||
setEmail('')
|
||||
setPasswordConfirm('')
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -69,6 +74,18 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
const normalizedCell = toE164CellNumber(cellNumber.trim())
|
||||
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)
|
||||
setError('')
|
||||
try {
|
||||
@@ -76,8 +93,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
cellNumber: normalizedCell,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
...(password.trim() ? { password: password.trim() } : {}),
|
||||
...(email.trim() ? { email: email.trim() } : {}),
|
||||
...(trimmedPassword ? { password: trimmedPassword } : {}),
|
||||
})
|
||||
onCreated({
|
||||
id: created.id,
|
||||
@@ -91,7 +107,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to add customer.')
|
||||
setError(err instanceof ApiError ? err.message : t('customers.addModal.error'))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
@@ -108,79 +124,91 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="add-customer-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="add-customer-title" className={modalStyles.title}>
|
||||
Add customer
|
||||
{t('customers.addModal.title')}
|
||||
</h3>
|
||||
<p className={modalStyles.subtitle}>
|
||||
Creates a verified customer account or links an existing user to your business.
|
||||
</p>
|
||||
<p className={modalStyles.subtitle}>{t('customers.addModal.subtitle')}</p>
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.body}>
|
||||
<p className={formStyles.hint}>
|
||||
Password is required only for new accounts. Existing users are added as verified
|
||||
customers.
|
||||
</p>
|
||||
<p className={formStyles.hint}>{t('customers.addModal.hint')}</p>
|
||||
|
||||
{error && <p className={modalStyles.errorText}>{error}</p>}
|
||||
|
||||
<div className={formStyles.formGrid}>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="add-customer-first-name">First name</label>
|
||||
<label htmlFor="add-customer-first-name">{t('customers.addModal.firstName')}</label>
|
||||
<input
|
||||
id="add-customer-first-name"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="add-customer-last-name"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
id="add-customer-cell"
|
||||
value={cellNumber}
|
||||
onChange={(e) => setCellNumber(e.target.value)}
|
||||
placeholder="0912..."
|
||||
placeholder={t('customers.addModal.cellPlaceholder')}
|
||||
autoComplete="off"
|
||||
disabled={isSubmitting}
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
|
||||
<label htmlFor="add-customer-password">Password (new users)</label>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="add-customer-password">{t('customers.addModal.password')}</label>
|
||||
<input
|
||||
id="add-customer-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
placeholder={t('customers.addModal.passwordPlaceholder')}
|
||||
disabled={isSubmitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div className={`${modalStyles.field} ${formStyles.formFull}`}>
|
||||
<label htmlFor="add-customer-email">Email (optional)</label>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="add-customer-password-confirm">
|
||||
{t('customers.addModal.passwordConfirm')}
|
||||
</label>
|
||||
<input
|
||||
id="add-customer-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
id="add-customer-password-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
placeholder={t('customers.addModal.passwordConfirmPlaceholder')}
|
||||
disabled={isSubmitting}
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +220,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('customers.addModal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -200,7 +228,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
>
|
||||
Add customer
|
||||
{t('customers.addModal.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ImageCropper } from './ImageCropper'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import styles from './AddWebsiteSliderSlideModal.module.css'
|
||||
|
||||
@@ -26,6 +27,7 @@ export function AddWebsiteSliderSlideModal({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: AddWebsiteSliderSlideModalProps) {
|
||||
const t = useT()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
@@ -85,32 +87,37 @@ export function AddWebsiteSliderSlideModal({
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<h3 id="add-slide-title" className={modalStyles.title}>
|
||||
Add slide
|
||||
{t('website.sliders.modal.title')}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className={modalStyles.form} onSubmit={handleSubmit}>
|
||||
<div className={modalStyles.field}>
|
||||
<label>Slide image</label>
|
||||
<label>{t('website.sliders.modal.image')}</label>
|
||||
<ImageCropper
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
aspect={9 / 4}
|
||||
uploadLabel="Upload slide image"
|
||||
hint="9:4 banner ratio recommended"
|
||||
changeLabel="Change image"
|
||||
uploadLabel={t('website.sliders.modal.upload')}
|
||||
hint={t('website.sliders.modal.hint')}
|
||||
changeLabel={t('website.sliders.modal.changeImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="slide-title">Title (optional)</label>
|
||||
<label htmlFor="slide-title">{t('website.sliders.modal.titleOptional')}</label>
|
||||
<input
|
||||
id="slide-title"
|
||||
type="text"
|
||||
placeholder="e.g. Summer sale"
|
||||
placeholder={t('website.sliders.modal.titlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -118,7 +125,7 @@ export function AddWebsiteSliderSlideModal({
|
||||
</div>
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="slide-link">Link URL (optional)</label>
|
||||
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
|
||||
<input
|
||||
id="slide-link"
|
||||
type="url"
|
||||
@@ -137,14 +144,14 @@ export function AddWebsiteSliderSlideModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={modalStyles.submitBtn}
|
||||
disabled={isSubmitting || !image}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add slide'}
|
||||
{isSubmitting ? t('website.adding') : t('website.sliders.modal.addSlide')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, MessageSquare, Trash2, BadgeCheck, Clock } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Blog } from '../types/blog'
|
||||
import {
|
||||
formatBlogAuthor,
|
||||
formatBlogCardDate,
|
||||
isBlogVerified,
|
||||
} from '../services/blogService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
@@ -30,11 +32,21 @@ export function BlogCard({
|
||||
onRemove,
|
||||
isVerifying = false,
|
||||
}: BlogCardProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
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 abstractLocale = textLocaleAttrs(blog.abstract)
|
||||
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 (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -53,21 +65,21 @@ export function BlogCard({
|
||||
{!verified && (
|
||||
<span className={`${cardStyles.statusBadge} ${cardStyles.waitingBadge}`}>
|
||||
<Clock size={11} aria-hidden="true" />
|
||||
Waiting
|
||||
{t('blog.card.waiting')}
|
||||
</span>
|
||||
)}
|
||||
{verified && (
|
||||
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
|
||||
Verified
|
||||
{t('blog.card.verified')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cardStyles.body}>
|
||||
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<h3
|
||||
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
dir={titleDir}
|
||||
>
|
||||
{blog.title}
|
||||
</h3>
|
||||
@@ -77,13 +89,17 @@ export function BlogCard({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
dir={abstractDir}
|
||||
>
|
||||
{blog.abstract}
|
||||
</p>
|
||||
) : (
|
||||
<p className={cardStyles.abstract} lang="en" dir="ltr">
|
||||
No summary yet.
|
||||
<p
|
||||
className={cardStyles.abstract}
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{t('blog.card.noSummary')}
|
||||
</p>
|
||||
)}
|
||||
<p className={cardStyles.meta}>
|
||||
@@ -95,28 +111,32 @@ export function BlogCard({
|
||||
</Link>
|
||||
|
||||
<div className={controlStyles.controls}>
|
||||
<Tooltip label="Edit blog">
|
||||
<button type="button" onClick={() => onEdit(blog.id)} aria-label="Edit">
|
||||
<Tooltip label={t('blog.card.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(blog.id)}
|
||||
aria-label={t('blog.card.edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={verified ? 'Unverify blog' : 'Verify blog'}>
|
||||
<Tooltip label={verifyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={verified ? cardStyles.verifyActive : cardStyles.verifyWaiting}
|
||||
disabled={isVerifying}
|
||||
onClick={() => onToggleVerify(blog.id)}
|
||||
aria-label={verified ? 'Unverify blog' : 'Verify blog'}
|
||||
aria-label={verifyLabel}
|
||||
>
|
||||
{verified ? <BadgeCheck size={16} /> : <Clock size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="View comments">
|
||||
<Tooltip label={t('blog.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.iconBtn}
|
||||
onClick={() => onComments(blog.id)}
|
||||
aria-label={`Comments (${commentCount})`}
|
||||
aria-label={`${t('blog.card.comments')} (${commentCount})`}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
{commentCount > 0 && (
|
||||
@@ -124,12 +144,12 @@ export function BlogCard({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove blog">
|
||||
<Tooltip label={t('blog.card.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.danger}
|
||||
onClick={() => onRemove(blog.id)}
|
||||
aria-label="Remove"
|
||||
aria-label={t('blog.card.remove')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FolderPlus, Pencil, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Category } from '../types/category'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './CategoryRow.module.css'
|
||||
|
||||
@@ -24,6 +26,16 @@ export function BlogCategoryRow({
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: 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 (
|
||||
<div className={styles.row} style={{ marginLeft: depth * 8 }}>
|
||||
<div
|
||||
@@ -45,9 +57,13 @@ export function BlogCategoryRow({
|
||||
>
|
||||
<div className={styles.textBlock}>
|
||||
<div className={styles.names}>
|
||||
<span className={styles.nameEn}>{category.nameEn}</span>
|
||||
<span className={styles.separator}>·</span>
|
||||
<span className={styles.nameFa}>{category.nameFa}</span>
|
||||
<span className={isFa ? styles.nameFa : styles.nameEn}>{primaryName}</span>
|
||||
{secondaryName ? (
|
||||
<>
|
||||
<span className={styles.separator}>·</span>
|
||||
<span className={isFa ? styles.nameEn : styles.nameFa}>{secondaryName}</span>
|
||||
</>
|
||||
) : null}
|
||||
{hasChildren && (
|
||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
||||
<ChevronRight size={18} />
|
||||
@@ -59,32 +75,32 @@ export function BlogCategoryRow({
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label="Add sub category">
|
||||
<Tooltip label={t('categories.tooltip.addSub')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onAddSub(category.id)}
|
||||
aria-label="Add sub category"
|
||||
aria-label={t('categories.tooltip.addSub')}
|
||||
>
|
||||
<FolderPlus size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit category">
|
||||
<Tooltip label={t('categories.tooltip.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onEdit(category.id)}
|
||||
aria-label="Edit category"
|
||||
aria-label={t('categories.tooltip.edit')}
|
||||
>
|
||||
<Pencil size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove category">
|
||||
<Tooltip label={t('categories.tooltip.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${styles.danger}`}
|
||||
onClick={() => onRemove(category.id)}
|
||||
aria-label="Remove"
|
||||
aria-label={t('categories.tooltip.remove')}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -30,6 +31,8 @@ export function BlogCommentsModal({
|
||||
onClose,
|
||||
onCountChange,
|
||||
}: BlogCommentsModalProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [comments, setComments] = useState<ProductComment[]>([])
|
||||
@@ -200,7 +203,7 @@ export function BlogCommentsModal({
|
||||
<div className={styles.commentMeta}>
|
||||
<div className={styles.author}>{comment.author}</div>
|
||||
<div className={styles.dateTime}>
|
||||
{formatCommentDate(comment.createdAt)}
|
||||
{formatCommentDate(comment.createdAt, dateLocale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.likes}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -18,6 +19,8 @@ interface BlogCommentsSectionProps {
|
||||
}
|
||||
|
||||
export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSectionProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [comments, setComments] = useState<ProductComment[]>([])
|
||||
const [totalComments, setTotalComments] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -130,7 +133,9 @@ export function BlogCommentsSection({ blogId, onCountChange }: BlogCommentsSecti
|
||||
<div className={styles.commentHeader}>
|
||||
<div className={styles.commentMeta}>
|
||||
<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>
|
||||
<span className={styles.likes}>
|
||||
<ThumbsUp size={13} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ImageCropper } from './ImageCropper'
|
||||
import type { Brand, BrandFormData } from '../types/brand'
|
||||
import styles from './BrandModal.module.css'
|
||||
@@ -21,9 +23,12 @@ export function BrandModal({
|
||||
onClose,
|
||||
onSubmit,
|
||||
editingBrand = null,
|
||||
title = 'Add Brand',
|
||||
title,
|
||||
isSubmitting = false,
|
||||
}: BrandModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
@@ -85,39 +90,46 @@ export function BrandModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="brand-modal-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h3 id="brand-modal-title" className={styles.title}>
|
||||
{title}
|
||||
{title ?? t('brands.add')}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.field}>
|
||||
<label>Brand logo</label>
|
||||
<label>{t('brands.modal.logo')}</label>
|
||||
<ImageCropper
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
outputFormat="png"
|
||||
accept="image/png"
|
||||
uploadLabel="Upload brand logo"
|
||||
hint="PNG only — transparent background recommended"
|
||||
changeLabel="Change logo"
|
||||
uploadLabel={t('brands.modal.uploadLogo')}
|
||||
hint={t('brands.modal.logoHint')}
|
||||
changeLabel={t('brands.modal.changeLogo')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="nameFa">Name (FA)</label>
|
||||
<label htmlFor="nameFa">{t('brands.modal.nameFa')}</label>
|
||||
<input
|
||||
id="nameFa"
|
||||
name="nameFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
className="faText"
|
||||
placeholder="نام برند"
|
||||
placeholder={t('brands.modal.nameFaPlaceholder')}
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -125,13 +137,13 @@ export function BrandModal({
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="nameEn">Name (EN)</label>
|
||||
<label htmlFor="nameEn">{t('brands.modal.nameEn')}</label>
|
||||
<input
|
||||
id="nameEn"
|
||||
name="nameEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Brand name"
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
placeholder={t('brands.modal.nameEnPlaceholder')}
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
required
|
||||
@@ -140,12 +152,13 @@ export function BrandModal({
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="about">About</label>
|
||||
<label htmlFor="about">{t('brands.modal.about')}</label>
|
||||
<textarea
|
||||
id="about"
|
||||
name="about"
|
||||
rows={3}
|
||||
placeholder="Short description of this brand"
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
placeholder={t('brands.modal.aboutPlaceholder')}
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -159,10 +172,10 @@ export function BrandModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('brands.modal.cancel')}
|
||||
</button>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Brand'}
|
||||
{isSubmitting ? t('brands.modal.saving') : t('brands.modal.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Brand } from '../types/brand'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import rowStyles from './CategoryRow.module.css'
|
||||
import styles from './BrandRow.module.css'
|
||||
@@ -11,6 +13,12 @@ interface 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 (
|
||||
<div className={rowStyles.row}>
|
||||
<div className={styles.info}>
|
||||
@@ -19,33 +27,33 @@ export function BrandRow({ brand, onEdit, onRemove }: BrandRowProps) {
|
||||
)}
|
||||
<div className={rowStyles.textBlock}>
|
||||
<div className={rowStyles.names}>
|
||||
<span className={rowStyles.nameEn}>{brand.nameEn}</span>
|
||||
{brand.nameFa && (
|
||||
<span className={isFa ? rowStyles.nameFa : rowStyles.nameEn}>{primaryName}</span>
|
||||
{secondaryName ? (
|
||||
<>
|
||||
<span className={rowStyles.separator}>·</span>
|
||||
<span className={rowStyles.nameFa}>{brand.nameFa}</span>
|
||||
<span className={isFa ? rowStyles.nameEn : rowStyles.nameFa}>{secondaryName}</span>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
{brand.about && <p className={rowStyles.description}>{brand.about}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={rowStyles.controls}>
|
||||
<Tooltip label="Edit brand">
|
||||
<Tooltip label={t('brands.tooltip.edit')}>
|
||||
<button
|
||||
className={rowStyles.controlBtn}
|
||||
onClick={() => onEdit(brand.id)}
|
||||
aria-label="Edit brand"
|
||||
aria-label={t('brands.tooltip.edit')}
|
||||
>
|
||||
<Pencil size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove brand">
|
||||
<Tooltip label={t('brands.tooltip.remove')}>
|
||||
<button
|
||||
className={`${rowStyles.controlBtn} ${rowStyles.danger}`}
|
||||
onClick={() => onRemove(brand.id)}
|
||||
aria-label="Remove brand"
|
||||
aria-label={t('brands.tooltip.remove')}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
import { SearchableSelect } from './SearchableSelect'
|
||||
@@ -26,9 +28,12 @@ export function CategoryModal({
|
||||
categories,
|
||||
defaultParentId = '',
|
||||
editingCategory = null,
|
||||
title = 'Add Category',
|
||||
title,
|
||||
isSubmitting = false,
|
||||
}: CategoryModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
@@ -91,35 +96,43 @@ export function CategoryModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="category-modal-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h3 id="category-modal-title" className={styles.title}>
|
||||
{title}
|
||||
{title ?? t('categories.add')}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.field}>
|
||||
<label>Parent Category</label>
|
||||
<label>{t('categories.modal.parent')}</label>
|
||||
<SearchableSelect
|
||||
options={parentOptions}
|
||||
value={parentId}
|
||||
onChange={setParentId}
|
||||
placeholder={t('categories.select.search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="nameFa">Name (FA)</label>
|
||||
<label htmlFor="nameFa">{t('categories.modal.nameFa')}</label>
|
||||
<input
|
||||
id="nameFa"
|
||||
name="nameFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
className="faText"
|
||||
placeholder="نام دستهبندی"
|
||||
placeholder={t('categories.modal.nameFaPlaceholder')}
|
||||
value={nameFa}
|
||||
onChange={(e) => setNameFa(e.target.value)}
|
||||
required
|
||||
@@ -128,13 +141,13 @@ export function CategoryModal({
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="nameEn">Name (EN)</label>
|
||||
<label htmlFor="nameEn">{t('categories.modal.nameEn')}</label>
|
||||
<input
|
||||
id="nameEn"
|
||||
name="nameEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="Category name"
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
placeholder={t('categories.modal.nameEnPlaceholder')}
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
required
|
||||
@@ -143,12 +156,13 @@ export function CategoryModal({
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="description">Description</label>
|
||||
<label htmlFor="description">{t('categories.modal.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
placeholder="Short description of this category"
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
placeholder={t('categories.modal.descriptionPlaceholder')}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
@@ -162,10 +176,10 @@ export function CategoryModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('categories.modal.cancel')}
|
||||
</button>
|
||||
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Category'}
|
||||
{isSubmitting ? t('categories.modal.saving') : t('categories.modal.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -85,11 +85,17 @@
|
||||
.nameFa {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.names .nameEn:not(:first-child),
|
||||
.names .nameFa:not(:first-child) {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FolderPlus, Layers, ListTree, Trash2, ChevronRight, ClipboardList } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Category } from '../types/category'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './CategoryRow.module.css'
|
||||
|
||||
@@ -32,6 +34,16 @@ export function CategoryRow({
|
||||
onOptions,
|
||||
onRemove,
|
||||
}: 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 (
|
||||
<div
|
||||
className={styles.row}
|
||||
@@ -56,9 +68,13 @@ export function CategoryRow({
|
||||
>
|
||||
<div className={styles.textBlock}>
|
||||
<div className={styles.names}>
|
||||
<span className={styles.nameEn}>{category.nameEn}</span>
|
||||
<span className={styles.separator}>·</span>
|
||||
<span className={styles.nameFa}>{category.nameFa}</span>
|
||||
<span className={isFa ? styles.nameFa : styles.nameEn}>{primaryName}</span>
|
||||
{secondaryName ? (
|
||||
<>
|
||||
<span className={styles.separator}>·</span>
|
||||
<span className={isFa ? styles.nameEn : styles.nameFa}>{secondaryName}</span>
|
||||
</>
|
||||
) : null}
|
||||
{hasChildren && (
|
||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
||||
<ChevronRight size={18} />
|
||||
@@ -72,20 +88,20 @@ export function CategoryRow({
|
||||
</div>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label="Add sub category">
|
||||
<Tooltip label={t('categories.tooltip.addSub')}>
|
||||
<button
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onAddSub(category.id)}
|
||||
aria-label="Add sub category"
|
||||
aria-label={t('categories.tooltip.addSub')}
|
||||
>
|
||||
<FolderPlus size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Manage variations">
|
||||
<Tooltip label={t('categories.tooltip.variations')}>
|
||||
<button
|
||||
className={`${styles.controlBtn} ${styles.iconBtn}`}
|
||||
onClick={() => onVariations(category.id)}
|
||||
aria-label={`Variations (${variationCount})`}
|
||||
aria-label={`${t('categories.tooltip.variations')} (${variationCount})`}
|
||||
>
|
||||
<Layers size={17} />
|
||||
{variationCount > 0 && (
|
||||
@@ -93,11 +109,11 @@ export function CategoryRow({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Technical data form">
|
||||
<Tooltip label={t('categories.tooltip.technical')}>
|
||||
<button
|
||||
className={`${styles.controlBtn} ${styles.iconBtn}`}
|
||||
onClick={() => onTechnicalForm(category.id)}
|
||||
aria-label={`Technical data form (${technicalFieldCount})`}
|
||||
aria-label={`${t('categories.tooltip.technical')} (${technicalFieldCount})`}
|
||||
>
|
||||
<ClipboardList size={17} />
|
||||
{technicalFieldCount > 0 && (
|
||||
@@ -105,20 +121,20 @@ export function CategoryRow({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Category options">
|
||||
<Tooltip label={t('categories.tooltip.options')}>
|
||||
<button
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onOptions(category.id)}
|
||||
aria-label="Options"
|
||||
aria-label={t('categories.tooltip.options')}
|
||||
>
|
||||
<ListTree size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove category">
|
||||
<Tooltip label={t('categories.tooltip.remove')}>
|
||||
<button
|
||||
className={`${styles.controlBtn} ${styles.danger}`}
|
||||
onClick={() => onRemove(category.id)}
|
||||
aria-label="Remove"
|
||||
aria-label={t('categories.tooltip.remove')}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ContactSubmission } from '../types/contactSubmission'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
import styles from './ContactSubmissionDetailModal.module.css'
|
||||
|
||||
@@ -14,12 +16,13 @@ interface ContactSubmissionDetailModalProps {
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
function formatDateTime(value: string, locale: 'en' | 'fa') {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
const intlLocale = locale === 'fa' ? 'fa-IR' : 'en-US'
|
||||
return {
|
||||
date: d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
date: d.toLocaleDateString(intlLocale, { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: d.toLocaleTimeString(intlLocale, { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +31,8 @@ export function ContactSubmissionDetailModal({
|
||||
submission,
|
||||
onClose,
|
||||
}: ContactSubmissionDetailModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
@@ -56,7 +61,7 @@ export function ContactSubmissionDetailModal({
|
||||
|
||||
if (!mounted || !submission) return null
|
||||
|
||||
const { date, time } = formatDateTime(submission.createdAt)
|
||||
const { date, time } = formatDateTime(submission.createdAt, locale)
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -73,7 +78,7 @@ export function ContactSubmissionDetailModal({
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="contact-submission-title" className={modalStyles.title}>
|
||||
Contact submission
|
||||
{t('website.contact.detailTitle')}
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>{submission.title}</p>
|
||||
</div>
|
||||
@@ -81,7 +86,7 @@ export function ContactSubmissionDetailModal({
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -90,31 +95,31 @@ export function ContactSubmissionDetailModal({
|
||||
<div className={styles.body}>
|
||||
<dl className={styles.metaGrid}>
|
||||
<div className={styles.metaItem}>
|
||||
<dt>Name</dt>
|
||||
<dt>{t('website.contact.col.name')}</dt>
|
||||
<dd>{submission.name}</dd>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<dt>Email</dt>
|
||||
<dt>{t('website.contact.col.email')}</dt>
|
||||
<dd>{submission.email ?? '—'}</dd>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<dt>Cell number</dt>
|
||||
<dt>{t('website.contact.col.cell')}</dt>
|
||||
<dd>
|
||||
{submission.cellNumber ? formatCellForDisplay(submission.cellNumber) : '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<dt>Date</dt>
|
||||
<dt>{t('website.contact.col.date')}</dt>
|
||||
<dd>{date}</dd>
|
||||
</div>
|
||||
<div className={styles.metaItem}>
|
||||
<dt>Time</dt>
|
||||
<dt>{t('website.contact.col.time')}</dt>
|
||||
<dd>{time || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllProducts } from '../services/productService'
|
||||
import {
|
||||
@@ -40,6 +42,8 @@ export function CreateStoreItemsModal({
|
||||
onCreated,
|
||||
onEditExisting,
|
||||
}: CreateStoreItemsModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [products, setProducts] = useState<{ id: string; title: string; nameFa: string }[]>([])
|
||||
@@ -118,7 +122,7 @@ export function CreateStoreItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load products.')
|
||||
setError(t('storeItems.create.errorProducts'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingProducts(false)
|
||||
@@ -138,7 +142,7 @@ export function CreateStoreItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load product variations.')
|
||||
setError(t('storeItems.create.errorVariations'))
|
||||
}
|
||||
setVariations([])
|
||||
setRows([])
|
||||
@@ -235,7 +239,7 @@ export function CreateStoreItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to create store items.')
|
||||
setError(t('storeItems.create.errorCreate'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -253,36 +257,40 @@ export function CreateStoreItemsModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-store-items-title"
|
||||
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="create-store-items-title" className={modalStyles.title}>
|
||||
Add Store Items
|
||||
{t('storeItems.create.title')}
|
||||
</h3>
|
||||
<p className={modalStyles.subtitle}>
|
||||
Create sellable variants from a product's variations.
|
||||
</p>
|
||||
<p className={modalStyles.subtitle}>{t('storeItems.create.subtitle')}</p>
|
||||
</div>
|
||||
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<button
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="store-item-product">Product</label>
|
||||
<ProductSearchSelect
|
||||
id="store-item-product"
|
||||
options={products}
|
||||
value={productId}
|
||||
onChange={handleProductChange}
|
||||
placeholder="Type 3+ characters to search products"
|
||||
placeholder={t('storeItems.create.productPlaceholder')}
|
||||
disabled={isLoadingProducts || isSubmitting}
|
||||
aria-label={t('storeItems.create.productPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{productId && isLoadingVariations && (
|
||||
<p className={modalStyles.emptyText}>Loading product variations...</p>
|
||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||
)}
|
||||
|
||||
{productId && !isLoadingVariations && (
|
||||
@@ -294,8 +302,8 @@ export function CreateStoreItemsModal({
|
||||
{formVariations.map((variation) => (
|
||||
<span key={variation.id}>{variation.name}</span>
|
||||
))}
|
||||
<span>Price (IRT)</span>
|
||||
<span>Stock</span>
|
||||
<span>{t('storeItems.create.price')}</span>
|
||||
<span>{t('storeItems.create.stock')}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
@@ -346,13 +354,13 @@ export function CreateStoreItemsModal({
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={rowStyles.removeCell}>
|
||||
<Tooltip label="Remove row">
|
||||
<Tooltip label={t('storeItems.create.removeRow')}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.removeRowBtn}
|
||||
onClick={() => removeRow(row.id)}
|
||||
disabled={rows.length <= 1 || isSubmitting}
|
||||
aria-label="Remove row"
|
||||
aria-label={t('storeItems.create.removeRow')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -369,7 +377,7 @@ export function CreateStoreItemsModal({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add row
|
||||
{t('storeItems.create.addRow')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -383,14 +391,16 @@ export function CreateStoreItemsModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('storeItems.create.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={modalStyles.submitBtn}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Create store items'}
|
||||
{isSubmitting
|
||||
? t('storeItems.create.submitting')
|
||||
: t('storeItems.create.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getProductVariationValues,
|
||||
@@ -59,6 +61,9 @@ export function EditStoreItemsModal({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditStoreItemsModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [variations, setVariations] = useState<ProductVariationSelection[]>([])
|
||||
@@ -113,7 +118,7 @@ export function EditStoreItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load product variations.')
|
||||
setError(t('storeItems.create.errorVariations'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -203,13 +208,17 @@ export function EditStoreItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to save store items.')
|
||||
setError(t('storeItems.edit.errorSave'))
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const subtitle = isFa
|
||||
? [productNameFa, productTitle].filter(Boolean).join(' / ')
|
||||
: [productTitle, productNameFa].filter(Boolean).join(' / ')
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
@@ -221,25 +230,28 @@ export function EditStoreItemsModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-store-items-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h3 id="edit-store-items-title" className={modalStyles.title}>
|
||||
Edit Store Items
|
||||
{t('storeItems.edit.title')}
|
||||
</h3>
|
||||
<p className={modalStyles.subtitle}>
|
||||
{productTitle}
|
||||
{productNameFa ? ` / ${productNameFa}` : ''}
|
||||
</p>
|
||||
<p className={modalStyles.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
<button className={modalStyles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
<button
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={modalStyles.body} onSubmit={(e) => void handleSubmit(e)}>
|
||||
{isLoading ? (
|
||||
<p className={modalStyles.emptyText}>Loading product variations...</p>
|
||||
<p className={modalStyles.emptyText}>{t('storeItems.create.loadingVariations')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -249,8 +261,8 @@ export function EditStoreItemsModal({
|
||||
{formVariations.map((variation) => (
|
||||
<span key={variation.id}>{variation.name}</span>
|
||||
))}
|
||||
<span>Price (IRT)</span>
|
||||
<span>Stock</span>
|
||||
<span>{t('storeItems.create.price')}</span>
|
||||
<span>{t('storeItems.create.stock')}</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
@@ -301,13 +313,13 @@ export function EditStoreItemsModal({
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={rowStyles.removeCell}>
|
||||
<Tooltip label="Remove row">
|
||||
<Tooltip label={t('storeItems.create.removeRow')}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.removeRowBtn}
|
||||
onClick={() => removeRow(row.id)}
|
||||
disabled={rows.length <= 1 || isSubmitting}
|
||||
aria-label="Remove row"
|
||||
aria-label={t('storeItems.create.removeRow')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -324,7 +336,7 @@ export function EditStoreItemsModal({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add row
|
||||
{t('storeItems.create.addRow')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -338,10 +350,10 @@ export function EditStoreItemsModal({
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t('storeItems.create.cancel')}
|
||||
</button>
|
||||
<button type="submit" className={modalStyles.submitBtn} disabled={!canSubmit}>
|
||||
{isSubmitting ? 'Saving…' : 'Save changes'}
|
||||
{isSubmitting ? t('storeItems.edit.saving') : t('storeItems.edit.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import Cropper, { type Area } from 'react-easy-crop'
|
||||
import { ImagePlus, X } from 'lucide-react'
|
||||
import { getCroppedImage } from '../utils/cropImage'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './ImageCropper.module.css'
|
||||
|
||||
interface ImageCropperProps {
|
||||
@@ -22,10 +23,14 @@ export function ImageCropper({
|
||||
aspect = 1,
|
||||
outputFormat = 'jpeg',
|
||||
accept = 'image/*',
|
||||
uploadLabel = 'Upload thumbnail image',
|
||||
hint = 'Click to select, then crop',
|
||||
changeLabel = 'Change thumbnail',
|
||||
uploadLabel,
|
||||
hint,
|
||||
changeLabel,
|
||||
}: 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 [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
@@ -93,8 +98,13 @@ export function ImageCropper({
|
||||
<div className={styles.wrapper}>
|
||||
{value && !imageSrc && (
|
||||
<div className={styles.preview}>
|
||||
<img src={value} alt="Thumbnail preview" className={styles.previewImgNatural} />
|
||||
<button type="button" className={styles.removeBtn} onClick={removeThumbnail} aria-label="Remove thumbnail">
|
||||
<img src={value} alt={resolvedUploadLabel} className={styles.previewImgNatural} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={removeThumbnail}
|
||||
aria-label={t('products.form.thumbnailRemove')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,8 +113,8 @@ export function ImageCropper({
|
||||
{!value && !imageSrc && (
|
||||
<label className={styles.uploadZone} style={frameStyle}>
|
||||
<ImagePlus size={28} />
|
||||
<span>{uploadLabel}</span>
|
||||
<span className={styles.hint}>{hint}</span>
|
||||
<span>{resolvedUploadLabel}</span>
|
||||
<span className={styles.hint}>{resolvedHint}</span>
|
||||
<input type="file" accept={accept} onChange={handleFile} hidden />
|
||||
</label>
|
||||
)}
|
||||
@@ -125,7 +135,7 @@ export function ImageCropper({
|
||||
</div>
|
||||
<div className={styles.cropControls}>
|
||||
<label className={styles.zoomLabel}>
|
||||
Zoom
|
||||
{t('products.form.zoom')}
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
@@ -137,7 +147,7 @@ export function ImageCropper({
|
||||
</label>
|
||||
<div className={styles.cropActions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={cancelCrop}>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,7 +155,7 @@ export function ImageCropper({
|
||||
onClick={() => void applyCrop()}
|
||||
disabled={!croppedArea}
|
||||
>
|
||||
Apply Crop
|
||||
{t('products.form.applyCrop')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,7 +164,7 @@ export function ImageCropper({
|
||||
|
||||
{value && !imageSrc && (
|
||||
<label className={styles.changeBtn}>
|
||||
{changeLabel}
|
||||
{resolvedChangeLabel}
|
||||
<input type="file" accept={accept} onChange={handleFile} hidden />
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react'
|
||||
import { ImagePlus, X } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './ImageUploader.module.css'
|
||||
|
||||
interface ImageUploaderProps {
|
||||
@@ -8,6 +9,7 @@ interface ImageUploaderProps {
|
||||
}
|
||||
|
||||
export function ImageUploader({ images, onChange }: ImageUploaderProps) {
|
||||
const t = useT()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -39,12 +41,12 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
|
||||
<div className={styles.grid}>
|
||||
{images.map((src, index) => (
|
||||
<div key={`${src.slice(0, 32)}-${index}`} className={styles.item}>
|
||||
<img src={src} alt={`Product image ${index + 1}`} />
|
||||
<img src={src} alt={t('products.form.images')} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={() => removeImage(index)}
|
||||
aria-label={`Remove image ${index + 1}`}
|
||||
aria-label={t('products.form.imagesRemove', { index: index + 1 })}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -57,7 +59,7 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={24} />
|
||||
<span>Add images</span>
|
||||
<span>{t('products.form.imagesAdd')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +71,7 @@ export function ImageUploader({ images, onChange }: ImageUploaderProps) {
|
||||
hidden
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.filtersPanel {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
@@ -10,25 +10,25 @@
|
||||
}
|
||||
|
||||
.filtersTitle {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filtersGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 10px 12px;
|
||||
align-items: end;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filtersInputs {
|
||||
grid-column: span 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 10px 12px;
|
||||
align-items: end;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterActions {
|
||||
@@ -38,13 +38,13 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
align-self: end;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.fieldCol2 {
|
||||
@@ -98,15 +98,12 @@
|
||||
.switchField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.switchFieldSpacer {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
visibility: hidden;
|
||||
user-select: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.switchInline {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
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)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleString('en-US', {
|
||||
return d.toLocaleString(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
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 }[] = [
|
||||
{ label: 'Status', value: transaction.status },
|
||||
{ label: 'Date', value: formatDateTime(transaction.createdAt) },
|
||||
{ label: 'Date', value: formatDateTime(transaction.createdAt, locale) },
|
||||
]
|
||||
|
||||
if (transaction.type === 'pos' && transaction.posType) {
|
||||
@@ -83,6 +84,8 @@ function transactionDetails(transaction: OrderTransaction) {
|
||||
}
|
||||
|
||||
export function OrderTransactionsModal({ open, order, onClose }: OrderTransactionsModalProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
@@ -147,7 +150,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
||||
) : (
|
||||
<ul className={styles.txList}>
|
||||
{transactions.map((transaction) => {
|
||||
const details = transactionDetails(transaction)
|
||||
const details = transactionDetails(transaction, dateLocale)
|
||||
return (
|
||||
<li key={transaction.id} className={styles.txCard}>
|
||||
<div className={styles.txHeader}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllStoreItems } from '../services/storeItemService'
|
||||
import { groupStoreItemsByProduct } from '../utils/storeProductGroups'
|
||||
@@ -30,6 +32,9 @@ export function PickStoreSpecialItemsModal({
|
||||
onConfirm,
|
||||
isSubmitting = false,
|
||||
}: PickStoreSpecialItemsModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
|
||||
@@ -97,7 +102,7 @@ export function PickStoreSpecialItemsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load store items.')
|
||||
setError(t('website.specialItems.pickErrorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -117,6 +122,12 @@ export function PickStoreSpecialItemsModal({
|
||||
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
|
||||
|
||||
return createPortal(
|
||||
@@ -130,15 +141,22 @@ export function PickStoreSpecialItemsModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pick-special-items-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="pick-special-items-title" className={modalStyles.title}>
|
||||
Add to {specialTitle}
|
||||
{t('website.pick.addTo', { title: specialTitle })}
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>Select store items to feature in this category.</p>
|
||||
<p className={modalStyles.subtitle}>{t('website.specialItems.pickSubtitle')}</p>
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -147,22 +165,22 @@ export function PickStoreSpecialItemsModal({
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading store items...</p>
|
||||
<p className={styles.empty}>{t('website.specialItems.pickLoading')}</p>
|
||||
) : 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
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleSelection}
|
||||
placeholder="Type 3+ characters to search store items"
|
||||
placeholder={t('website.specialItems.pickSearch')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -170,7 +188,7 @@ export function PickStoreSpecialItemsModal({
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || selectedIds.length === 0}
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : `Add ${selectedIds.length || ''} item${selectedIds.length === 1 ? '' : 's'}`}
|
||||
{confirmLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import { StoreItemPrice } from './StoreItemPrice'
|
||||
import modalStyles from './VariationsModal.module.css'
|
||||
@@ -25,6 +27,9 @@ export function PickStoreVariantModal({
|
||||
onClose,
|
||||
onSelect,
|
||||
}: PickStoreVariantModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
@@ -53,6 +58,10 @@ export function PickStoreVariantModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
const subtitle = isFa
|
||||
? [productNameFa, productTitle].filter(Boolean).join(' / ')
|
||||
: [productTitle, productNameFa].filter(Boolean).join(' / ')
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
@@ -64,22 +73,21 @@ export function PickStoreVariantModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pick-variant-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="pick-variant-title" className={modalStyles.title}>
|
||||
Choose variant
|
||||
{t('storeItems.pick.title')}
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>
|
||||
{productTitle}
|
||||
{productNameFa ? ` / ${productNameFa}` : ''}
|
||||
</p>
|
||||
<p className={modalStyles.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -98,7 +106,7 @@ export function PickStoreVariantModal({
|
||||
<span className={styles.optionLabel}>{variant.label}</span>
|
||||
{variant.stockQuantity !== null && (
|
||||
<span className={styles.optionStock}>
|
||||
{variant.stockQuantity} in stock
|
||||
{t('storeItems.pick.inStock', { count: variant.stockQuantity })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllBrands } from '../services/brandService'
|
||||
import {
|
||||
@@ -29,6 +31,9 @@ export function PickWebsiteBrandsModal({
|
||||
onConfirm,
|
||||
isSubmitting = false,
|
||||
}: PickWebsiteBrandsModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
|
||||
@@ -92,7 +97,7 @@ export function PickWebsiteBrandsModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load brands.')
|
||||
setError(t('website.specialBrands.pickErrorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -110,6 +115,12 @@ export function PickWebsiteBrandsModal({
|
||||
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
|
||||
|
||||
return createPortal(
|
||||
@@ -123,15 +134,22 @@ export function PickWebsiteBrandsModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pick-brands-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="pick-brands-title" className={modalStyles.title}>
|
||||
Add to {groupTitle}
|
||||
{t('website.pick.addTo', { title: groupTitle })}
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>Select brands to feature in this group.</p>
|
||||
<p className={modalStyles.subtitle}>{t('website.specialBrands.pickSubtitle')}</p>
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -140,22 +158,22 @@ export function PickWebsiteBrandsModal({
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading brands...</p>
|
||||
<p className={styles.empty}>{t('website.specialBrands.pickLoading')}</p>
|
||||
) : 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
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleSelection}
|
||||
placeholder="Type 3+ characters to search brands"
|
||||
placeholder={t('website.specialBrands.pickSearch')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -163,9 +181,7 @@ export function PickWebsiteBrandsModal({
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || selectedIds.length === 0}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Adding...'
|
||||
: `Add ${selectedIds.length || ''} brand${selectedIds.length === 1 ? '' : 's'}`}
|
||||
{confirmLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||
import {
|
||||
@@ -29,6 +31,9 @@ export function PickWebsiteCategoriesModal({
|
||||
onConfirm,
|
||||
isSubmitting = false,
|
||||
}: PickWebsiteCategoriesModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [options, setOptions] = useState<StoreItemSearchOption[]>([])
|
||||
@@ -93,7 +98,7 @@ export function PickWebsiteCategoriesModal({
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to load categories.')
|
||||
setError(t('website.specialCategories.pickErrorLoad'))
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -111,6 +116,12 @@ export function PickWebsiteCategoriesModal({
|
||||
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
|
||||
|
||||
return createPortal(
|
||||
@@ -124,15 +135,22 @@ export function PickWebsiteCategoriesModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pick-categories-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={modalStyles.header}>
|
||||
<div>
|
||||
<h2 id="pick-categories-title" className={modalStyles.title}>
|
||||
Add to {groupTitle}
|
||||
{t('website.pick.addTo', { title: groupTitle })}
|
||||
</h2>
|
||||
<p className={modalStyles.subtitle}>Select product categories to feature in this group.</p>
|
||||
<p className={modalStyles.subtitle}>{t('website.specialCategories.pickSubtitle')}</p>
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -141,22 +159,22 @@ export function PickWebsiteCategoriesModal({
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
{isLoading ? (
|
||||
<p className={styles.empty}>Loading categories...</p>
|
||||
<p className={styles.empty}>{t('website.specialCategories.pickLoading')}</p>
|
||||
) : 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
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleSelection}
|
||||
placeholder="Type 3+ characters to search categories"
|
||||
placeholder={t('website.specialCategories.pickSearch')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,9 +182,7 @@ export function PickWebsiteCategoriesModal({
|
||||
onClick={handleConfirm}
|
||||
disabled={isSubmitting || selectedIds.length === 0}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Adding...'
|
||||
: `Add ${selectedIds.length || ''} categor${selectedIds.length === 1 ? 'y' : 'ies'}`}
|
||||
{confirmLabel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleEn[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.abstract {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Portfolio } from '../types/portfolio'
|
||||
import { formatPortfolioCardDate } from '../services/portfolioService'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './PortfolioCard.module.css'
|
||||
@@ -28,13 +30,21 @@ export function PortfolioCard({
|
||||
onComments,
|
||||
onRemove,
|
||||
}: 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 titleEn = (portfolio.titleEn || '').trim()
|
||||
const showBothTitles = Boolean(titleFa && titleEn)
|
||||
const showAbstract = !showBothTitles
|
||||
const abstractLocale = textLocaleAttrs(portfolio.abstract)
|
||||
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 (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -52,14 +62,14 @@ export function PortfolioCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cardStyles.body}>
|
||||
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{titleFa ? (
|
||||
<h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl">
|
||||
{titleFa}
|
||||
</h3>
|
||||
) : null}
|
||||
{titleEn ? (
|
||||
<p className={cardStyles.titleEn} lang="en" dir="ltr">
|
||||
<p className={cardStyles.titleEn} lang="en" dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -70,13 +80,17 @@ export function PortfolioCard({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
dir={abstractDir}
|
||||
>
|
||||
{portfolio.abstract}
|
||||
</p>
|
||||
) : (
|
||||
<p className={cardStyles.abstract} lang="en" dir="ltr">
|
||||
No summary yet.
|
||||
<p
|
||||
className={cardStyles.abstract}
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{t('portfolio.card.noSummary')}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
@@ -93,27 +107,31 @@ export function PortfolioCard({
|
||||
</Link>
|
||||
|
||||
<div className={controlStyles.controls}>
|
||||
<Tooltip label="Move up">
|
||||
<Tooltip label={t('portfolio.card.moveUp')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(portfolio.id)}
|
||||
disabled={!canMoveUp || isMovingUp}
|
||||
aria-label="Move up"
|
||||
aria-label={t('portfolio.card.moveUp')}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit portfolio">
|
||||
<button type="button" onClick={() => onEdit(portfolio.id)} aria-label="Edit">
|
||||
<Tooltip label={t('portfolio.card.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(portfolio.id)}
|
||||
aria-label={t('portfolio.card.edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="View comments">
|
||||
<Tooltip label={t('portfolio.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.iconBtn}
|
||||
onClick={() => onComments(portfolio.id)}
|
||||
aria-label={`Comments (${commentCount})`}
|
||||
aria-label={`${t('portfolio.card.comments')} (${commentCount})`}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
{commentCount > 0 && (
|
||||
@@ -121,12 +139,12 @@ export function PortfolioCard({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove portfolio">
|
||||
<Tooltip label={t('portfolio.card.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.danger}
|
||||
onClick={() => onRemove(portfolio.id)}
|
||||
aria-label="Remove"
|
||||
aria-label={t('portfolio.card.remove')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -30,6 +31,8 @@ export function PortfolioCommentsModal({
|
||||
onClose,
|
||||
onCountChange,
|
||||
}: PortfolioCommentsModalProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [comments, setComments] = useState<ProductComment[]>([])
|
||||
@@ -200,7 +203,7 @@ export function PortfolioCommentsModal({
|
||||
<div className={styles.commentMeta}>
|
||||
<div className={styles.author}>{comment.author}</div>
|
||||
<div className={styles.dateTime}>
|
||||
{formatCommentDate(comment.createdAt)}
|
||||
{formatCommentDate(comment.createdAt, dateLocale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.likes}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -21,6 +22,8 @@ export function PortfolioCommentsSection({
|
||||
portfolioId,
|
||||
onCountChange,
|
||||
}: PortfolioCommentsSectionProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [comments, setComments] = useState<ProductComment[]>([])
|
||||
const [totalComments, setTotalComments] = useState(0)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -133,7 +136,9 @@ export function PortfolioCommentsSection({
|
||||
<div className={styles.commentHeader}>
|
||||
<div className={styles.commentMeta}>
|
||||
<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>
|
||||
<span className={styles.likes}>
|
||||
<ThumbsUp size={13} />
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable:hover .nameEn {
|
||||
.clickable:hover .nameFa,
|
||||
.clickable:hover .nameEnPrimary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -62,14 +63,48 @@
|
||||
.body {
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nameEn {
|
||||
.nameFa {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
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;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -78,9 +113,9 @@
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
.nameFaSecondary {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
@@ -96,11 +131,19 @@
|
||||
.categoryChip {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 50px;
|
||||
direction: rtl;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.bodyLtr {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Info, Trash2, Layers, MessageSquare, ClipboardList } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { Product } from '../types/product'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './ProductCard.module.css'
|
||||
|
||||
@@ -27,49 +29,82 @@ export function ProductCard({
|
||||
onRemove,
|
||||
onVariations,
|
||||
}: 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 (
|
||||
<article className={styles.card}>
|
||||
<Link to={`/products/detail/${product.id}`} className={styles.clickable}>
|
||||
<div className={styles.imageWrap}>
|
||||
<img src={product.image} alt={product.nameEn} className={styles.image} loading="lazy" />
|
||||
{product.status === 'draft' && <span className={styles.badge}>Draft</span>}
|
||||
<img
|
||||
src={product.image}
|
||||
alt={primaryName}
|
||||
className={styles.image}
|
||||
loading="lazy"
|
||||
/>
|
||||
{product.status === 'draft' && (
|
||||
<span className={styles.badge}>{t('products.card.draft')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<h3 className={styles.nameEn}>{product.nameEn}</h3>
|
||||
<p className={styles.nameFa}>{product.nameFa}</p>
|
||||
<span className={styles.categoryChip}>{product.category}</span>
|
||||
<div className={`${styles.body}${isFa ? '' : ` ${styles.bodyLtr}`}`} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{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}
|
||||
</>
|
||||
)}
|
||||
{categoryLabel ? (
|
||||
<span className={styles.categoryChip}>{categoryLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label="Edit product">
|
||||
<button type="button" onClick={() => onEdit(product.id)} aria-label="Edit">
|
||||
<Tooltip label={t('products.card.edit')}>
|
||||
<button type="button" onClick={() => onEdit(product.id)} aria-label={t('products.card.edit')}>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Quick info">
|
||||
<button type="button" onClick={() => onQuickInfo(product.id)} aria-label="Quick info">
|
||||
<Tooltip label={t('products.card.quickInfo')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQuickInfo(product.id)}
|
||||
aria-label={t('products.card.quickInfo')}
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Manage variations">
|
||||
<Tooltip label={t('products.card.variations')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onVariations(product.id)}
|
||||
aria-label={`Variations (${variantCount})`}
|
||||
aria-label={`${t('products.card.variations')} (${variantCount})`}
|
||||
>
|
||||
<Layers size={16} />
|
||||
{variantCount > 0 && <span className={styles.variantBadge}>{variantCount}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="View comments">
|
||||
<Tooltip label={t('products.card.comments')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
onClick={() => onComments(product.id)}
|
||||
aria-label={`Comments (${commentCount})`}
|
||||
aria-label={`${t('products.card.comments')} (${commentCount})`}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
{commentCount > 0 && (
|
||||
@@ -77,21 +112,21 @@ export function ProductCard({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Technical data">
|
||||
<Tooltip label={t('products.card.technical')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTechnicalInfo(product.id)}
|
||||
aria-label="Technical data"
|
||||
aria-label={t('products.card.technical')}
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove product">
|
||||
<Tooltip label={t('products.card.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(product.id)}
|
||||
aria-label="Remove"
|
||||
aria-label={t('products.card.remove')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -30,6 +31,8 @@ export function ProductCommentsModal({
|
||||
onClose,
|
||||
onCountChange,
|
||||
}: ProductCommentsModalProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [comments, setComments] = useState<ProductComment[]>([])
|
||||
@@ -200,7 +203,7 @@ export function ProductCommentsModal({
|
||||
<div className={styles.commentMeta}>
|
||||
<div className={styles.author}>{comment.author}</div>
|
||||
<div className={styles.dateTime}>
|
||||
{formatCommentDate(comment.createdAt)}
|
||||
{formatCommentDate(comment.createdAt, dateLocale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.likes}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -38,6 +39,8 @@ function formatTechnicalValue(item: ProductTechnicalValue): string {
|
||||
}
|
||||
|
||||
export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) {
|
||||
const { locale } = useLocale()
|
||||
const dateLocale = locale === 'fa' ? 'fa' : 'en'
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('technical')
|
||||
const [technicalValues, setTechnicalValues] = useState<ProductTechnicalValue[]>([])
|
||||
const [technicalMessage, setTechnicalMessage] = useState('')
|
||||
@@ -296,7 +299,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
|
||||
<div className={styles.commentHeader}>
|
||||
<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>
|
||||
<span className={styles.likes}>
|
||||
<ThumbsUp size={13} />
|
||||
@@ -363,7 +368,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
|
||||
<div className={styles.reviewHeader}>
|
||||
<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 className={styles.rating} aria-label={`Rating ${review.rate} out of 10`}>
|
||||
<Star size={14} fill="currentColor" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from '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'
|
||||
|
||||
export interface ProductSearchOption {
|
||||
@@ -16,6 +18,14 @@ interface ProductSearchSelectProps {
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
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({
|
||||
@@ -26,13 +36,18 @@ export function ProductSearchSelect({
|
||||
disabled = false,
|
||||
id,
|
||||
minSearchLength = 3,
|
||||
'aria-label': ariaLabel,
|
||||
}: ProductSearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const preferFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selected = options.find((option) => option.id === value)
|
||||
const selectedLabel = selected ? formatProductLabel(selected, preferFa) : ''
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const trimmed = query.trim()
|
||||
@@ -70,7 +85,7 @@ export function ProductSearchSelect({
|
||||
const showDropdown = open && query.trim().length >= minSearchLength
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef}>
|
||||
<div className={styles.wrapper} ref={containerRef} dir={preferFa ? 'rtl' : 'ltr'}>
|
||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input
|
||||
@@ -78,12 +93,8 @@ export function ProductSearchSelect({
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder={
|
||||
selected
|
||||
? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}`
|
||||
: placeholder
|
||||
}
|
||||
value={open ? query : selected ? `${selected.title}${selected.nameFa ? ` / ${selected.nameFa}` : ''}` : ''}
|
||||
placeholder={selected ? selectedLabel : placeholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
@@ -91,13 +102,15 @@ export function ProductSearchSelect({
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? placeholder}
|
||||
dir={preferFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
{value && !open && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label="Clear selection"
|
||||
aria-label={t('storeItems.search.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -106,10 +119,12 @@ export function ProductSearchSelect({
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
filtered.map((option) => (
|
||||
@@ -121,11 +136,33 @@ export function ProductSearchSelect({
|
||||
className={`${styles.option} ${option.id === value ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption(option.id)}
|
||||
>
|
||||
<span className={styles.optionEn}>{option.title}</span>
|
||||
{option.nameFa && (
|
||||
{preferFa && option.nameFa ? (
|
||||
<>
|
||||
<span className={styles.optionSep}>/</span>
|
||||
<span className={styles.optionFa}>{option.nameFa}</span>
|
||||
<span className={styles.optionFa} dir="rtl">
|
||||
{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>
|
||||
@@ -136,8 +173,10 @@ export function ProductSearchSelect({
|
||||
)}
|
||||
|
||||
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
|
||||
<ul className={styles.dropdown}>
|
||||
<li className={styles.noResults}>Type at least {minSearchLength} characters to search</li>
|
||||
<ul className={`${styles.dropdown} ${styles.dropdownInFlow}`}>
|
||||
<li className={styles.noResults}>
|
||||
{t('storeItems.search.minChars', { count: minSearchLength })}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -105,7 +116,7 @@
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
font-size: var(--field-font-size);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
@@ -123,6 +134,11 @@
|
||||
|
||||
.optionEn {
|
||||
font-weight: 500;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.optionEn[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.optionSep {
|
||||
@@ -133,6 +149,7 @@
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 500;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from '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 styles from './SearchableSelect.module.css'
|
||||
|
||||
@@ -10,18 +12,34 @@ interface SearchableSelectProps {
|
||||
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({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search categories...',
|
||||
placeholder,
|
||||
}: SearchableSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selected = options.find((o) => o.id === value)
|
||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||
|
||||
const filtered = options.filter((cat) => {
|
||||
const q = query.trim().toLowerCase()
|
||||
@@ -55,27 +73,28 @@ export function SearchableSelect({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef}>
|
||||
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder={selected ? `${selected.nameEn} / ${selected.nameFa}` : placeholder}
|
||||
value={open ? query : selected ? `${selected.nameEn} / ${selected.nameFa}` : ''}
|
||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
{value && !open && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label="Clear selection"
|
||||
aria-label={t('categories.select.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -91,23 +110,41 @@ export function SearchableSelect({
|
||||
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption('')}
|
||||
>
|
||||
— None (root category) —
|
||||
{t('categories.select.none')}
|
||||
</button>
|
||||
</li>
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>No categories found</li>
|
||||
<li className={styles.noResults}>{t('categories.select.empty')}</li>
|
||||
) : (
|
||||
filtered.map((cat) => (
|
||||
<li key={cat.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === cat.id ? styles.optionSelected : ''}`}
|
||||
style={{ paddingLeft: `${14 + cat.depth * 16}px` }}
|
||||
style={{ paddingInlineStart: `${14 + cat.depth * 16}px` }}
|
||||
onClick={() => selectOption(cat.id)}
|
||||
>
|
||||
<span className={styles.optionEn}>{cat.nameEn}</span>
|
||||
<span className={styles.optionSep}>·</span>
|
||||
<span className={styles.optionFa}>{cat.nameFa}</span>
|
||||
{isFa ? (
|
||||
<>
|
||||
<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>
|
||||
</li>
|
||||
))
|
||||
|
||||
@@ -201,15 +201,18 @@ export function Sidebar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
navItems.forEach((item) => {
|
||||
if (item.type === 'group' && isGroupActive(item.basePath, pathname)) {
|
||||
setOpenGroups((prev) => ({ ...prev, [item.id]: true }))
|
||||
}
|
||||
})
|
||||
const activeGroup = navItems.find(
|
||||
(item) => item.type === 'group' && isGroupActive(item.basePath, pathname),
|
||||
)
|
||||
if (!activeGroup || activeGroup.type !== 'group') return
|
||||
setOpenGroups({ [activeGroup.id]: true })
|
||||
}, [pathname, navItems])
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
setOpenGroups((prev) => {
|
||||
const willOpen = !prev[id]
|
||||
return willOpen ? { [id]: true } : {}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,11 +24,12 @@
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable:hover .nameEn {
|
||||
.clickable:hover .nameEnPrimary,
|
||||
.clickable:hover .nameFa {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -72,35 +73,75 @@
|
||||
}
|
||||
|
||||
.festivalBadge {
|
||||
left: 8px;
|
||||
inset-inline-start: 8px;
|
||||
text-transform: uppercase;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.stockBadge {
|
||||
right: 8px;
|
||||
inset-inline-end: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1;
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nameEn {
|
||||
.bodyLtr {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
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;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
.nameFaSecondary {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -173,7 +214,6 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
|
||||
.addToCartIcon {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useId } from 'react'
|
||||
import { Pencil, Percent, Sparkles, Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import {
|
||||
formatVariantCount,
|
||||
type StoreProductListing,
|
||||
@@ -63,9 +65,21 @@ export function StoreItemCard({
|
||||
onFestival,
|
||||
onRemove,
|
||||
onAddToCart,
|
||||
removeTooltip = 'Remove from store',
|
||||
removeTooltip,
|
||||
}: StoreItemCardProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
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 (
|
||||
<article className={styles.card}>
|
||||
@@ -74,23 +88,43 @@ export function StoreItemCard({
|
||||
{listing.productImage ? (
|
||||
<img
|
||||
src={listing.productImage}
|
||||
alt={listing.productTitle}
|
||||
alt={primaryName}
|
||||
className={styles.image}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<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 && (
|
||||
<span className={styles.stockBadge}>{listing.productTotalStock} in stock</span>
|
||||
<span className={styles.stockBadge}>
|
||||
{t('storeItems.card.inStock', { count: listing.productTotalStock })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<h3 className={styles.nameEn}>{listing.productTitle}</h3>
|
||||
{listing.productNameFa && <p className={styles.nameFa}>{listing.productNameFa}</p>}
|
||||
<p className={styles.variantLabel}>{formatVariantCount(listing.variantCount)}</p>
|
||||
<div
|
||||
className={`${styles.body}${isFa ? '' : ` ${styles.bodyLtr}`}`}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
{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
|
||||
price={listing.displayPrice}
|
||||
discountedPrice={listing.displayDiscountedPrice}
|
||||
@@ -100,44 +134,52 @@ export function StoreItemCard({
|
||||
|
||||
<div className={styles.controls} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.controlsLeft}>
|
||||
<Tooltip label="Edit store items">
|
||||
<button type="button" onClick={() => onEdit(listing)} aria-label="Edit store items">
|
||||
<Tooltip label={t('storeItems.card.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(listing)}
|
||||
aria-label={t('storeItems.card.edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Set discounts">
|
||||
<button type="button" onClick={() => onDiscount(listing)} aria-label="Set discounts">
|
||||
<Tooltip label={t('storeItems.card.discount')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDiscount(listing)}
|
||||
aria-label={t('storeItems.card.discount')}
|
||||
>
|
||||
<Percent size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Festival reward points">
|
||||
<Tooltip label={t('storeItems.card.festivalPoints')}>
|
||||
<button
|
||||
type="button"
|
||||
className={listing.showFestival ? styles.festivalActive : undefined}
|
||||
onClick={() => onFestival(listing)}
|
||||
aria-label="Festival reward points"
|
||||
aria-label={t('storeItems.card.festivalPoints')}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeTooltip}>
|
||||
<Tooltip label={removeLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => onRemove(listing)}
|
||||
aria-label={removeTooltip}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip label="Add to shopping cart">
|
||||
<Tooltip label={t('storeItems.card.addToCart')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addToCartBtn}
|
||||
onClick={() => onAddToCart(listing)}
|
||||
aria-label="Add to shopping cart"
|
||||
aria-label={t('storeItems.card.addToCart')}
|
||||
>
|
||||
<GradientPlusIcon gradientId={plusGradientId} />
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: var(--field-font-size);
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
@@ -87,6 +87,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.optionFa {
|
||||
@@ -95,6 +96,19 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from '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 styles from './StoreItemMultiSearchSelect.module.css'
|
||||
|
||||
@@ -23,16 +25,20 @@ export function StoreItemMultiSearchSelect({
|
||||
options,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
placeholder = 'Type 3+ characters to search store items',
|
||||
placeholder,
|
||||
disabled = false,
|
||||
minSearchLength = 3,
|
||||
}: StoreItemMultiSearchSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const searchPlaceholder = placeholder ?? t('storeItems.search.minChars', { count: minSearchLength })
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const trimmed = query.trim()
|
||||
@@ -65,10 +71,40 @@ export function StoreItemMultiSearchSelect({
|
||||
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
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.wrapper} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={selectStyles.wrapper} ref={containerRef}>
|
||||
<div className={`${selectStyles.inputWrap} ${open ? selectStyles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={selectStyles.searchIcon} />
|
||||
@@ -76,7 +112,7 @@ export function StoreItemMultiSearchSelect({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={selectStyles.input}
|
||||
placeholder={placeholder}
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
@@ -84,6 +120,7 @@ export function StoreItemMultiSearchSelect({
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +128,9 @@ export function StoreItemMultiSearchSelect({
|
||||
<ul className={`${selectStyles.dropdown} ${styles.dropdown}`} role="listbox">
|
||||
{filtered.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
filtered.map((option) => {
|
||||
@@ -115,10 +154,7 @@ export function StoreItemMultiSearchSelect({
|
||||
<span className={styles.thumbPlaceholder} />
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.optionMeta}>
|
||||
<span className={styles.optionEn}>{option.title}</span>
|
||||
{option.nameFa && <span className={`${styles.optionFa} faText`}>{option.nameFa}</span>}
|
||||
</span>
|
||||
{renderOptionMeta(option)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
@@ -130,7 +166,7 @@ export function StoreItemMultiSearchSelect({
|
||||
{open && query.trim().length > 0 && query.trim().length < minSearchLength && (
|
||||
<ul className={selectStyles.dropdown}>
|
||||
<li className={selectStyles.noResults}>
|
||||
Type at least {minSearchLength} characters to search
|
||||
{t('storeItems.search.minChars', { count: minSearchLength })}
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
@@ -147,15 +183,12 @@ export function StoreItemMultiSearchSelect({
|
||||
<span className={styles.thumbPlaceholder} />
|
||||
)}
|
||||
</span>
|
||||
<div className={styles.optionMeta}>
|
||||
<span className={styles.optionEn}>{option.title}</span>
|
||||
{option.nameFa && <span className={`${styles.optionFa} faText`}>{option.nameFa}</span>}
|
||||
</div>
|
||||
{renderOptionMeta(option)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeSelectedBtn}
|
||||
onClick={() => onToggle(option.id)}
|
||||
aria-label={`Remove ${option.title}`}
|
||||
aria-label={t('storeItems.search.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navPair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { StoreSpecial } from '../types/storeSpecial'
|
||||
import { specialItemsToListings } from '../utils/storeSpecialListings'
|
||||
import type { StoreProductListing } from '../utils/storeProductGroups'
|
||||
@@ -33,6 +35,9 @@ export function StoreSpecialCarousel({
|
||||
onRemoveListing,
|
||||
onAddToCart,
|
||||
}: StoreSpecialCarouselProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const listings = specialItemsToListings(special.items)
|
||||
|
||||
@@ -43,85 +48,101 @@ export function StoreSpecialCarousel({
|
||||
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 (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>{special.title}</h3>
|
||||
<div className={styles.headerActions}>
|
||||
<Tooltip label="Edit category">
|
||||
<Tooltip label={t('website.specialItems.editCategory')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => onEditSpecial(special)}
|
||||
aria-label="Edit category"
|
||||
aria-label={t('website.specialItems.editCategory')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete category">
|
||||
<Tooltip label={t('website.specialItems.deleteCategory')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
|
||||
onClick={() => onDeleteSpecial(special)}
|
||||
aria-label="Delete category"
|
||||
aria-label={t('website.specialItems.deleteCategory')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Scroll left">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Scroll right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={styles.navPair} dir="ltr">
|
||||
<Tooltip label={t('website.specialItems.scrollLeft')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t('website.specialItems.scrollLeft')}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('website.specialItems.scrollRight')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t('website.specialItems.scrollRight')}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.trackWrap}>
|
||||
<div className={styles.track} ref={trackRef}>
|
||||
<div className={styles.cardSlot}>
|
||||
<Tooltip label={`Add store items to ${special.title}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addCard}
|
||||
onClick={() => onAddItems(special)}
|
||||
aria-label={`Add store items to ${special.title}`}
|
||||
>
|
||||
<span className={styles.addIcon}>
|
||||
<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 className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{isFa ? (
|
||||
<>
|
||||
{addCard}
|
||||
{itemCards}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{itemCards}
|
||||
{addCard}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './CategoryModal.module.css'
|
||||
|
||||
export interface StoreSpecialModalValues {
|
||||
key: string
|
||||
title: string
|
||||
}
|
||||
|
||||
interface StoreSpecialModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (title: string) => void
|
||||
onSubmit: (values: StoreSpecialModalValues) => void
|
||||
initialKey?: string
|
||||
initialTitle?: string
|
||||
title?: string
|
||||
submitLabel?: string
|
||||
fieldLabel?: string
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
|
||||
|
||||
export function StoreSpecialModal({
|
||||
open,
|
||||
onClose,
|
||||
onSubmit,
|
||||
initialKey = '',
|
||||
initialTitle = '',
|
||||
title = 'Add Special Category',
|
||||
submitLabel = 'Create',
|
||||
fieldLabel = 'Category title',
|
||||
title,
|
||||
submitLabel,
|
||||
isSubmitting = false,
|
||||
}: StoreSpecialModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [categoryTitle, setCategoryTitle] = useState(initialTitle)
|
||||
const [groupKey, setGroupKey] = useState(initialKey)
|
||||
const [groupTitle, setGroupTitle] = useState(initialTitle)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setCategoryTitle(initialTitle)
|
||||
setGroupKey(initialKey)
|
||||
setGroupTitle(initialTitle)
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
@@ -44,7 +57,7 @@ export function StoreSpecialModal({
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, initialTitle, mounted])
|
||||
}, [open, initialKey, initialTitle, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
@@ -57,11 +70,15 @@ export function StoreSpecialModal({
|
||||
|
||||
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>) {
|
||||
e.preventDefault()
|
||||
const trimmed = categoryTitle.trim()
|
||||
if (!trimmed) return
|
||||
onSubmit(trimmed)
|
||||
if (!canSubmit) return
|
||||
onSubmit({ key: trimmedKey, title: trimmedTitle })
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
@@ -75,39 +92,69 @@ export function StoreSpecialModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="special-modal-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h2 id="special-modal-title" className={styles.title}>
|
||||
{title}
|
||||
{title ?? t('website.specialItems.createTitle')}
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="special-category-title">{fieldLabel}</label>
|
||||
<label htmlFor="special-group-key">{t('website.groupKey')}</label>
|
||||
<input
|
||||
id="special-category-title"
|
||||
value={categoryTitle}
|
||||
onChange={(e) => setCategoryTitle(e.target.value)}
|
||||
placeholder="e.g. Special Sale, Our Best Sellers"
|
||||
id="special-group-key"
|
||||
value={groupKey}
|
||||
onChange={(e) => setGroupKey(e.target.value)}
|
||||
placeholder={t('website.groupKeyPlaceholder')}
|
||||
autoFocus
|
||||
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 className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.submitBtn}
|
||||
disabled={isSubmitting || !categoryTitle.trim()}
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : submitLabel}
|
||||
{isSubmitting
|
||||
? t('website.saving')
|
||||
: (submitLabel ?? t('website.create'))}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
padding: 6px 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navPair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronLeft, ChevronRight, Pencil, Plus, Trash2 } from 'lucide-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 { Tooltip } from './Tooltip'
|
||||
import styles from './WebsiteGroupCarousel.module.css'
|
||||
@@ -26,10 +28,15 @@ export function WebsiteGroupCarousel<T>({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
addTooltip,
|
||||
editTooltip = 'Edit group',
|
||||
deleteTooltip = 'Delete group',
|
||||
editTooltip,
|
||||
deleteTooltip,
|
||||
}: WebsiteGroupCarouselProps<T>) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const editLabel = editTooltip ?? t('website.editGroup')
|
||||
const deleteLabel = deleteTooltip ?? t('website.deleteGroup')
|
||||
|
||||
function scrollBy(direction: -1 | 1) {
|
||||
const track = trackRef.current
|
||||
@@ -38,76 +45,92 @@ export function WebsiteGroupCarousel<T>({
|
||||
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 (
|
||||
<section className={styles.section}>
|
||||
<div className={styles.header}>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<div className={styles.headerActions}>
|
||||
<Tooltip label={editTooltip}>
|
||||
<Tooltip label={editLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={onEditGroup}
|
||||
aria-label={editTooltip}
|
||||
aria-label={editLabel}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={deleteTooltip}>
|
||||
<Tooltip label={deleteLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
|
||||
onClick={onDeleteGroup}
|
||||
aria-label={deleteTooltip}
|
||||
aria-label={deleteLabel}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Scroll left">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Scroll right">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className={styles.navPair} dir="ltr">
|
||||
<Tooltip label={t('website.scrollLeft')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label={t('website.scrollLeft')}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('website.scrollRight')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label={t('website.scrollRight')}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.trackWrap}>
|
||||
<div className={styles.track} ref={trackRef}>
|
||||
<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>
|
||||
|
||||
{items.map((item) => (
|
||||
<div key={itemKey(item)} className={styles.cardSlot}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{isFa ? (
|
||||
<>
|
||||
{addCard}
|
||||
{itemCards}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{itemCards}
|
||||
{addCard}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -22,22 +22,38 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nameEn {
|
||||
.namePrimary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.35;
|
||||
text-align: start;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
.namePrimary[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nameSecondary {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
direction: rtl;
|
||||
line-height: 1.35;
|
||||
text-align: start;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nameSecondary[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
@@ -45,12 +61,17 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
text-align: start;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card[dir='rtl'] .subtitle {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './WebsiteGroupItemCard.module.css'
|
||||
|
||||
@@ -15,23 +17,55 @@ export function WebsiteGroupItemCard({
|
||||
nameFa,
|
||||
subtitle,
|
||||
onRemove,
|
||||
removeTooltip = 'Remove from group',
|
||||
removeTooltip,
|
||||
}: 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 (
|
||||
<article className={styles.card}>
|
||||
<article className={styles.card} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={styles.text}>
|
||||
<h3 className={styles.nameEn}>{title}</h3>
|
||||
{nameFa && <p className={styles.nameFa}>{nameFa}</p>}
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
{isFa ? (
|
||||
<>
|
||||
<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 className={styles.controls}>
|
||||
<Tooltip label={removeTooltip}>
|
||||
<Tooltip label={removeLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={onRemove}
|
||||
aria-label={removeTooltip}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
.navPair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { useRef } from 'react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { WebsiteSliderSlide } from '../types/websiteSlider'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { WebsiteSliderSlideCard } from './WebsiteSliderSlideCard'
|
||||
@@ -16,6 +18,9 @@ export function WebsiteSliderGallery({
|
||||
onAddSlide,
|
||||
onRemoveSlide,
|
||||
}: WebsiteSliderGalleryProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const showNav = slides.length > 0
|
||||
|
||||
@@ -26,27 +31,50 @@ export function WebsiteSliderGallery({
|
||||
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 (
|
||||
<section className={styles.section}>
|
||||
{showNav && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerActions}>
|
||||
<Tooltip label="Scroll left">
|
||||
<div className={styles.navPair} dir="ltr">
|
||||
<Tooltip label={t('website.scrollLeft')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(-1)}
|
||||
aria-label="Scroll left"
|
||||
aria-label={t('website.scrollLeft')}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Scroll right">
|
||||
<Tooltip label={t('website.scrollRight')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => scrollBy(1)}
|
||||
aria-label="Scroll right"
|
||||
aria-label={t('website.scrollRight')}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
@@ -56,27 +84,9 @@ export function WebsiteSliderGallery({
|
||||
)}
|
||||
|
||||
<div className={styles.trackWrap}>
|
||||
<div className={styles.track} ref={trackRef}>
|
||||
<div className={`${styles.slideSlot} ${styles.slideSlotAdd}`}>
|
||||
<Tooltip label="Add slide">
|
||||
<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 className={styles.track} ref={trackRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
{slideCards}
|
||||
{addCard}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { WebsiteSliderSlide } from '../types/websiteSlider'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './WebsiteSliderSlideCard.module.css'
|
||||
@@ -9,20 +10,27 @@ interface WebsiteSliderSlideCardProps {
|
||||
}
|
||||
|
||||
export function WebsiteSliderSlideCard({ slide, onRemove }: WebsiteSliderSlideCardProps) {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
<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 className={styles.footer}>
|
||||
<p className={styles.title}>{slide.title?.trim() || 'Untitled slide'}</p>
|
||||
<Tooltip label="Remove slide">
|
||||
<p className={styles.title}>{slide.title?.trim() || t('website.sliders.untitled')}</p>
|
||||
<Tooltip label={t('website.sliders.removeSlide')}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
onClick={onRemove}
|
||||
aria-label="Remove slide"
|
||||
aria-label={t('website.sliders.removeSlide')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user