Improve product cards/pagination, Farsi locale fonts, and super-admin migrate UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 16:10:43 +03:30
co-authored by Cursor
parent 2e40d5eb4c
commit f5b2193ba1
108 changed files with 2421 additions and 812 deletions
@@ -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;
+24 -4
View File
@@ -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,
)
}
+4 -1
View File
@@ -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,
)
}
+1 -1
View File
@@ -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
View File
@@ -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;
+35 -7
View File
@@ -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 {