mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2e40d5eb4c
commit
f5b2193ba1
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
@@ -96,7 +97,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -205,5 +206,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -123,7 +124,7 @@ export function AddProductByAiModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && !isSubmitting && onClose()}
|
||||
@@ -230,5 +231,7 @@ export function AddProductByAiModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import type { Variation, VariationType } from '../types/variation'
|
||||
import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation'
|
||||
@@ -114,7 +115,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
||||
? selectedSizes.length > 0
|
||||
: customValues.some((v) => v.trim())
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -243,6 +244,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ImageCropper } from './ImageCropper'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
@@ -70,7 +71,7 @@ export function AddWebsiteSliderSlideModal({
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
@@ -149,5 +150,7 @@ export function AddWebsiteSliderSlideModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
@@ -102,6 +103,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.title[dir='ltr'] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.abstract {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
@@ -113,6 +122,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.abstract[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.abstract[dir='ltr'] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatBlogCardDate,
|
||||
isBlogVerified,
|
||||
} from '../services/blogService'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './BlogCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -31,6 +32,9 @@ export function BlogCard({
|
||||
}: BlogCardProps) {
|
||||
const verified = isBlogVerified(blog)
|
||||
const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt)
|
||||
const titleLocale = textLocaleAttrs(blog.title)
|
||||
const abstractLocale = textLocaleAttrs(blog.abstract)
|
||||
const hasAbstract = Boolean(blog.abstract?.trim())
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -60,11 +64,27 @@ export function BlogCard({
|
||||
</div>
|
||||
|
||||
<div className={cardStyles.body}>
|
||||
<h3 className={cardStyles.title}>{blog.title}</h3>
|
||||
{blog.abstract ? (
|
||||
<p className={cardStyles.abstract}>{blog.abstract}</p>
|
||||
<h3
|
||||
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
>
|
||||
{blog.title}
|
||||
</h3>
|
||||
{hasAbstract ? (
|
||||
<p
|
||||
className={[cardStyles.abstract, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{blog.abstract}
|
||||
</p>
|
||||
) : (
|
||||
<p className={cardStyles.abstract}>No summary yet.</p>
|
||||
<p className={cardStyles.abstract} lang="en" dir="ltr">
|
||||
No summary yet.
|
||||
</p>
|
||||
)}
|
||||
<p className={cardStyles.meta}>
|
||||
<span>{formatBlogAuthor(blog.author)}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderPlus, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { FolderPlus, Pencil, Trash2, ChevronRight } from 'lucide-react'
|
||||
import type { Category } from '../types/category'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import styles from './CategoryRow.module.css'
|
||||
@@ -10,6 +10,7 @@ interface BlogCategoryRowProps {
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
onAddSub: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
@@ -20,10 +21,11 @@ export function BlogCategoryRow({
|
||||
expanded,
|
||||
onToggle,
|
||||
onAddSub,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: BlogCategoryRowProps) {
|
||||
return (
|
||||
<div className={styles.row} style={{ marginLeft: depth * 28 }}>
|
||||
<div className={styles.row} style={{ marginLeft: depth * 8 }}>
|
||||
<div
|
||||
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
||||
onClick={hasChildren ? onToggle : undefined}
|
||||
@@ -59,6 +61,7 @@ export function BlogCategoryRow({
|
||||
<div className={styles.controls}>
|
||||
<Tooltip label="Add sub category">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onAddSub(category.id)}
|
||||
aria-label="Add sub category"
|
||||
@@ -66,8 +69,19 @@ export function BlogCategoryRow({
|
||||
<FolderPlus size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit category">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.controlBtn}
|
||||
onClick={() => onEdit(category.id)}
|
||||
aria-label="Edit category"
|
||||
>
|
||||
<Pencil size={17} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove category">
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.controlBtn} ${styles.danger}`}
|
||||
onClick={() => onRemove(category.id)}
|
||||
aria-label="Remove"
|
||||
|
||||
@@ -8,6 +8,7 @@ interface BlogCategoryTreeProps {
|
||||
expandedIds: Set<string>
|
||||
onToggle: (id: string) => void
|
||||
onAddSub: (id: string) => void
|
||||
onEdit: (id: string) => void
|
||||
onRemove: (id: string) => void
|
||||
parentId?: string | null
|
||||
depth?: number
|
||||
@@ -18,6 +19,7 @@ export function BlogCategoryTree({
|
||||
expandedIds,
|
||||
onToggle,
|
||||
onAddSub,
|
||||
onEdit,
|
||||
onRemove,
|
||||
parentId = null,
|
||||
depth = 0,
|
||||
@@ -39,6 +41,7 @@ export function BlogCategoryTree({
|
||||
expanded={expanded}
|
||||
onToggle={() => onToggle(category.id)}
|
||||
onAddSub={onAddSub}
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
{childCount && (
|
||||
@@ -49,6 +52,7 @@ export function BlogCategoryTree({
|
||||
expandedIds={expandedIds}
|
||||
onToggle={onToggle}
|
||||
onAddSub={onAddSub}
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
parentId={category.id}
|
||||
depth={depth + 1}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
@@ -147,7 +148,7 @@ export function BlogCommentsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -258,5 +259,7 @@ export function BlogCommentsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ImageCropper } from './ImageCropper'
|
||||
import type { Brand, BrandFormData } from '../types/brand'
|
||||
@@ -73,7 +74,7 @@ export function BrandModal({
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -167,5 +168,7 @@ export function BrandModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
@@ -79,7 +80,7 @@ export function CategoryAiPromptModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
||||
@@ -157,5 +158,7 @@ export function CategoryAiPromptModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import { flattenCategories } from '../utils/categories'
|
||||
@@ -79,7 +80,7 @@ export function CategoryModal({
|
||||
categories.filter((category) => category.id !== editingCategory?.id),
|
||||
)
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -170,5 +171,7 @@ export function CategoryModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function CategoryRow({
|
||||
return (
|
||||
<div
|
||||
className={styles.row}
|
||||
style={{ marginLeft: depth * 28 }}
|
||||
style={{ marginLeft: depth * 8 }}
|
||||
>
|
||||
<div
|
||||
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
|
||||
|
||||
@@ -10,4 +10,7 @@
|
||||
|
||||
.childrenInner {
|
||||
overflow: hidden;
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
border-left: 2px solid rgba(148, 163, 184, 0.3);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
import styles from './ConfirmDeleteModal.module.css'
|
||||
|
||||
@@ -47,7 +48,7 @@ export function ConfirmDeleteModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onCancel}
|
||||
@@ -82,5 +83,7 @@ export function ConfirmDeleteModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { ContactSubmission } from '../types/contactSubmission'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
@@ -57,7 +58,7 @@ export function ContactSubmissionDetailModal({
|
||||
|
||||
const { date, time } = formatDateTime(submission.createdAt)
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -119,5 +120,7 @@ export function ContactSubmissionDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { ProductVariant } from '../types/productVariant'
|
||||
import { buildVariantLabel } from '../types/productVariant'
|
||||
@@ -96,7 +97,7 @@ export function CreateProductVariantModal({
|
||||
onSubmit({ variationId, optionId })
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -184,5 +185,7 @@ export function CreateProductVariantModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllProducts } from '../services/productService'
|
||||
@@ -241,7 +242,7 @@ export function CreateStoreItemsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -395,5 +396,7 @@ export function CreateStoreItemsModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
|
||||
@@ -96,7 +97,7 @@ export function EditCustomerModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -186,5 +187,7 @@ export function EditCustomerModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -209,7 +210,7 @@ export function EditStoreItemsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -346,5 +347,7 @@ export function EditStoreItemsModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import styles from './ImageLightbox.module.css'
|
||||
|
||||
@@ -59,7 +60,7 @@ export function ImageLightbox({
|
||||
const hasPrev = index > 0
|
||||
const hasNext = index < images.length - 1
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${open ? styles.overlayIn : styles.overlayOut}`}
|
||||
onClick={onClose}
|
||||
@@ -118,5 +119,7 @@ export function ImageLightbox({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Order } from '../services/orderService'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
||||
|
||||
const itemCount = totalQuantity(order)
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -148,5 +149,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
@@ -86,7 +87,7 @@ export function OrderStepModal({
|
||||
steps.find((step) => step.id === order.processStepId)?.label ||
|
||||
order.processStepId
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={() => {
|
||||
@@ -161,5 +162,7 @@ export function OrderStepModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
@@ -116,7 +117,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
||||
|
||||
if (!mounted || !order) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -188,5 +189,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.gridCols4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.gridFour {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -76,6 +82,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.gridCols4 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.gridHome {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -84,7 +96,8 @@
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gridHome,
|
||||
.grid {
|
||||
.grid,
|
||||
.gridCols4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +122,8 @@
|
||||
|
||||
.grid,
|
||||
.gridHome,
|
||||
.gridFour {
|
||||
.gridFour,
|
||||
.gridCols4 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
transition: background 0.2s, color 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.navBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.navBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
min-width: 38px;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.pageBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pageBtn.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
@@ -1,53 +1 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import styles from './Pagination.module.css'
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
|
||||
return (
|
||||
<nav className={styles.pagination} aria-label="Product pages">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
|
||||
<div className={styles.pages}>
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${page === currentPage ? styles.active : ''}`}
|
||||
onClick={() => onPageChange(page)}
|
||||
aria-label={`Page ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navBtn}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
export { Pagination, type PaginationProps } from '@meshkee/dashboard-ui'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllStoreItems } from '../services/storeItemService'
|
||||
@@ -118,7 +119,7 @@ export function PickStoreSpecialItemsModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
@@ -175,5 +176,7 @@ export function PickStoreSpecialItemsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import type { StoreItem } from '../services/storeItemService'
|
||||
import { StoreItemPrice } from './StoreItemPrice'
|
||||
@@ -52,7 +53,7 @@ export function PickStoreVariantModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -112,5 +113,7 @@ export function PickStoreVariantModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listAllBrands } from '../services/brandService'
|
||||
@@ -111,7 +112,7 @@ export function PickWebsiteBrandsModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
@@ -170,5 +171,7 @@ export function PickWebsiteBrandsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||
@@ -112,7 +113,7 @@ export function PickWebsiteCategoriesModal({
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
@@ -171,5 +172,7 @@ export function PickWebsiteCategoriesModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,31 +25,28 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable:hover .title {
|
||||
.clickable:hover .titleFa,
|
||||
.clickable:hover .titleEn {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(148, 163, 184, 0.12) 0%,
|
||||
@@ -65,11 +62,15 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
.titleFa {
|
||||
margin: 0;
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
transition: color 0.2s;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -77,6 +78,20 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleEn {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
transition: color 0.2s;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.abstract {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
@@ -88,6 +103,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.abstract[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.abstract[dir='ltr'] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
|
||||
import { Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
||||
import type { Portfolio } from '../types/portfolio'
|
||||
import { formatPortfolioCardDate } from '../services/portfolioService'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import cardStyles from './PortfolioCard.module.css'
|
||||
import controlStyles from './ProductCard.module.css'
|
||||
@@ -22,6 +23,12 @@ export function PortfolioCard({
|
||||
onRemove,
|
||||
}: PortfolioCardProps) {
|
||||
const publishDate = formatPortfolioCardDate(portfolio.publishedAt ?? portfolio.createdAt)
|
||||
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())
|
||||
|
||||
return (
|
||||
<article className={cardStyles.card}>
|
||||
@@ -30,7 +37,7 @@ export function PortfolioCard({
|
||||
{portfolio.titleImageUrl ? (
|
||||
<img
|
||||
src={portfolio.titleImageUrl}
|
||||
alt={portfolio.title}
|
||||
alt={titleFa || titleEn || 'Portfolio'}
|
||||
className={cardStyles.image}
|
||||
loading="lazy"
|
||||
/>
|
||||
@@ -40,12 +47,33 @@ export function PortfolioCard({
|
||||
</div>
|
||||
|
||||
<div className={cardStyles.body}>
|
||||
<h3 className={cardStyles.title}>{portfolio.title}</h3>
|
||||
{portfolio.abstract ? (
|
||||
<p className={cardStyles.abstract}>{portfolio.abstract}</p>
|
||||
) : (
|
||||
<p className={cardStyles.abstract}>No summary yet.</p>
|
||||
)}
|
||||
{titleFa ? (
|
||||
<h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl">
|
||||
{titleFa}
|
||||
</h3>
|
||||
) : null}
|
||||
{titleEn ? (
|
||||
<p className={cardStyles.titleEn} lang="en" dir="ltr">
|
||||
{titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
{showAbstract ? (
|
||||
hasAbstract ? (
|
||||
<p
|
||||
className={[cardStyles.abstract, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{portfolio.abstract}
|
||||
</p>
|
||||
) : (
|
||||
<p className={cardStyles.abstract} lang="en" dir="ltr">
|
||||
No summary yet.
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
<p className={cardStyles.meta}>
|
||||
{portfolio.categoryName ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
@@ -147,7 +148,7 @@ export function PortfolioCommentsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -258,5 +259,7 @@ export function PortfolioCommentsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
|
||||
}
|
||||
|
||||
.image {
|
||||
@@ -80,6 +81,7 @@
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
|
||||
import type { ProductComment } from '../types/comment'
|
||||
import { formatCommentDate } from '../data/productComments'
|
||||
@@ -147,7 +148,7 @@ export function ProductCommentsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -258,5 +259,7 @@ export function ProductCommentsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -210,7 +211,7 @@ export function ProductTechnicalInfoModal({
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -271,5 +272,7 @@ export function ProductTechnicalInfoModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -119,7 +120,7 @@ export function ProductVariantsModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -202,5 +203,7 @@ export function ProductVariantsModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
|
||||
.optionFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-weight: 500;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowLeft, Minus, Plus, Search, Trash2, User, X } from 'lucide-react'
|
||||
import { useDraftCart } from '../context/DraftCartContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -341,7 +342,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
|
||||
|
||||
const canAddPayment = payments.length < PAYMENT_METHOD_OPTIONS.length
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -700,5 +701,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -119,7 +120,7 @@ export function StoreItemDiscountModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -196,5 +197,7 @@ export function StoreItemDiscountModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
@@ -114,7 +115,7 @@ export function StoreItemFestivalModal({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -193,5 +194,7 @@ export function StoreItemFestivalModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import styles from './CategoryModal.module.css'
|
||||
|
||||
@@ -63,13 +64,18 @@ export function StoreSpecialModal({
|
||||
onSubmit(trimmed)
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={styles.overlay}
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
<div className={styles.modal} role="dialog" aria-modal="true" aria-labelledby="special-modal-title">
|
||||
<div
|
||||
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="special-modal-title"
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<h2 id="special-modal-title" className={styles.title}>
|
||||
{title}
|
||||
@@ -107,5 +113,7 @@ export function StoreSpecialModal({
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './CategoryAiPromptModal.module.css'
|
||||
|
||||
export function buildTechnicalFormAiPrompt(categoryName: string) {
|
||||
return `Suggest technical data form fields for the "${categoryName}" product category.
|
||||
|
||||
For each field include:
|
||||
- A short English label (e.g. "Screen Size", "Material", "Color")
|
||||
- The input type: text, textarea, select, or multi_select
|
||||
- Whether it is required
|
||||
- For select / multi_select: the list of options
|
||||
|
||||
Focus on specs and attributes that shoppers typically compare when buying this type of product.`
|
||||
}
|
||||
|
||||
interface TechnicalFormAiPromptModalProps {
|
||||
open: boolean
|
||||
categoryName: string
|
||||
onClose: () => void
|
||||
onRun: (prompt: string) => Promise<void>
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
export function TechnicalFormAiPromptModal({
|
||||
open,
|
||||
categoryName,
|
||||
onClose,
|
||||
onRun,
|
||||
isRunning,
|
||||
}: TechnicalFormAiPromptModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setPrompt(buildTechnicalFormAiPrompt(categoryName))
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted, categoryName])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isRunning) onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [mounted, closing, isRunning, onClose])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const trimmed = prompt.trim()
|
||||
if (trimmed.length < 10) {
|
||||
setError('Prompt must be at least 10 characters.')
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
try {
|
||||
await onRun(trimmed)
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError('Unable to generate technical form with AI.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="tech-form-ai-prompt-title"
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
|
||||
Generate Technical Form with AI
|
||||
</h2>
|
||||
<p className={styles.subtitle}>
|
||||
Edit the prompt below, then run it to fill the form fields.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
|
||||
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
|
||||
<label htmlFor="tech-form-ai-prompt">Prompt</label>
|
||||
<textarea
|
||||
id="tech-form-ai-prompt"
|
||||
className={aiStyles.aiPromptTextarea}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={8}
|
||||
disabled={isRunning}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
|
||||
<div className={modalStyles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.cancelBtn}
|
||||
onClick={onClose}
|
||||
disabled={isRunning}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={aiStyles.aiBtn}
|
||||
disabled={isRunning || prompt.trim().length < 10}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
{isRunning ? 'Generating…' : 'Run'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -156,4 +156,5 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||
import { createId } from '../utils/id'
|
||||
@@ -18,6 +19,7 @@ interface TechnicalFormModalProps {
|
||||
onChange: (fields: TechnicalFormFieldDraft[]) => void
|
||||
onSave: () => void
|
||||
onGenerate?: () => void
|
||||
onGenerateClick?: () => void
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
@@ -51,6 +53,7 @@ export function TechnicalFormModal({
|
||||
onChange,
|
||||
onSave,
|
||||
onGenerate,
|
||||
onGenerateClick,
|
||||
}: TechnicalFormModalProps) {
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
@@ -166,7 +169,7 @@ export function TechnicalFormModal({
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
onClick={onClose}
|
||||
@@ -314,11 +317,11 @@ export function TechnicalFormModal({
|
||||
</div>
|
||||
|
||||
<div className={fieldStyles.toolbar}>
|
||||
{onGenerate && (
|
||||
{(onGenerateClick ?? onGenerate) && (
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiBtn}
|
||||
onClick={onGenerate}
|
||||
onClick={onGenerateClick ?? onGenerate}
|
||||
disabled={isLoading || isSaving || isGenerating}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
@@ -356,5 +359,7 @@ export function TechnicalFormModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.overlayNested {
|
||||
z-index: 210;
|
||||
z-index: 1010;
|
||||
}
|
||||
|
||||
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
|
||||
@@ -119,6 +119,14 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.listItemName:global(.faText),
|
||||
.listItemValues:global(.faText),
|
||||
.subtitle:global(.faText) {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.listItemValues {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import type { Variation, VariationType } from '../types/variation'
|
||||
import { createId } from '../utils/id'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import { AddVariationModal } from './AddVariationModal'
|
||||
import styles from './VariationsModal.module.css'
|
||||
|
||||
@@ -57,6 +59,7 @@ export function VariationsModal({
|
||||
if (!mounted) return null
|
||||
|
||||
const list = variations
|
||||
const categoryLocale = textLocaleAttrs(categoryName)
|
||||
|
||||
function handleAdd(variation: Omit<Variation, 'id'>) {
|
||||
onChange([
|
||||
@@ -76,7 +79,7 @@ export function VariationsModal({
|
||||
return 'Custom'
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
|
||||
@@ -95,7 +98,13 @@ export function VariationsModal({
|
||||
Variations
|
||||
</h3>
|
||||
{categoryName && (
|
||||
<p className={styles.subtitle}>{categoryName}</p>
|
||||
<p
|
||||
className={[styles.subtitle, categoryLocale.className].filter(Boolean).join(' ')}
|
||||
lang={categoryLocale.lang}
|
||||
dir={categoryLocale.dir}
|
||||
>
|
||||
{categoryName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
|
||||
@@ -105,12 +114,26 @@ export function VariationsModal({
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.list}>
|
||||
{list.map((variation) => (
|
||||
{list.map((variation) => {
|
||||
const nameLocale = textLocaleAttrs(variation.name)
|
||||
const valuesText = variation.values.join(' · ') || 'No values'
|
||||
const valuesLocale = textLocaleAttrs(valuesText)
|
||||
return (
|
||||
<div key={variation.id} className={styles.listItem}>
|
||||
<div className={styles.listItemInfo}>
|
||||
<div className={styles.listItemName}>{variation.name}</div>
|
||||
<div className={styles.listItemValues}>
|
||||
{variation.values.join(' · ') || 'No values'}
|
||||
<div
|
||||
className={[styles.listItemName, nameLocale.className].filter(Boolean).join(' ')}
|
||||
lang={nameLocale.lang}
|
||||
dir={nameLocale.dir}
|
||||
>
|
||||
{variation.name}
|
||||
</div>
|
||||
<div
|
||||
className={[styles.listItemValues, valuesLocale.className].filter(Boolean).join(' ')}
|
||||
lang={valuesLocale.lang}
|
||||
dir={valuesLocale.dir}
|
||||
>
|
||||
{valuesText}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.listItemEnd}>
|
||||
@@ -125,7 +148,8 @@ export function VariationsModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!list.length && (
|
||||
<p className={styles.emptyText}>No variations yet. Add color, size, or custom options.</p>
|
||||
)}
|
||||
@@ -153,6 +177,7 @@ export function VariationsModal({
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSubmit={handleAdd}
|
||||
/>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@
|
||||
|
||||
.nameFa {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const dummyProducts: Product[] = catalog.map((item, index) => ({
|
||||
commentCount: [3, 12, 0, 7, 24, 5, 1, 18, 9, 0, 14, 6, 21, 4, 11, 2, 8, 16, 0, 13, 7, 19, 3, 10][index] ?? 0,
|
||||
}))
|
||||
|
||||
export const PRODUCTS_PER_PAGE = 12
|
||||
export const PRODUCTS_PER_PAGE = 24
|
||||
|
||||
export function getProductById(id: string): Product | undefined {
|
||||
return dummyProducts.find((p) => p.id === id)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
|
||||
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -11,30 +9,8 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
|
||||
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANYekan';
|
||||
src:
|
||||
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
|
||||
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -125,19 +125,24 @@ textarea:focus {
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
|
||||
textarea::placeholder {
|
||||
font-family: var(--font-en);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[dir='rtl'],
|
||||
:lang(fa),
|
||||
.faText {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-weight: 400;
|
||||
font-weight: 400; /* IRANYekan Regular */
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir='rtl']::placeholder,
|
||||
:lang(fa)::placeholder,
|
||||
.faText::placeholder {
|
||||
.faText::placeholder,
|
||||
input.faText::placeholder,
|
||||
input[dir='rtl']::placeholder,
|
||||
input[lang='fa']::placeholder {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-weight: 400;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export function AddNewPortfolioPage() {
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [titleFa, setTitleFa] = useState('')
|
||||
const [titleEn, setTitleEn] = useState('')
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [mainTextHtml, setMainTextHtml] = useState('')
|
||||
const [titleImage, setTitleImage] = useState<string | null>(null)
|
||||
@@ -98,7 +99,8 @@ export function AddNewPortfolioPage() {
|
||||
const portfolio = await getPortfolio(id!, controller.signal)
|
||||
const form = mapPortfolioApiToFormState(portfolio)
|
||||
setCategoryId(form.categoryId)
|
||||
setTitle(form.title)
|
||||
setTitleFa(form.titleFa)
|
||||
setTitleEn(form.titleEn)
|
||||
setAbstract(form.abstract)
|
||||
setMainTextHtml(form.mainTextHtml)
|
||||
setTitleImage(form.titleImage)
|
||||
@@ -140,7 +142,8 @@ export function AddNewPortfolioPage() {
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim(),
|
||||
mainTextHtml: resolvedMainTextHtml,
|
||||
categoryId: categoryId || undefined,
|
||||
@@ -267,18 +270,37 @@ export function AddNewPortfolioPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title">Title</label>
|
||||
<input
|
||||
id="portfolio-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Portfolio title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-fa">Title (FA)</label>
|
||||
<input
|
||||
id="portfolio-title-fa"
|
||||
name="titleFa"
|
||||
type="text"
|
||||
dir="rtl"
|
||||
lang="fa"
|
||||
className="faText"
|
||||
placeholder="عنوان فارسی"
|
||||
value={titleFa}
|
||||
onChange={(e) => setTitleFa(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title-en">Title (EN)</label>
|
||||
<input
|
||||
id="portfolio-title-en"
|
||||
name="titleEn"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="English title"
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnailField {
|
||||
align-self: start;
|
||||
}
|
||||
@@ -244,6 +251,10 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.fieldFull,
|
||||
.fieldHalf,
|
||||
.fieldCategory,
|
||||
|
||||
@@ -63,6 +63,13 @@ export function BlogCategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -174,6 +181,10 @@ export function BlogCategoriesPage() {
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../services/blogService'
|
||||
import type { BlogDetail } from '../types/blog'
|
||||
import { BLOG_TYPE_OPTIONS } from '../types/blog'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './BlogDetailsPage.module.css'
|
||||
|
||||
@@ -71,6 +72,11 @@ export function BlogDetailsPage() {
|
||||
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
|
||||
const typeLabel =
|
||||
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type
|
||||
const titleLocale = textLocaleAttrs(blog.title)
|
||||
const abstractLocale = textLocaleAttrs(blog.abstract)
|
||||
const contentLocale = textLocaleAttrs(
|
||||
blog.mainTextHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
@@ -85,8 +91,26 @@ export function BlogDetailsPage() {
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<h2 className={pageStyles.pageTitle}>{blog.title}</h2>
|
||||
{blog.abstract && <p className={pageStyles.pageSubtitle}>{blog.abstract}</p>}
|
||||
<h2
|
||||
className={[pageStyles.pageTitle, titleLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={titleLocale.lang}
|
||||
dir={titleLocale.dir}
|
||||
>
|
||||
{blog.title}
|
||||
</h2>
|
||||
{blog.abstract ? (
|
||||
<p
|
||||
className={[pageStyles.pageSubtitle, abstractLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={abstractLocale.lang}
|
||||
dir={abstractLocale.dir}
|
||||
>
|
||||
{blog.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,11 +148,15 @@ export function BlogDetailsPage() {
|
||||
<div className={styles.contentPanel}>
|
||||
{blog.mainTextHtml ? (
|
||||
<div
|
||||
className={styles.content}
|
||||
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
|
||||
lang={contentLocale.lang}
|
||||
dir={contentLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className={styles.content}>No content yet.</p>
|
||||
<p className={styles.content} lang="en" dir="ltr">
|
||||
No content yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.authorRow}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { VariationsModal } from '../components/VariationsModal'
|
||||
import { TechnicalFormModal } from '../components/TechnicalFormModal'
|
||||
import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptModal'
|
||||
import type { Category, CategoryFormData } from '../types/category'
|
||||
import type { Variation } from '../types/variation'
|
||||
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
|
||||
@@ -68,6 +69,7 @@ export function CategoriesPage() {
|
||||
const [technicalError, setTechnicalError] = useState('')
|
||||
const [aiModalOpen, setAiModalOpen] = useState(false)
|
||||
const [aiGenerating, setAiGenerating] = useState(false)
|
||||
const [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -287,14 +289,19 @@ export function CategoriesPage() {
|
||||
return categoryTechnicalFields[categoryId] ?? []
|
||||
}
|
||||
|
||||
async function handleGenerateTechnicalForm() {
|
||||
function handleGenerateTechnicalFormClick() {
|
||||
setTechAiPromptOpen(true)
|
||||
}
|
||||
|
||||
async function handleGenerateTechnicalForm(hint?: string) {
|
||||
if (!technicalTarget) return
|
||||
|
||||
setTechAiPromptOpen(false)
|
||||
setTechnicalGenerating(true)
|
||||
setTechnicalError('')
|
||||
|
||||
try {
|
||||
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId)
|
||||
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId, hint)
|
||||
const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({
|
||||
id: createId(),
|
||||
label: field.label,
|
||||
@@ -523,6 +530,17 @@ export function CategoriesPage() {
|
||||
}}
|
||||
onSave={() => void handleSaveTechnicalForm()}
|
||||
onGenerate={() => void handleGenerateTechnicalForm()}
|
||||
onGenerateClick={handleGenerateTechnicalFormClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{technicalTarget && (
|
||||
<TechnicalFormAiPromptModal
|
||||
open={techAiPromptOpen}
|
||||
categoryName={technicalTarget.categoryName}
|
||||
onClose={() => setTechAiPromptOpen(false)}
|
||||
onRun={(prompt) => handleGenerateTechnicalForm(prompt)}
|
||||
isRunning={technicalGenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.customerName[dir='rtl'] {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.emailCell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -139,7 +144,7 @@
|
||||
}
|
||||
|
||||
.toggleInActions {
|
||||
margin-right: 0;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -185,14 +190,22 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AddCustomerModal } from '../components/AddCustomerModal'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { EditCustomerModal } from '../components/EditCustomerModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type CustomersListResponse,
|
||||
} from '../services/customerService'
|
||||
import { formatIrtPrice } from '../utils/irtPrice'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import filterStyles from '../components/ListFiltersPanel.module.css'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './CustomersPage.module.css'
|
||||
@@ -30,14 +32,6 @@ function formatDate(value: string) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(customer: BusinessCustomerListItem) {
|
||||
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
@@ -112,8 +106,6 @@ export function CustomersPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -365,7 +357,24 @@ export function CustomersPage() {
|
||||
className={!customer.isEnabled ? styles.inactiveRow : undefined}
|
||||
>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.customerName}>{displayName(customer)}</div>
|
||||
{(() => {
|
||||
const name = displayName(customer)
|
||||
const locale = textLocaleAttrs(name)
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
styles.customerName,
|
||||
locale.className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={locale.lang}
|
||||
dir={locale.dir}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!customer.isEnabled && (
|
||||
<div className={styles.statusDisabled}>Disabled</div>
|
||||
)}
|
||||
@@ -383,15 +392,19 @@ export function CustomersPage() {
|
||||
</td>
|
||||
<td className={`${styles.td} ${styles.tdActions}`}>
|
||||
<div className={styles.rowActions}>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={customer.isEnabled}
|
||||
disabled={togglingId === customer.id || removing}
|
||||
size="compact"
|
||||
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
|
||||
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
|
||||
/>
|
||||
</span>
|
||||
<Tooltip
|
||||
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
|
||||
>
|
||||
<span className={styles.toggleInActions}>
|
||||
<ToggleSwitch
|
||||
checked={customer.isEnabled}
|
||||
disabled={togglingId === customer.id || removing}
|
||||
size="compact"
|
||||
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
|
||||
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit customer">
|
||||
<button
|
||||
type="button"
|
||||
@@ -442,37 +455,15 @@ export function CustomersPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
padding: 12px 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -260,11 +260,19 @@ export function MyProductsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
|
||||
{totalProducts} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
disabled={isLoading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -267,14 +267,22 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagination > div:first-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { OrderItemsModal } from '../components/OrderItemsModal'
|
||||
import { OrderStepModal } from '../components/OrderStepModal'
|
||||
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -39,14 +40,6 @@ interface AppliedFilters {
|
||||
maxTotal?: number
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(order: Order) {
|
||||
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
||||
return name || '—'
|
||||
@@ -202,8 +195,6 @@ export function OrdersPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -547,37 +538,15 @@ export function OrdersPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,13 @@ export function PortfolioCategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEditModal(category: Category) {
|
||||
setEditingCategory(category)
|
||||
setDefaultParentId(category.parentId ?? '')
|
||||
setModalTitle('Edit Category')
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
function toggleExpanded(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -174,6 +181,10 @@ export function PortfolioCategoriesPage() {
|
||||
expandedIds={expandedIds}
|
||||
onToggle={toggleExpanded}
|
||||
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
|
||||
onEdit={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) openEditModal(category)
|
||||
}}
|
||||
onRemove={(id) => {
|
||||
const category = categories.find((item) => item.id === id)
|
||||
if (category) setDeleteTarget(category)
|
||||
|
||||
@@ -151,7 +151,7 @@ export function PortfolioListPage() {
|
||||
<p className={styles.empty}>No portfolio items found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={pageStyles.grid}>
|
||||
<div className={pageStyles.gridCols4}>
|
||||
{portfolios.map((portfolio) => (
|
||||
<PortfolioCard
|
||||
key={portfolio.id}
|
||||
|
||||
@@ -119,12 +119,15 @@
|
||||
}
|
||||
|
||||
.nameFa {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
|
||||
.nameFa:global(.faText) {
|
||||
font-weight: 400;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.summary {
|
||||
@@ -136,13 +139,26 @@
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-size: 14px;
|
||||
font-weight: 300; /* IRANYekan Light */
|
||||
line-height: 1.7;
|
||||
color: var(--text-primary);
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.description:global(.faText),
|
||||
.description:global(.faText) :where(*) {
|
||||
font-family: var(--font-fa), var(--font-en);
|
||||
font-weight: 300;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.description p {
|
||||
margin-bottom: 0.75em;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.description p:last-child {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapProductApiToUi,
|
||||
} from '../services/productService'
|
||||
import type { Product } from '../types/product'
|
||||
import { textLocaleAttrs } from '../utils/textLocale'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './ProductDetailsPage.module.css'
|
||||
|
||||
@@ -78,6 +79,14 @@ export function ProductDetailsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const nameEnLocale = textLocaleAttrs(product.nameEn)
|
||||
const nameFaLocale = textLocaleAttrs(product.nameFa)
|
||||
const summaryLocale = textLocaleAttrs(product.summary)
|
||||
const descriptionLocale = textLocaleAttrs(
|
||||
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||
)
|
||||
const categoryLocale = textLocaleAttrs(product.category)
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
@@ -127,27 +136,69 @@ export function ProductDetailsPage() {
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
{product.category && <span className={styles.categoryChip}>{product.category}</span>}
|
||||
{product.category && (
|
||||
<span
|
||||
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
|
||||
lang={categoryLocale.lang}
|
||||
dir={categoryLocale.dir}
|
||||
>
|
||||
{product.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h1 className={styles.nameEn}>{product.nameEn}</h1>
|
||||
{product.nameFa && <p className={styles.nameFa}>{product.nameFa}</p>}
|
||||
<h1
|
||||
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')}
|
||||
lang={nameEnLocale.lang}
|
||||
dir={nameEnLocale.dir}
|
||||
>
|
||||
{product.nameEn}
|
||||
</h1>
|
||||
{product.nameFa && (
|
||||
<p
|
||||
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')}
|
||||
lang={nameFaLocale.lang}
|
||||
dir={nameFaLocale.dir}
|
||||
>
|
||||
{product.nameFa}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{product.summary && <p className={styles.summary}>{product.summary}</p>}
|
||||
{product.summary && (
|
||||
<p
|
||||
className={[styles.summary, summaryLocale.className].filter(Boolean).join(' ')}
|
||||
lang={summaryLocale.lang}
|
||||
dir={summaryLocale.dir}
|
||||
>
|
||||
{product.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{product.description && (
|
||||
<div
|
||||
className={styles.description}
|
||||
className={[styles.description, descriptionLocale.className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
lang={descriptionLocale.lang}
|
||||
dir={descriptionLocale.dir}
|
||||
dangerouslySetInnerHTML={{ __html: product.description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{product.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{product.tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{product.tags.map((tag) => {
|
||||
const tagLocale = textLocaleAttrs(tag)
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className={[styles.tag, tagLocale.className].filter(Boolean).join(' ')}
|
||||
lang={tagLocale.lang}
|
||||
dir={tagLocale.dir}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
@@ -31,14 +32,6 @@ interface AppliedFilters {
|
||||
maxTotal?: number
|
||||
}
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function displayName(card: ShoppingCard) {
|
||||
const name = [card.customer.firstName, card.customer.lastName]
|
||||
.filter(Boolean)
|
||||
@@ -123,8 +116,6 @@ export function ShoppingCardsPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -403,37 +394,15 @@ export function ShoppingCardsPage() {
|
||||
|
||||
<div className={tableStyles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={tableStyles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -88,14 +88,18 @@
|
||||
|
||||
.pagination {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pagination > nav {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.pagerBtns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { RotateCcw, Search } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import { formatCellForDisplay } from '../lib/cellNumber'
|
||||
import {
|
||||
@@ -18,14 +19,6 @@ import styles from './WebsiteContactPage.module.css'
|
||||
const PAGE_SIZE = 20
|
||||
const COLUMN_COUNT = 6
|
||||
|
||||
function buildPageNumbers(page: number, totalPages: number) {
|
||||
const start = Math.max(1, page - 2)
|
||||
const end = Math.min(totalPages, page + 2)
|
||||
const numbers: number[] = []
|
||||
for (let i = start; i <= end; i++) numbers.push(i)
|
||||
return numbers
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
|
||||
@@ -83,8 +76,6 @@ export function WebsiteContactPage() {
|
||||
return Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
}, [data?.total])
|
||||
|
||||
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
|
||||
|
||||
const showingFrom = useMemo(() => {
|
||||
if (!data || data.total === 0) return 0
|
||||
return (page - 1) * PAGE_SIZE + 1
|
||||
@@ -252,37 +243,15 @@ export function WebsiteContactPage() {
|
||||
|
||||
<div className={styles.pagination}>
|
||||
<div>
|
||||
Page {page} / {totalPages}
|
||||
</div>
|
||||
<div className={styles.pagerBtns}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
{pageNumbers.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => setPage(n)}
|
||||
disabled={loading}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
disabled={loading}
|
||||
variant="inline"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface PortfolioApi {
|
||||
id: string
|
||||
businessId: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
mainTextHtml: string
|
||||
@@ -51,7 +53,10 @@ function businessPath(suffix = '') {
|
||||
}
|
||||
|
||||
export interface CreatePortfolioPayload {
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn?: string
|
||||
/** @deprecated Prefer titleFa */
|
||||
title?: string
|
||||
abstract?: string
|
||||
mainTextHtml?: string
|
||||
categoryId?: string
|
||||
@@ -63,6 +68,9 @@ export interface CreatePortfolioPayload {
|
||||
}
|
||||
|
||||
export interface UpdatePortfolioPayload {
|
||||
titleFa?: string
|
||||
titleEn?: string | null
|
||||
/** @deprecated Prefer titleFa */
|
||||
title?: string
|
||||
abstract?: string | null
|
||||
mainTextHtml?: string | null
|
||||
@@ -87,7 +95,9 @@ export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string |
|
||||
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
|
||||
return {
|
||||
id: portfolio.id,
|
||||
title: portfolio.title,
|
||||
title: portfolio.titleFa || portfolio.title,
|
||||
titleFa: portfolio.titleFa || portfolio.title,
|
||||
titleEn: portfolio.titleEn ?? null,
|
||||
slug: portfolio.slug,
|
||||
abstract: portfolio.abstract,
|
||||
status: portfolio.status,
|
||||
@@ -134,7 +144,8 @@ export function mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDet
|
||||
|
||||
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
|
||||
return {
|
||||
title: portfolio.title,
|
||||
titleFa: portfolio.titleFa || portfolio.title,
|
||||
titleEn: portfolio.titleEn ?? '',
|
||||
abstract: portfolio.abstract,
|
||||
mainTextHtml: portfolio.mainTextHtml,
|
||||
categoryId: portfolio.categoryId ?? '',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { apiRequest } from '../lib/api'
|
||||
import { getActiveBusinessId } from '../lib/businessContext'
|
||||
import type { Product } from '../types/product'
|
||||
|
||||
export const PRODUCTS_PER_PAGE = 12
|
||||
export const PRODUCTS_PER_PAGE = 24
|
||||
|
||||
export interface ProductApi {
|
||||
id: string
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface PortfolioGalleryItem {
|
||||
export interface Portfolio {
|
||||
id: string
|
||||
title: string
|
||||
titleFa: string
|
||||
titleEn: string | null
|
||||
slug: string
|
||||
abstract: string
|
||||
status: PortfolioStatus
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Arabic / Persian script ranges (including presentation forms). */
|
||||
const RTL_SCRIPT_RE =
|
||||
/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
|
||||
|
||||
export function isRtlScript(text: string | null | undefined): boolean {
|
||||
if (!text) return false
|
||||
return RTL_SCRIPT_RE.test(text)
|
||||
}
|
||||
|
||||
export type TextLocaleAttrs = {
|
||||
lang: 'fa' | 'en'
|
||||
dir: 'rtl' | 'ltr'
|
||||
className: string | undefined
|
||||
}
|
||||
|
||||
/** Font + direction attrs for mixed FA/EN content fields. */
|
||||
export function textLocaleAttrs(text: string | null | undefined): TextLocaleAttrs {
|
||||
if (isRtlScript(text)) {
|
||||
return { lang: 'fa', dir: 'rtl', className: 'faText' }
|
||||
}
|
||||
return { lang: 'en', dir: 'ltr', className: undefined }
|
||||
}
|
||||
Reference in New Issue
Block a user