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
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber' import { toE164CellNumber } from '../lib/cellNumber'
@@ -96,7 +97,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -205,5 +206,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -123,7 +124,7 @@ export function AddProductByAiModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isSubmitting && onClose()} onClick={(e) => e.target === e.currentTarget && !isSubmitting && onClose()}
@@ -230,5 +231,7 @@ export function AddProductByAiModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import type { Variation, VariationType } from '../types/variation' import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, SIZE_OPTIONS } 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 ? selectedSizes.length > 0
: customValues.some((v) => v.trim()) : customValues.some((v) => v.trim())
return ( return createPortal(
<div <div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -243,6 +244,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
</div> </div>
</form> </form>
</div> </div>
</div> </div>,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper' import { ImageCropper } from './ImageCropper'
import modalStyles from './CategoryModal.module.css' import modalStyles from './CategoryModal.module.css'
@@ -70,7 +71,7 @@ export function AddWebsiteSliderSlideModal({
}) })
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -149,5 +150,7 @@ export function AddWebsiteSliderSlideModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -91,6 +91,7 @@
} }
.title { .title {
margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
@@ -102,6 +103,14 @@
overflow: hidden; overflow: hidden;
} }
.title[dir='rtl'] {
text-align: right;
}
.title[dir='ltr'] {
text-align: left;
}
.abstract { .abstract {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
@@ -113,6 +122,14 @@
overflow: hidden; overflow: hidden;
} }
.abstract[dir='rtl'] {
text-align: right;
}
.abstract[dir='ltr'] {
text-align: left;
}
.meta { .meta {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
+24 -4
View File
@@ -6,6 +6,7 @@ import {
formatBlogCardDate, formatBlogCardDate,
isBlogVerified, isBlogVerified,
} from '../services/blogService' } from '../services/blogService'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css' import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css' import controlStyles from './ProductCard.module.css'
@@ -31,6 +32,9 @@ export function BlogCard({
}: BlogCardProps) { }: BlogCardProps) {
const verified = isBlogVerified(blog) const verified = isBlogVerified(blog)
const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt) const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt)
const titleLocale = textLocaleAttrs(blog.title)
const abstractLocale = textLocaleAttrs(blog.abstract)
const hasAbstract = Boolean(blog.abstract?.trim())
return ( return (
<article className={cardStyles.card}> <article className={cardStyles.card}>
@@ -60,11 +64,27 @@ export function BlogCard({
</div> </div>
<div className={cardStyles.body}> <div className={cardStyles.body}>
<h3 className={cardStyles.title}>{blog.title}</h3> <h3
{blog.abstract ? ( className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
<p className={cardStyles.abstract}>{blog.abstract}</p> 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}> <p className={cardStyles.meta}>
<span>{formatBlogAuthor(blog.author)}</span> <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 type { Category } from '../types/category'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css' import styles from './CategoryRow.module.css'
@@ -10,6 +10,7 @@ interface BlogCategoryRowProps {
expanded: boolean expanded: boolean
onToggle: () => void onToggle: () => void
onAddSub: (id: string) => void onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
} }
@@ -20,10 +21,11 @@ export function BlogCategoryRow({
expanded, expanded,
onToggle, onToggle,
onAddSub, onAddSub,
onEdit,
onRemove, onRemove,
}: BlogCategoryRowProps) { }: BlogCategoryRowProps) {
return ( return (
<div className={styles.row} style={{ marginLeft: depth * 28 }}> <div className={styles.row} style={{ marginLeft: depth * 8 }}>
<div <div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`} className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
onClick={hasChildren ? onToggle : undefined} onClick={hasChildren ? onToggle : undefined}
@@ -59,6 +61,7 @@ export function BlogCategoryRow({
<div className={styles.controls}> <div className={styles.controls}>
<Tooltip label="Add sub category"> <Tooltip label="Add sub category">
<button <button
type="button"
className={styles.controlBtn} className={styles.controlBtn}
onClick={() => onAddSub(category.id)} onClick={() => onAddSub(category.id)}
aria-label="Add sub category" aria-label="Add sub category"
@@ -66,8 +69,19 @@ export function BlogCategoryRow({
<FolderPlus size={17} /> <FolderPlus size={17} />
</button> </button>
</Tooltip> </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"> <Tooltip label="Remove category">
<button <button
type="button"
className={`${styles.controlBtn} ${styles.danger}`} className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)} onClick={() => onRemove(category.id)}
aria-label="Remove" aria-label="Remove"
@@ -8,6 +8,7 @@ interface BlogCategoryTreeProps {
expandedIds: Set<string> expandedIds: Set<string>
onToggle: (id: string) => void onToggle: (id: string) => void
onAddSub: (id: string) => void onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
parentId?: string | null parentId?: string | null
depth?: number depth?: number
@@ -18,6 +19,7 @@ export function BlogCategoryTree({
expandedIds, expandedIds,
onToggle, onToggle,
onAddSub, onAddSub,
onEdit,
onRemove, onRemove,
parentId = null, parentId = null,
depth = 0, depth = 0,
@@ -39,6 +41,7 @@ export function BlogCategoryTree({
expanded={expanded} expanded={expanded}
onToggle={() => onToggle(category.id)} onToggle={() => onToggle(category.id)}
onAddSub={onAddSub} onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove} onRemove={onRemove}
/> />
{childCount && ( {childCount && (
@@ -49,6 +52,7 @@ export function BlogCategoryTree({
expandedIds={expandedIds} expandedIds={expandedIds}
onToggle={onToggle} onToggle={onToggle}
onAddSub={onAddSub} onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove} onRemove={onRemove}
parentId={category.id} parentId={category.id}
depth={depth + 1} depth={depth + 1}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function BlogCommentsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -258,5 +259,7 @@ export function BlogCommentsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper' import { ImageCropper } from './ImageCropper'
import type { Brand, BrandFormData } from '../types/brand' import type { Brand, BrandFormData } from '../types/brand'
@@ -73,7 +74,7 @@ export function BrandModal({
}) })
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -167,5 +168,7 @@ export function BrandModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react' import { Sparkles, X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import modalStyles from './CategoryModal.module.css' import modalStyles from './CategoryModal.module.css'
@@ -79,7 +80,7 @@ export function CategoryAiPromptModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()} onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
@@ -157,5 +158,7 @@ export function CategoryAiPromptModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
@@ -79,7 +80,7 @@ export function CategoryModal({
categories.filter((category) => category.id !== editingCategory?.id), categories.filter((category) => category.id !== editingCategory?.id),
) )
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -170,5 +171,7 @@ export function CategoryModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
+1 -1
View File
@@ -35,7 +35,7 @@ export function CategoryRow({
return ( return (
<div <div
className={styles.row} className={styles.row}
style={{ marginLeft: depth * 28 }} style={{ marginLeft: depth * 8 }}
> >
<div <div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`} className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
@@ -10,4 +10,7 @@
.childrenInner { .childrenInner {
overflow: hidden; 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 { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react' import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css' import styles from './ConfirmDeleteModal.module.css'
@@ -47,7 +48,7 @@ export function ConfirmDeleteModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel} onClick={onCancel}
@@ -82,5 +83,7 @@ export function ConfirmDeleteModal({
</button> </button>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { ContactSubmission } from '../types/contactSubmission' import type { ContactSubmission } from '../types/contactSubmission'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
@@ -57,7 +58,7 @@ export function ContactSubmissionDetailModal({
const { date, time } = formatDateTime(submission.createdAt) const { date, time } = formatDateTime(submission.createdAt)
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -119,5 +120,7 @@ export function ContactSubmissionDetailModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { ProductVariant } from '../types/productVariant' import type { ProductVariant } from '../types/productVariant'
import { buildVariantLabel } from '../types/productVariant' import { buildVariantLabel } from '../types/productVariant'
@@ -96,7 +97,7 @@ export function CreateProductVariantModal({
onSubmit({ variationId, optionId }) onSubmit({ variationId, optionId })
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -184,5 +185,7 @@ export function CreateProductVariantModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService' import { listAllProducts } from '../services/productService'
@@ -241,7 +242,7 @@ export function CreateStoreItemsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -395,5 +396,7 @@ export function CreateStoreItemsModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber' import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
@@ -96,7 +97,7 @@ export function EditCustomerModal({
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -186,5 +187,7 @@ export function EditCustomerModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -209,7 +210,7 @@ export function EditStoreItemsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -346,5 +347,7 @@ export function EditStoreItemsModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronLeft, ChevronRight, X } from 'lucide-react' import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import styles from './ImageLightbox.module.css' import styles from './ImageLightbox.module.css'
@@ -59,7 +60,7 @@ export function ImageLightbox({
const hasPrev = index > 0 const hasPrev = index > 0
const hasNext = index < images.length - 1 const hasNext = index < images.length - 1
return ( return createPortal(
<div <div
className={`${styles.overlay} ${open ? styles.overlayIn : styles.overlayOut}`} className={`${styles.overlay} ${open ? styles.overlayIn : styles.overlayOut}`}
onClick={onClose} onClick={onClose}
@@ -118,5 +119,7 @@ export function ImageLightbox({
</div> </div>
)} )}
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Order } from '../services/orderService' import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
const itemCount = totalQuantity(order) const itemCount = totalQuantity(order)
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -148,5 +149,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
@@ -86,7 +87,7 @@ export function OrderStepModal({
steps.find((step) => step.id === order.processStepId)?.label || steps.find((step) => step.id === order.processStepId)?.label ||
order.processStepId order.processStepId
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={() => { onClick={() => {
@@ -161,5 +162,7 @@ export function OrderStepModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService' import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
@@ -116,7 +117,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
if (!mounted || !order) return null if (!mounted || !order) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -188,5 +189,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -52,6 +52,12 @@
gap: 24px; gap: 24px;
} }
.gridCols4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.gridFour { .gridFour {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); 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) { @media (max-width: 1200px) {
.gridHome { .gridHome {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -84,7 +96,8 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.gridHome, .gridHome,
.grid { .grid,
.gridCols4 {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
} }
@@ -109,7 +122,8 @@
.grid, .grid,
.gridHome, .gridHome,
.gridFour { .gridFour,
.gridCols4 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px; 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' export { Pagination, type PaginationProps } from '@meshkee/dashboard-ui'
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>
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllStoreItems } from '../services/storeItemService' import { listAllStoreItems } from '../services/storeItemService'
@@ -118,7 +119,7 @@ export function PickStoreSpecialItemsModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -175,5 +176,7 @@ export function PickStoreSpecialItemsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { StoreItem } from '../services/storeItemService' import type { StoreItem } from '../services/storeItemService'
import { StoreItemPrice } from './StoreItemPrice' import { StoreItemPrice } from './StoreItemPrice'
@@ -52,7 +53,7 @@ export function PickStoreVariantModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -112,5 +113,7 @@ export function PickStoreVariantModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listAllBrands } from '../services/brandService' import { listAllBrands } from '../services/brandService'
@@ -111,7 +112,7 @@ export function PickWebsiteBrandsModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -170,5 +171,7 @@ export function PickWebsiteBrandsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
@@ -112,7 +113,7 @@ export function PickWebsiteCategoriesModal({
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -171,5 +172,7 @@ export function PickWebsiteCategoriesModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -25,31 +25,28 @@
cursor: pointer; cursor: pointer;
} }
.clickable:hover .title { .clickable:hover .titleFa,
.clickable:hover .titleEn {
color: var(--primary); color: var(--primary);
} }
.imageWrap { .imageWrap {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden; overflow: hidden;
background: transparent; background: rgba(148, 163, 184, 0.08);
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
} }
.image { .image {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
display: block; display: block;
width: 100%;
height: auto;
} }
.imagePlaceholder { .imagePlaceholder {
width: 100%; width: 100%;
height: 100%; aspect-ratio: 3 / 2;
background: linear-gradient( background: linear-gradient(
135deg, 135deg,
rgba(148, 163, 184, 0.12) 0%, rgba(148, 163, 184, 0.12) 0%,
@@ -65,11 +62,15 @@
flex: 1; flex: 1;
} }
.title { .titleFa {
margin: 0;
font-family: var(--font-fa), var(--font-en);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 500;
line-height: 1.4; line-height: 1.4;
color: var(--text-primary); color: var(--text-primary);
direction: rtl;
text-align: right;
transition: color 0.2s; transition: color 0.2s;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -77,6 +78,20 @@
overflow: hidden; 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 { .abstract {
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
@@ -88,6 +103,14 @@
overflow: hidden; overflow: hidden;
} }
.abstract[dir='rtl'] {
text-align: right;
}
.abstract[dir='ltr'] {
text-align: left;
}
.meta { .meta {
margin: 0; margin: 0;
font-size: 12px; 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 { Pencil, MessageSquare, Trash2 } from 'lucide-react'
import type { Portfolio } from '../types/portfolio' import type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService' import { formatPortfolioCardDate } from '../services/portfolioService'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import cardStyles from './PortfolioCard.module.css' import cardStyles from './PortfolioCard.module.css'
import controlStyles from './ProductCard.module.css' import controlStyles from './ProductCard.module.css'
@@ -22,6 +23,12 @@ export function PortfolioCard({
onRemove, onRemove,
}: PortfolioCardProps) { }: PortfolioCardProps) {
const publishDate = formatPortfolioCardDate(portfolio.publishedAt ?? portfolio.createdAt) 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 ( return (
<article className={cardStyles.card}> <article className={cardStyles.card}>
@@ -30,7 +37,7 @@ export function PortfolioCard({
{portfolio.titleImageUrl ? ( {portfolio.titleImageUrl ? (
<img <img
src={portfolio.titleImageUrl} src={portfolio.titleImageUrl}
alt={portfolio.title} alt={titleFa || titleEn || 'Portfolio'}
className={cardStyles.image} className={cardStyles.image}
loading="lazy" loading="lazy"
/> />
@@ -40,12 +47,33 @@ export function PortfolioCard({
</div> </div>
<div className={cardStyles.body}> <div className={cardStyles.body}>
<h3 className={cardStyles.title}>{portfolio.title}</h3> {titleFa ? (
{portfolio.abstract ? ( <h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl">
<p className={cardStyles.abstract}>{portfolio.abstract}</p> {titleFa}
) : ( </h3>
<p className={cardStyles.abstract}>No summary yet.</p> ) : 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}> <p className={cardStyles.meta}>
{portfolio.categoryName ? ( {portfolio.categoryName ? (
<> <>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function PortfolioCommentsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -258,5 +259,7 @@ export function PortfolioCommentsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -35,6 +35,7 @@
overflow: hidden; overflow: hidden;
background: #ffffff; background: #ffffff;
border-bottom: 1px solid rgba(148, 163, 184, 0.12); border-bottom: 1px solid rgba(148, 163, 184, 0.12);
border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
} }
.image { .image {
@@ -80,6 +81,7 @@
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-fa), var(--font-en);
font-size: 12px; font-size: 12px;
font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
direction: rtl; direction: rtl;
text-align: left; text-align: left;
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react' import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment' import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments' import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function ProductCommentsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -258,5 +259,7 @@ export function ProductCommentsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -210,7 +211,7 @@ export function ProductTechnicalInfoModal({
) )
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -271,5 +272,7 @@ export function ProductTechnicalInfoModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -119,7 +120,7 @@ export function ProductVariantsModal({
} }
} }
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -202,5 +203,7 @@ export function ProductVariantsModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -131,6 +131,7 @@
.optionFa { .optionFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-fa), var(--font-en);
font-weight: 500;
direction: rtl; direction: rtl;
} }
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ArrowLeft, Minus, Plus, Search, Trash2, User, X } from 'lucide-react' import { ArrowLeft, Minus, Plus, Search, Trash2, User, X } from 'lucide-react'
import { useDraftCart } from '../context/DraftCartContext' import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -341,7 +342,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
const canAddPayment = payments.length < PAYMENT_METHOD_OPTIONS.length const canAddPayment = payments.length < PAYMENT_METHOD_OPTIONS.length
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -700,5 +701,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -103,6 +103,7 @@
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-fa), var(--font-en);
font-size: 12px; font-size: 12px;
font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
direction: rtl; direction: rtl;
text-align: left; text-align: left;
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -119,7 +120,7 @@ export function StoreItemDiscountModal({
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -196,5 +197,7 @@ export function StoreItemDiscountModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
@@ -114,7 +115,7 @@ export function StoreItemFestivalModal({
} }
} }
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -193,5 +194,7 @@ export function StoreItemFestivalModal({
</form> </form>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import styles from './CategoryModal.module.css' import styles from './CategoryModal.module.css'
@@ -63,13 +64,18 @@ export function StoreSpecialModal({
onSubmit(trimmed) onSubmit(trimmed)
} }
return ( return createPortal(
<div <div
className={styles.overlay} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation" 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}> <div className={styles.header}>
<h2 id="special-modal-title" className={styles.title}> <h2 id="special-modal-title" className={styles.title}>
{title} {title}
@@ -107,5 +113,7 @@ export function StoreSpecialModal({
</form> </form>
</div> </div>
</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; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
justify-content: space-between;
} }
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Sparkles, Trash2, X } from 'lucide-react' import { Plus, Sparkles, Trash2, X } from 'lucide-react'
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm' import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { createId } from '../utils/id' import { createId } from '../utils/id'
@@ -18,6 +19,7 @@ interface TechnicalFormModalProps {
onChange: (fields: TechnicalFormFieldDraft[]) => void onChange: (fields: TechnicalFormFieldDraft[]) => void
onSave: () => void onSave: () => void
onGenerate?: () => void onGenerate?: () => void
onGenerateClick?: () => void
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
@@ -51,6 +53,7 @@ export function TechnicalFormModal({
onChange, onChange,
onSave, onSave,
onGenerate, onGenerate,
onGenerateClick,
}: TechnicalFormModalProps) { }: TechnicalFormModalProps) {
const [mounted, setMounted] = useState(open) const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false) const [closing, setClosing] = useState(false)
@@ -166,7 +169,7 @@ export function TechnicalFormModal({
return true return true
}) })
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -314,11 +317,11 @@ export function TechnicalFormModal({
</div> </div>
<div className={fieldStyles.toolbar}> <div className={fieldStyles.toolbar}>
{onGenerate && ( {(onGenerateClick ?? onGenerate) && (
<button <button
type="button" type="button"
className={aiStyles.aiBtn} className={aiStyles.aiBtn}
onClick={onGenerate} onClick={onGenerateClick ?? onGenerate}
disabled={isLoading || isSaving || isGenerating} disabled={isLoading || isSaving || isGenerating}
> >
<Sparkles size={16} /> <Sparkles size={16} />
@@ -356,5 +359,7 @@ export function TechnicalFormModal({
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
@@ -1,18 +1,18 @@
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.25); background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(6px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(10px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 1000;
padding: 20px; padding: 20px;
} }
.overlayNested { .overlayNested {
z-index: 210; z-index: 1010;
} }
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; } .overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
@@ -119,6 +119,14 @@
color: var(--text-primary); color: var(--text-primary);
} }
.listItemName:global(.faText),
.listItemValues:global(.faText),
.subtitle:global(.faText) {
font-weight: 500;
text-align: left;
unicode-bidi: plaintext;
}
.listItemValues { .listItemValues {
font-size: 12px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react' import { Plus, X } from 'lucide-react'
import type { Variation, VariationType } from '../types/variation' import type { Variation, VariationType } from '../types/variation'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal' import { AddVariationModal } from './AddVariationModal'
import styles from './VariationsModal.module.css' import styles from './VariationsModal.module.css'
@@ -57,6 +59,7 @@ export function VariationsModal({
if (!mounted) return null if (!mounted) return null
const list = variations const list = variations
const categoryLocale = textLocaleAttrs(categoryName)
function handleAdd(variation: Omit<Variation, 'id'>) { function handleAdd(variation: Omit<Variation, 'id'>) {
onChange([ onChange([
@@ -76,7 +79,7 @@ export function VariationsModal({
return 'Custom' return 'Custom'
} }
return ( return createPortal(
<> <>
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
@@ -95,7 +98,13 @@ export function VariationsModal({
Variations Variations
</h3> </h3>
{categoryName && ( {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> </div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button className={styles.closeBtn} onClick={onClose} aria-label="Close">
@@ -105,12 +114,26 @@ export function VariationsModal({
<div className={styles.body}> <div className={styles.body}>
<div className={styles.list}> <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 key={variation.id} className={styles.listItem}>
<div className={styles.listItemInfo}> <div className={styles.listItemInfo}>
<div className={styles.listItemName}>{variation.name}</div> <div
<div className={styles.listItemValues}> className={[styles.listItemName, nameLocale.className].filter(Boolean).join(' ')}
{variation.values.join(' · ') || 'No values'} 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> </div>
<div className={styles.listItemEnd}> <div className={styles.listItemEnd}>
@@ -125,7 +148,8 @@ export function VariationsModal({
</button> </button>
</div> </div>
</div> </div>
))} )
})}
{!list.length && ( {!list.length && (
<p className={styles.emptyText}>No variations yet. Add color, size, or custom options.</p> <p className={styles.emptyText}>No variations yet. Add color, size, or custom options.</p>
)} )}
@@ -153,6 +177,7 @@ export function VariationsModal({
onClose={() => setAddOpen(false)} onClose={() => setAddOpen(false)}
onSubmit={handleAdd} onSubmit={handleAdd}
/> />
</> </>,
document.body,
) )
} }
@@ -31,10 +31,13 @@
.nameFa { .nameFa {
margin-top: 4px; margin-top: 4px;
font-family: var(--font-fa), var(--font-en);
font-size: 13px; font-size: 13px;
font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
text-align: left; text-align: left;
line-height: 1.4; line-height: 1.4;
direction: rtl;
} }
.subtitle { .subtitle {
+1 -1
View File
@@ -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, 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 { export function getProductById(id: string): Product | undefined {
return dummyProducts.find((p) => p.id === id) return dummyProducts.find((p) => p.id === id)
+2 -26
View File
@@ -1,9 +1,7 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */ /* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face { @font-face {
font-family: 'IRANYekan'; font-family: 'IRANYekan';
src: src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -11,30 +9,8 @@
@font-face { @font-face {
font-family: 'IRANYekan'; font-family: 'IRANYekan';
src: src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; 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;
}
+7 -2
View File
@@ -125,19 +125,24 @@ textarea:focus {
input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder, input:not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='hidden'])::placeholder,
textarea::placeholder { textarea::placeholder {
font-family: var(--font-en); font-family: var(--font-en);
opacity: 1;
} }
[dir='rtl'], [dir='rtl'],
:lang(fa), :lang(fa),
.faText { .faText {
font-family: var(--font-fa), var(--font-en); font-family: var(--font-fa), var(--font-en);
font-weight: 400; font-weight: 400; /* IRANYekan Regular */
text-align: right; text-align: right;
} }
[dir='rtl']::placeholder, [dir='rtl']::placeholder,
:lang(fa)::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-family: var(--font-fa), var(--font-en);
font-weight: 400; font-weight: 400;
opacity: 1;
} }
+37 -15
View File
@@ -49,7 +49,8 @@ export function AddNewPortfolioPage() {
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('') const [categoryId, setCategoryId] = useState('')
const [title, setTitle] = useState('') const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [abstract, setAbstract] = useState('') const [abstract, setAbstract] = useState('')
const [mainTextHtml, setMainTextHtml] = useState('') const [mainTextHtml, setMainTextHtml] = useState('')
const [titleImage, setTitleImage] = useState<string | null>(null) const [titleImage, setTitleImage] = useState<string | null>(null)
@@ -98,7 +99,8 @@ export function AddNewPortfolioPage() {
const portfolio = await getPortfolio(id!, controller.signal) const portfolio = await getPortfolio(id!, controller.signal)
const form = mapPortfolioApiToFormState(portfolio) const form = mapPortfolioApiToFormState(portfolio)
setCategoryId(form.categoryId) setCategoryId(form.categoryId)
setTitle(form.title) setTitleFa(form.titleFa)
setTitleEn(form.titleEn)
setAbstract(form.abstract) setAbstract(form.abstract)
setMainTextHtml(form.mainTextHtml) setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage) setTitleImage(form.titleImage)
@@ -140,7 +142,8 @@ export function AddNewPortfolioPage() {
} }
const payload = { const payload = {
title: title.trim(), titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim(), abstract: abstract.trim(),
mainTextHtml: resolvedMainTextHtml, mainTextHtml: resolvedMainTextHtml,
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
@@ -267,18 +270,37 @@ export function AddNewPortfolioPage() {
/> />
</div> </div>
<div className={styles.field}> <div className={styles.titleRow}>
<label htmlFor="portfolio-title">Title</label> <div className={styles.field}>
<input <label htmlFor="portfolio-title-fa">Title (FA)</label>
id="portfolio-title" <input
name="title" id="portfolio-title-fa"
type="text" name="titleFa"
placeholder="Portfolio title" type="text"
value={title} dir="rtl"
onChange={(e) => setTitle(e.target.value)} lang="fa"
required className="faText"
disabled={isSubmitting} 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>
<div className={styles.field}> <div className={styles.field}>
@@ -34,6 +34,13 @@
min-width: 0; min-width: 0;
} }
.titleRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
min-width: 0;
}
.thumbnailField { .thumbnailField {
align-self: start; align-self: start;
} }
@@ -244,6 +251,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.titleRow {
grid-template-columns: 1fr;
}
.fieldFull, .fieldFull,
.fieldHalf, .fieldHalf,
.fieldCategory, .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) { function toggleExpanded(id: string) {
setExpandedIds((prev) => { setExpandedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -174,6 +181,10 @@ export function BlogCategoriesPage() {
expandedIds={expandedIds} expandedIds={expandedIds}
onToggle={toggleExpanded} onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')} onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => { onRemove={(id) => {
const category = categories.find((item) => item.id === id) const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category) if (category) setDeleteTarget(category)
+32 -4
View File
@@ -11,6 +11,7 @@ import {
} from '../services/blogService' } from '../services/blogService'
import type { BlogDetail } from '../types/blog' import type { BlogDetail } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog' import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css' import styles from './BlogDetailsPage.module.css'
@@ -71,6 +72,11 @@ export function BlogDetailsPage() {
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt) const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
const typeLabel = const typeLabel =
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type 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 ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -85,8 +91,26 @@ export function BlogDetailsPage() {
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
<div> <div>
<h2 className={pageStyles.pageTitle}>{blog.title}</h2> <h2
{blog.abstract && <p className={pageStyles.pageSubtitle}>{blog.abstract}</p>} 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>
</div> </div>
@@ -124,11 +148,15 @@ export function BlogDetailsPage() {
<div className={styles.contentPanel}> <div className={styles.contentPanel}>
{blog.mainTextHtml ? ( {blog.mainTextHtml ? (
<div <div
className={styles.content} className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
lang={contentLocale.lang}
dir={contentLocale.dir}
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }} 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}> <div className={styles.authorRow}>
+20 -2
View File
@@ -8,6 +8,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { VariationsModal } from '../components/VariationsModal' import { VariationsModal } from '../components/VariationsModal'
import { TechnicalFormModal } from '../components/TechnicalFormModal' import { TechnicalFormModal } from '../components/TechnicalFormModal'
import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptModal'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import type { Variation } from '../types/variation' import type { Variation } from '../types/variation'
import type { TechnicalFormFieldDraft } from '../types/technicalForm' import type { TechnicalFormFieldDraft } from '../types/technicalForm'
@@ -68,6 +69,7 @@ export function CategoriesPage() {
const [technicalError, setTechnicalError] = useState('') const [technicalError, setTechnicalError] = useState('')
const [aiModalOpen, setAiModalOpen] = useState(false) const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiGenerating, setAiGenerating] = useState(false) const [aiGenerating, setAiGenerating] = useState(false)
const [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
useEffect(() => { useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
@@ -287,14 +289,19 @@ export function CategoriesPage() {
return categoryTechnicalFields[categoryId] ?? [] return categoryTechnicalFields[categoryId] ?? []
} }
async function handleGenerateTechnicalForm() { function handleGenerateTechnicalFormClick() {
setTechAiPromptOpen(true)
}
async function handleGenerateTechnicalForm(hint?: string) {
if (!technicalTarget) return if (!technicalTarget) return
setTechAiPromptOpen(false)
setTechnicalGenerating(true) setTechnicalGenerating(true)
setTechnicalError('') setTechnicalError('')
try { try {
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId) const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId, hint)
const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({ const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({
id: createId(), id: createId(),
label: field.label, label: field.label,
@@ -523,6 +530,17 @@ export function CategoriesPage() {
}} }}
onSave={() => void handleSaveTechnicalForm()} onSave={() => void handleSaveTechnicalForm()}
onGenerate={() => void handleGenerateTechnicalForm()} 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; line-height: 1.35;
} }
.customerName[dir='rtl'] {
font-weight: 400;
text-align: left;
}
.emailCell { .emailCell {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -139,7 +144,7 @@
} }
.toggleInActions { .toggleInActions {
margin-right: 0; margin-right: 10px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -185,14 +190,22 @@
.pagination { .pagination {
padding: 12px 16px; padding: 12px 16px;
display: flex; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
justify-content: space-between;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
} }
.pagination > div:first-child {
justify-self: start;
}
.pagination > nav {
justify-self: center;
}
.pagerBtns { .pagerBtns {
display: flex; display: flex;
gap: 8px; gap: 8px;
+41 -50
View File
@@ -4,6 +4,7 @@ import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal' import { EditCustomerModal } from '../components/EditCustomerModal'
import { Pagination } from '../components/Pagination'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -17,6 +18,7 @@ import {
type CustomersListResponse, type CustomersListResponse,
} from '../services/customerService' } from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
import { textLocaleAttrs } from '../utils/textLocale'
import filterStyles from '../components/ListFiltersPanel.module.css' import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './CustomersPage.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' }) 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) { function displayName(customer: BusinessCustomerListItem) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim() const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
return name || '—' return name || '—'
@@ -112,8 +106,6 @@ export function CustomersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0 if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -365,7 +357,24 @@ export function CustomersPage() {
className={!customer.isEnabled ? styles.inactiveRow : undefined} className={!customer.isEnabled ? styles.inactiveRow : undefined}
> >
<td className={styles.td}> <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 && ( {!customer.isEnabled && (
<div className={styles.statusDisabled}>Disabled</div> <div className={styles.statusDisabled}>Disabled</div>
)} )}
@@ -383,15 +392,19 @@ export function CustomersPage() {
</td> </td>
<td className={`${styles.td} ${styles.tdActions}`}> <td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}> <div className={styles.rowActions}>
<span className={styles.toggleInActions}> <Tooltip
<ToggleSwitch label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
checked={customer.isEnabled} >
disabled={togglingId === customer.id || removing} <span className={styles.toggleInActions}>
size="compact" <ToggleSwitch
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`} checked={customer.isEnabled}
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)} disabled={togglingId === customer.id || removing}
/> size="compact"
</span> ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
/>
</span>
</Tooltip>
<Tooltip label="Edit customer"> <Tooltip label="Edit customer">
<button <button
type="button" type="button"
@@ -442,37 +455,15 @@ export function CustomersPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -24,6 +24,25 @@
border-radius: var(--radius-sm); 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) { @media (min-width: 640px) {
.grid { .grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
+13 -5
View File
@@ -260,11 +260,19 @@ export function MyProductsPage() {
))} ))}
</div> </div>
<Pagination <div className={styles.pagination}>
currentPage={currentPage} <div>
totalPages={totalPages} Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
onPageChange={handlePageChange} {totalProducts} total
/> </div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={isLoading}
variant="inline"
/>
</div>
</> </>
)} )}
+10 -2
View File
@@ -267,14 +267,22 @@
.pagination { .pagination {
padding: 12px 16px; padding: 12px 16px;
display: flex; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
justify-content: space-between;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
} }
.pagination > nav {
justify-self: center;
}
.pagination > div:first-child {
justify-self: start;
}
.pagerBtns { .pagerBtns {
display: flex; display: flex;
gap: 8px; gap: 8px;
+9 -40
View File
@@ -5,6 +5,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderStepModal } from '../components/OrderStepModal' import { OrderStepModal } from '../components/OrderStepModal'
import { OrderTransactionsModal } from '../components/OrderTransactionsModal' import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -39,14 +40,6 @@ interface AppliedFilters {
maxTotal?: number 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) { function displayName(order: Order) {
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim() const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
return name || '—' return name || '—'
@@ -202,8 +195,6 @@ export function OrdersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0 if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -547,37 +538,15 @@ export function OrdersPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </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) { function toggleExpanded(id: string) {
setExpandedIds((prev) => { setExpandedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@@ -174,6 +181,10 @@ export function PortfolioCategoriesPage() {
expandedIds={expandedIds} expandedIds={expandedIds}
onToggle={toggleExpanded} onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')} onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => { onRemove={(id) => {
const category = categories.find((item) => item.id === id) const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category) if (category) setDeleteTarget(category)
@@ -151,7 +151,7 @@ export function PortfolioListPage() {
<p className={styles.empty}>No portfolio items found.</p> <p className={styles.empty}>No portfolio items found.</p>
) : ( ) : (
<> <>
<div className={pageStyles.grid}> <div className={pageStyles.gridCols4}>
{portfolios.map((portfolio) => ( {portfolios.map((portfolio) => (
<PortfolioCard <PortfolioCard
key={portfolio.id} key={portfolio.id}
@@ -119,12 +119,15 @@
} }
.nameFa { .nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 18px; font-size: 18px;
font-weight: 400;
color: var(--text-secondary); color: var(--text-secondary);
direction: rtl;
text-align: left; text-align: left;
unicode-bidi: plaintext; }
.nameFa:global(.faText) {
font-weight: 400;
text-align: left;
} }
.summary { .summary {
@@ -136,13 +139,26 @@
} }
.description { .description {
font-family: var(--font-fa), var(--font-en);
font-size: 14px; font-size: 14px;
font-weight: 300; /* IRANYekan Light */
line-height: 1.7; line-height: 1.7;
color: var(--text-primary); 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 { .description p {
margin-bottom: 0.75em; margin-bottom: 0.75em;
font-family: inherit;
font-weight: inherit;
text-align: inherit;
} }
.description p:last-child { .description p:last-child {
+61 -10
View File
@@ -9,6 +9,7 @@ import {
mapProductApiToUi, mapProductApiToUi,
} from '../services/productService' } from '../services/productService'
import type { Product } from '../types/product' import type { Product } from '../types/product'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.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 ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<Breadcrumbs <Breadcrumbs
@@ -127,27 +136,69 @@ export function ProductDetailsPage() {
</div> </div>
<div className={styles.details}> <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> <h1
{product.nameFa && <p className={styles.nameFa}>{product.nameFa}</p>} 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 && ( {product.description && (
<div <div
className={styles.description} className={[styles.description, descriptionLocale.className]
.filter(Boolean)
.join(' ')}
lang={descriptionLocale.lang}
dir={descriptionLocale.dir}
dangerouslySetInnerHTML={{ __html: product.description }} dangerouslySetInnerHTML={{ __html: product.description }}
/> />
)} )}
{product.tags.length > 0 && ( {product.tags.length > 0 && (
<div className={styles.tags}> <div className={styles.tags}>
{product.tags.map((tag) => ( {product.tags.map((tag) => {
<span key={tag} className={styles.tag}> const tagLocale = textLocaleAttrs(tag)
{tag} return (
</span> <span
))} key={tag}
className={[styles.tag, tagLocale.className].filter(Boolean).join(' ')}
lang={tagLocale.lang}
dir={tagLocale.dir}
>
{tag}
</span>
)
})}
</div> </div>
)} )}
</div> </div>
+9 -40
View File
@@ -3,6 +3,7 @@ import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -31,14 +32,6 @@ interface AppliedFilters {
maxTotal?: number 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) { function displayName(card: ShoppingCard) {
const name = [card.customer.firstName, card.customer.lastName] const name = [card.customer.firstName, card.customer.lastName]
.filter(Boolean) .filter(Boolean)
@@ -123,8 +116,6 @@ export function ShoppingCardsPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0 if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -403,37 +394,15 @@ export function ShoppingCardsPage() {
<div className={tableStyles.pagination}> <div className={tableStyles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -88,14 +88,18 @@
.pagination { .pagination {
padding: 12px 16px; padding: 12px 16px;
display: flex; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
justify-content: space-between;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
} }
.pagination > nav {
justify-self: center;
}
.pagerBtns { .pagerBtns {
display: flex; display: flex;
align-items: center; align-items: center;
+9 -40
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { RotateCcw, Search } from 'lucide-react' import { RotateCcw, Search } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal' import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
import { Pagination } from '../components/Pagination'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
import { import {
@@ -18,14 +19,6 @@ import styles from './WebsiteContactPage.module.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const COLUMN_COUNT = 6 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) { function formatDateTime(value: string) {
const d = new Date(value) const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' } if (Number.isNaN(d.getTime())) return { date: value, time: '' }
@@ -83,8 +76,6 @@ export function WebsiteContactPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0 if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -252,37 +243,15 @@ export function WebsiteContactPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
+14 -3
View File
@@ -9,6 +9,8 @@ export interface PortfolioApi {
id: string id: string
businessId: string businessId: string
title: string title: string
titleFa: string
titleEn: string | null
slug: string slug: string
abstract: string abstract: string
mainTextHtml: string mainTextHtml: string
@@ -51,7 +53,10 @@ function businessPath(suffix = '') {
} }
export interface CreatePortfolioPayload { export interface CreatePortfolioPayload {
title: string titleFa: string
titleEn?: string
/** @deprecated Prefer titleFa */
title?: string
abstract?: string abstract?: string
mainTextHtml?: string mainTextHtml?: string
categoryId?: string categoryId?: string
@@ -63,6 +68,9 @@ export interface CreatePortfolioPayload {
} }
export interface UpdatePortfolioPayload { export interface UpdatePortfolioPayload {
titleFa?: string
titleEn?: string | null
/** @deprecated Prefer titleFa */
title?: string title?: string
abstract?: string | null abstract?: string | null
mainTextHtml?: string | null mainTextHtml?: string | null
@@ -87,7 +95,9 @@ export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string |
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio { export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
return { return {
id: portfolio.id, id: portfolio.id,
title: portfolio.title, title: portfolio.titleFa || portfolio.title,
titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? null,
slug: portfolio.slug, slug: portfolio.slug,
abstract: portfolio.abstract, abstract: portfolio.abstract,
status: portfolio.status, status: portfolio.status,
@@ -134,7 +144,8 @@ export function mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDet
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) { export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
return { return {
title: portfolio.title, titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? '',
abstract: portfolio.abstract, abstract: portfolio.abstract,
mainTextHtml: portfolio.mainTextHtml, mainTextHtml: portfolio.mainTextHtml,
categoryId: portfolio.categoryId ?? '', categoryId: portfolio.categoryId ?? '',
+1 -1
View File
@@ -2,7 +2,7 @@ import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext' import { getActiveBusinessId } from '../lib/businessContext'
import type { Product } from '../types/product' import type { Product } from '../types/product'
export const PRODUCTS_PER_PAGE = 12 export const PRODUCTS_PER_PAGE = 24
export interface ProductApi { export interface ProductApi {
id: string id: string
+2
View File
@@ -8,6 +8,8 @@ export interface PortfolioGalleryItem {
export interface Portfolio { export interface Portfolio {
id: string id: string
title: string title: string
titleFa: string
titleEn: string | null
slug: string slug: string
abstract: string abstract: string
status: PortfolioStatus status: PortfolioStatus
+22
View File
@@ -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 }
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Order } from '../services/orderService' import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber' import { formatCellForDisplay } from '../lib/cellNumber'
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
const itemCount = totalQuantity(order) const itemCount = totalQuantity(order)
return ( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose} onClick={onClose}
@@ -148,5 +149,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div> </div>
</div> </div>
</div> </div>
,
document.body,
) )
} }
+7 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Heart } from 'lucide-react' import { Heart } from 'lucide-react'
import { Breadcrumbs, useToast } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard' import { FavoriteStoreItemCard } from '../components/FavoriteStoreItemCard'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
import { import {
@@ -113,25 +113,12 @@ export function FavoritesPage() {
)} )}
{data && data.total > PAGE_SIZE && ( {data && data.total > PAGE_SIZE && (
<div className={styles.pagination}> <Pagination
<button currentPage={page}
type="button" totalPages={Math.max(1, Math.ceil(data.total / data.pageSize))}
disabled={page <= 1} onPageChange={setPage}
onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading}
> />
Previous
</button>
<span>
Page {page} of {Math.max(1, Math.ceil(data.total / data.pageSize))}
</span>
<button
type="button"
disabled={page >= Math.ceil(data.total / data.pageSize)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)} )}
</main> </main>
) )
+9 -41
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Breadcrumbs } from '@meshkee/dashboard-ui' import { Breadcrumbs, Pagination } from '@meshkee/dashboard-ui'
import { OrderItemsModal } from '../components/OrderItemsModal' import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderRow } from '../components/OrderRow' import { OrderRow } from '../components/OrderRow'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -15,14 +15,6 @@ import styles from './OrdersPage.module.css'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const COLUMN_COUNT = 7 const COLUMN_COUNT = 7
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
}
export function OrdersPage() { export function OrdersPage() {
const [data, setData] = useState<OrdersListResponse | null>(null) const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -58,8 +50,6 @@ export function OrdersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0 if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -155,37 +145,15 @@ export function OrdersPage() {
{data && data.total > PAGE_SIZE && ( {data && data.total > PAGE_SIZE && (
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data.total} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
)} )}
</div> </div>
Binary file not shown.
Binary file not shown.
+50 -25
View File
@@ -1,10 +1,11 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { AuthProvider } from './context/AuthContext' import { AuthProvider } from './context/AuthContext'
import { ToastProvider } from './context/ToastContext' import { ToastProvider } from './context/ToastContext'
import { AdminDomainGuard } from './components/AdminDomainGuard' import { AdminDomainGuard } from './components/AdminDomainGuard'
import { ProtectedRoute } from './components/ProtectedRoute' import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute' import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout' import { PageLayout } from './components/PageLayout'
import { RouteErrorBoundary } from './components/RouteErrorBoundary'
import { HomePage } from './pages/HomePage' import { HomePage } from './pages/HomePage'
import { BusinessesPage } from './pages/BusinessesPage' import { BusinessesPage } from './pages/BusinessesPage'
import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage' import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage'
@@ -17,41 +18,65 @@ import { PublicInvoicePage } from './pages/PublicInvoicePage'
import { ProfilePage } from './pages/ProfilePage' import { ProfilePage } from './pages/ProfilePage'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
function ProtectedPages() {
return (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
)
}
function App() { function App() {
return ( return (
<AdminDomainGuard> <BrowserRouter>
<BrowserRouter> <AuthProvider>
<AuthProvider> <ToastProvider>
<ToastProvider> <Routes>
<Routes> {/* Host-agnostic: can be served from meshkee.com or other public domains */}
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} /> <Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
<Route element={<AdminDomainGuard />}>
<Route element={<GuestRoute />}> <Route element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} /> <Route path="login" element={<LoginPage />} />
</Route> </Route>
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}> <Route element={<PageLayout />}>
<Route index element={<HomePage />} /> <Route element={<ProtectedPages />}>
<Route path="businesses" element={<BusinessesPage />} /> <Route index element={<HomePage />} />
<Route path="businesses/:businessId/invoices" element={<BusinessInvoicesPage />} /> <Route path="businesses" element={<BusinessesPage />} />
<Route path="businesses/:businessId/invoices/new" element={<IssueInvoicePage />} /> <Route
<Route path="users" element={<UsersPage />} /> path="businesses/:businessId/invoices"
<Route path="websites" element={<WebsitesPage />} /> element={<BusinessInvoicesPage />}
<Route path="settings" element={<SettingsPage />} /> />
<Route path="settings/invoice-templates/new" element={<InvoiceTemplateEditorPage />} /> <Route
<Route path="businesses/:businessId/invoices/new"
path="settings/invoice-templates/:templateId" element={<IssueInvoicePage />}
element={<InvoiceTemplateEditorPage />} />
/> <Route
<Route path="profile" element={<ProfilePage />} /> path="businesses/:businessId/invoices/:invoiceId/edit"
element={<IssueInvoicePage />}
/>
<Route path="users" element={<UsersPage />} />
<Route path="websites" element={<WebsitesPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="settings/invoice-templates/new"
element={<InvoiceTemplateEditorPage />}
/>
<Route
path="settings/invoice-templates/:templateId"
element={<InvoiceTemplateEditorPage />}
/>
<Route path="profile" element={<ProfilePage />} />
</Route>
</Route> </Route>
</Route> </Route>
</Routes> </Route>
</ToastProvider> </Routes>
</AuthProvider> </ToastProvider>
</BrowserRouter> </AuthProvider>
</AdminDomainGuard> </BrowserRouter>
) )
} }
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react' import { Outlet } from 'react-router-dom'
import { getAdminDomain, isAllowedAdminHost } from '../lib/config' import { getAdminDomain, isAllowedAdminHost } from '../lib/config'
import styles from './AdminDomainGuard.module.css' import styles from './AdminDomainGuard.module.css'
export function AdminDomainGuard({ children }: { children: ReactNode }) { /** Protects manage-only routes. Public invoice routes stay outside this guard. */
export function AdminDomainGuard() {
if (isAllowedAdminHost()) { if (isAllowedAdminHost()) {
return children return <Outlet />
} }
const expectedDomain = getAdminDomain() const expectedDomain = getAdminDomain()
@@ -1,13 +1,13 @@
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(127, 29, 29, 0.2); background: rgba(127, 29, 29, 0.28);
backdrop-filter: blur(6px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(10px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 200; z-index: 300;
padding: 20px; padding: 20px;
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react' import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css' import styles from './ConfirmDeleteModal.module.css'
@@ -41,13 +42,18 @@ export function ConfirmDeleteModal({
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel() if (e.key === 'Escape') onCancel()
} }
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onCancel]) }, [mounted, closing, onCancel])
if (!mounted) return null if (!mounted) return null
return ( return createPortal(
<div <div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`} className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel} onClick={onCancel}
@@ -81,6 +87,7 @@ export function ConfirmDeleteModal({
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
</div> </div>,
document.body,
) )
} }
@@ -1,9 +1,9 @@
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(15, 23, 42, 0.25); background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(8px); backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(10px);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -73,4 +73,3 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
+19 -6
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import styles from './Modal.module.css' import styles from './Modal.module.css'
@@ -18,17 +19,29 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose()
} }
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [open, onClose]) }, [open, onClose])
if (!open) return null if (!open) return null
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : '' const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
return ( return createPortal(
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}> <div
<div className={`${styles.modal} ${sizeClass}`} onClick={(e) => e.stopPropagation()}> className={styles.overlay}
role="dialog"
aria-modal="true"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className={`${styles.modal} ${sizeClass}`} onMouseDown={(e) => e.stopPropagation()}>
<div className={styles.header}> <div className={styles.header}>
<h3 className={styles.title}>{title}</h3> <h3 className={styles.title}>{title}</h3>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close"> <button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close">
@@ -37,7 +50,7 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
</div> </div>
<div className={styles.body}>{children}</div> <div className={styles.body}>{children}</div>
</div> </div>
</div> </div>,
document.body,
) )
} }
@@ -0,0 +1,56 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
error: Error | null
}
export class RouteErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Route render error:', error, info.componentStack)
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 24, maxWidth: 640 }}>
<h2 style={{ marginBottom: 8 }}>Something went wrong</h2>
<p style={{ color: '#b91c1c', marginBottom: 12 }}>{this.state.error.message}</p>
<pre
style={{
whiteSpace: 'pre-wrap',
fontSize: 12,
background: 'rgba(15,23,42,0.06)',
padding: 12,
borderRadius: 8,
overflow: 'auto',
}}
>
{this.state.error.stack}
</pre>
<button
type="button"
style={{ marginTop: 16 }}
onClick={() => {
this.setState({ error: null })
window.location.assign('/businesses')
}}
>
Reload businesses
</button>
</div>
)
}
return this.props.children
}
}
+2 -26
View File
@@ -1,9 +1,7 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */ /* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face { @font-face {
font-family: 'IRANYekan'; font-family: 'IRANYekan';
src: src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
font-weight: 300; font-weight: 300;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -11,30 +9,8 @@
@font-face { @font-face {
font-family: 'IRANYekan'; font-family: 'IRANYekan';
src: src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
font-display: swap; 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;
}
@@ -27,30 +27,76 @@
.statusChip { .statusChip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 3px 8px; max-width: 100%;
padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid transparent;
transition: filter 0.2s;
}
.statusChipBtn {
cursor: pointer;
background: none;
}
.statusChipBtn:hover {
filter: brightness(0.95);
}
.statusChipBtn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.statusChipBtn:disabled {
cursor: wait;
opacity: 0.7;
}
.statusOptionList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.statusOption {
min-height: 32px;
padding: 6px 12px;
font-size: 12px; font-size: 12px;
font-weight: 600;
} }
.status_draft { .statusOptionSelected {
background: rgba(148, 163, 184, 0.18); box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
color: #475569;
} }
.status_draft,
.status_issued { .status_issued {
background: rgba(var(--primary-rgb) / 0.12); color: #0f172a;
color: var(--primary); background: rgba(15, 23, 42, 0.08);
border-color: rgba(15, 23, 42, 0.18);
}
.status_approved {
color: #0e7490;
background: rgba(6, 182, 212, 0.14);
border-color: rgba(6, 182, 212, 0.32);
} }
.status_paid { .status_paid {
background: rgba(34, 197, 94, 0.12);
color: #15803d; color: #15803d;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(34, 197, 94, 0.32);
} }
.status_cancelled { .status_cancelled {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c; color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.28);
} }
.itemTitle { .itemTitle {
@@ -314,6 +360,10 @@
margin-bottom: 2px; margin-bottom: 2px;
} }
.detailMeta .statusChipBtn {
margin-top: 4px;
}
.detailNotes { .detailNotes {
margin: 0 0 14px; margin: 0 0 14px;
font-size: 13px; font-size: 13px;
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft, Copy, Eye, FilePlus2, Trash2 } from 'lucide-react' import { ArrowLeft, Copy, Eye, FilePlus2, Pencil, Trash2 } from 'lucide-react'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Modal } from '../components/Modal' import { Modal } from '../components/Modal'
import { isEmptyRichText } from '../components/RichTextEditor' import { isEmptyRichText } from '../components/RichTextEditor'
@@ -15,11 +15,12 @@ import {
} from '../services/invoiceService' } from '../services/invoiceService'
import type { Invoice, InvoiceStatus } from '../types/invoice' import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice' import { formatIrtPrice } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css' import tableStyles from './BusinessesPage.module.css'
import styles from './BusinessInvoicesPage.module.css' import styles from './BusinessInvoicesPage.module.css'
const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'paid', 'cancelled'] const STATUS_OPTIONS: InvoiceStatus[] = ['draft', 'issued', 'approved', 'paid', 'cancelled']
function formatDate(value: string) { function formatDate(value: string) {
const d = new Date(value) const d = new Date(value)
@@ -35,6 +36,21 @@ function statusLabel(status: InvoiceStatus) {
return status.charAt(0).toUpperCase() + status.slice(1) return status.charAt(0).toUpperCase() + status.slice(1)
} }
function statusClass(status: InvoiceStatus) {
return styles[`status_${status}` as keyof typeof styles] ?? styles.status_draft
}
function canEditInvoice(status: InvoiceStatus) {
return status !== 'approved'
}
function allowedStatusOptions(current: InvoiceStatus): InvoiceStatus[] {
if (current !== 'approved') return STATUS_OPTIONS
return STATUS_OPTIONS.filter(
(option) => option === 'approved' || option === 'paid' || option === 'cancelled',
)
}
export function BusinessInvoicesPage() { export function BusinessInvoicesPage() {
const { businessId = '' } = useParams() const { businessId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@@ -49,7 +65,8 @@ export function BusinessInvoicesPage() {
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null) const [detailInvoice, setDetailInvoice] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState(false) const [statusTarget, setStatusTarget] = useState<Invoice | null>(null)
const [statusUpdating, setStatusUpdating] = useState<InvoiceStatus | null>(null)
const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null) const [removeTarget, setRemoveTarget] = useState<Invoice | null>(null)
async function reload(signal?: AbortSignal) { async function reload(signal?: AbortSignal) {
@@ -80,20 +97,30 @@ export function BusinessInvoicesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [businessId, page]) }, [businessId, page])
async function handleStatusChange(next: InvoiceStatus) { function openStatusModal(invoice: Invoice) {
if (!detailInvoice) return setStatusTarget(invoice)
setStatusUpdating(true) }
async function handleStatusSelect(next: InvoiceStatus) {
if (!statusTarget || statusUpdating) return
if (next === statusTarget.status) {
setStatusTarget(null)
return
}
setStatusUpdating(next)
try { try {
const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, { const updated = await updateBusinessInvoiceStatus(businessId, statusTarget.id, {
status: next, status: next,
}) })
setDetailInvoice(updated)
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row))) setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast('Invoice status updated.', 'success') showToast('Invoice status updated.', 'success')
setStatusTarget(null)
} catch (err) { } catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error') showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
} finally { } finally {
setStatusUpdating(false) setStatusUpdating(null)
} }
} }
@@ -191,9 +218,15 @@ export function BusinessInvoicesPage() {
</td> </td>
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td> <td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</td>
<td className={tableStyles.td}> <td className={tableStyles.td}>
<span className={`${styles.statusChip} ${styles[`status_${invoice.status}`]}`}> <button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(invoice.status)}`}
onClick={() => openStatusModal(invoice)}
title="Change status"
aria-label={`Change status (${statusLabel(invoice.status)})`}
>
{statusLabel(invoice.status)} {statusLabel(invoice.status)}
</span> </button>
</td> </td>
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td> <td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
<td className={tableStyles.td}> <td className={tableStyles.td}>
@@ -229,6 +262,30 @@ export function BusinessInvoicesPage() {
> >
<Eye size={16} /> <Eye size={16} />
</button> </button>
<span
title={
canEditInvoice(invoice.status)
? undefined
: 'Approved invoices cannot be edited'
}
>
<button
type="button"
className={tableStyles.controlBtn}
disabled={!canEditInvoice(invoice.status)}
onClick={() =>
navigate(`/businesses/${businessId}/invoices/${invoice.id}/edit`)
}
title={canEditInvoice(invoice.status) ? 'Edit' : undefined}
aria-label={
canEditInvoice(invoice.status)
? 'Edit invoice'
: 'Edit invoice (disabled — approved)'
}
>
<Pencil size={16} />
</button>
</span>
<button <button
type="button" type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`} className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
@@ -249,26 +306,15 @@ export function BusinessInvoicesPage() {
{totalPages > 1 ? ( {totalPages > 1 ? (
<div className={tableStyles.pagination}> <div className={tableStyles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · 20 per page · {total} total
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<button
type="button"
className={tableStyles.pageBtn}
disabled={page >= totalPages || loading}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
</button>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
) : null} ) : null}
@@ -287,17 +333,15 @@ export function BusinessInvoicesPage() {
<div className={styles.detailMeta}> <div className={styles.detailMeta}>
<div> <div>
<span className={styles.detailLabel}>Status</span> <span className={styles.detailLabel}>Status</span>
<select <button
value={detailInvoice.status} type="button"
disabled={statusUpdating} className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
onChange={(e) => void handleStatusChange(e.target.value as InvoiceStatus)} onClick={() => openStatusModal(detailInvoice)}
title="Change status"
aria-label={`Change status (${statusLabel(detailInvoice.status)})`}
> >
{STATUS_OPTIONS.map((option) => ( {statusLabel(detailInvoice.status)}
<option key={option} value={option}> </button>
{statusLabel(option)}
</option>
))}
</select>
</div> </div>
<div> <div>
<span className={styles.detailLabel}>Total</span> <span className={styles.detailLabel}>Total</span>
@@ -396,6 +440,40 @@ export function BusinessInvoicesPage() {
) : null} ) : null}
</Modal> </Modal>
<Modal
open={!!statusTarget}
title="Change invoice status"
onClose={() => {
if (!statusUpdating) setStatusTarget(null)
}}
>
{statusTarget ? (
<div
className={styles.statusOptionList}
role="listbox"
aria-label="Invoice status"
>
{allowedStatusOptions(statusTarget.status).map((option) => {
const selected = statusTarget.status === option
const saving = statusUpdating === option
return (
<button
key={option}
type="button"
role="option"
aria-selected={selected}
className={`${styles.statusChip} ${styles.statusChipBtn} ${styles.statusOption} ${statusClass(option)} ${selected ? styles.statusOptionSelected : ''}`}
disabled={Boolean(statusUpdating)}
onClick={() => void handleStatusSelect(option)}
>
{saving ? 'Saving…' : statusLabel(option)}
</button>
)
})}
</div>
) : null}
</Modal>
<ConfirmDeleteModal <ConfirmDeleteModal
open={!!removeTarget} open={!!removeTarget}
title="Remove invoice" title="Remove invoice"
@@ -60,6 +60,140 @@
color: var(--text-muted); color: var(--text-muted);
} }
.entityGroupLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.entityChecks {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 8px;
}
.entityGroup {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.28);
}
.entityGroup:last-child {
border-bottom: none;
padding-bottom: 4px;
}
.entityGroup:first-child {
padding-top: 4px;
}
.entityGroupTitle {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
min-width: 88px;
flex-shrink: 0;
}
.entityNestedChecks {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 18px;
}
.entityCheck {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
width: fit-content;
max-width: 100%;
}
.field .entityCheck input[type='checkbox'],
.entityCheck input[type='checkbox'],
.field .entityCheck input[type='radio'],
.entityCheck input[type='radio'] {
appearance: auto;
-webkit-appearance: auto;
width: 16px;
height: 16px;
min-width: 16px;
min-height: 16px;
margin: 0;
padding: 0;
flex-shrink: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
cursor: pointer;
}
.entityCheck span {
line-height: 1.3;
}
.alertError {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
color: #b91c1c;
}
.alertInfo {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.25);
color: #1d4ed8;
}
.alertSuccess {
margin: 0 0 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.28);
color: #166534;
}
.migrateResultTitle {
margin: 0;
font-weight: 600;
}
.migrateResultList {
margin: 8px 0 0;
padding-inline-start: 18px;
display: flex;
flex-direction: column;
gap: 4px;
}
.migrateResultList strong {
font-weight: 600;
}
.field input, .field input,
.field select, .field select,
.field textarea { .field textarea {
@@ -182,6 +316,17 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.btnDanger {
color: white;
background: linear-gradient(135deg, #f87171 0%, #ef4444 55%, #dc2626 100%);
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.28);
border: none;
}
.btnDanger:hover:not(:disabled) {
transform: translateY(-1px);
}
.tablePanel { .tablePanel {
margin-top: 12px; margin-top: 12px;
padding: 0; padding: 0;
+525 -47
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
AlertTriangle, AlertTriangle,
Database,
Eraser,
FileText, FileText,
Globe, Globe,
Lock, Lock,
@@ -16,7 +18,16 @@ import {
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ToggleSwitch } from '../components/ToggleSwitch' import { ToggleSwitch } from '../components/ToggleSwitch'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber' import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
import type { BusinessesListResponse, BusinessListItem } from '../types/business' import type {
BusinessesListResponse,
BusinessListItem,
MigrateEntityResult,
MigrateFromOldEntity,
MigrateFromOldResponse,
PurgeBusinessDataEntity,
PurgeBusinessDataResponse,
PurgeEntityResult,
} from '../types/business'
import type { BusinessCategory } from '../types/category' import type { BusinessCategory } from '../types/category'
import type { ListBusinessesParams } from '../services/businessService' import type { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -24,6 +35,8 @@ import {
addBusinessDomain, addBusinessDomain,
createBusiness, createBusiness,
listBusinesses, listBusinesses,
migrateBusinessFromOld,
purgeBusinessData,
removeBusiness, removeBusiness,
setBusinessActive, setBusinessActive,
updateBusiness, updateBusiness,
@@ -45,6 +58,7 @@ import {
updateBusinessPrimaryColor, updateBusinessPrimaryColor,
} from '../services/businessSettingsService' } from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories' import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './BusinessesPage.module.css' import styles from './BusinessesPage.module.css'
@@ -56,12 +70,99 @@ function formatDate(value: string) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }) return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
} }
function buildPageNumbers(page: number, totalPages: number) { const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
const start = Math.max(1, page - 2) product_categories: 'Product categories',
const end = Math.min(totalPages, page + 2) product: 'Products',
const numbers: number[] = [] customer_categories: 'Customer categories',
for (let i = start; i <= end; i++) numbers.push(i) customer: 'Customers',
return numbers blog_categories: 'Blog categories',
blog: 'Blogs',
portfolio_categories: 'Portfolio categories',
portfolio: 'Portfolios',
}
const DATA_ENTITY_GROUPS = [
{
title: 'Products',
items: [
['product_categories', 'Categories'],
['product', 'Products'],
],
},
{
title: 'Customers',
items: [
['customer_categories', 'Categories'],
['customer', 'Customers'],
],
},
{
title: 'Blogs',
items: [
['blog_categories', 'Categories'],
['blog', 'Blogs'],
],
},
{
title: 'Portfolios',
items: [
['portfolio_categories', 'Categories'],
['portfolio', 'Portfolios'],
],
},
] as const
function formatMigrateEntityResult(result: MigrateEntityResult): string {
if ('created' in result) {
const base = `${result.created} created, ${result.skipped} skipped (${result.total} total)`
const extras = [
result.imagesCopied != null ? `${result.imagesCopied} images copied` : null,
result.imagesResized != null && result.imagesResized > 0
? `${result.imagesResized} resized (≤1280px)`
: null,
result.titlesUpdated != null && result.titlesUpdated > 0
? `${result.titlesUpdated} titles updated`
: null,
result.imagesFailed != null && result.imagesFailed > 0
? `${result.imagesFailed} images failed`
: null,
result.skippedInvalidCell != null && result.skippedInvalidCell > 0
? `${result.skippedInvalidCell} invalid/missing cell`
: null,
result.skippedAlreadyLinked != null && result.skippedAlreadyLinked > 0
? `${result.skippedAlreadyLinked} already linked`
: null,
result.skippedCreateFailed != null && result.skippedCreateFailed > 0
? `${result.skippedCreateFailed} create failed`
: null,
].filter(Boolean)
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function formatPurgeEntityResult(result: PurgeEntityResult): string {
if ('deleted' in result) {
const extras = [
result.imagesDeleted != null ? `${result.imagesDeleted} images deleted` : null,
].filter(Boolean)
const base = `${result.deleted} deleted`
return extras.length ? `${base}; ${extras.join(', ')}` : base
}
return 'Not implemented yet'
}
function emptyMigrateEntities(): Record<MigrateFromOldEntity, boolean> {
return {
product_categories: false,
product: false,
customer_categories: false,
customer: false,
blog_categories: false,
blog: false,
portfolio_categories: false,
portfolio: false,
}
} }
export function BusinessesPage() { export function BusinessesPage() {
@@ -91,12 +192,14 @@ export function BusinessesPage() {
) )
const [editLoadingSettings, setEditLoadingSettings] = useState(false) const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [domainOpen, setDomainOpen] = useState(false) const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null) const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null) const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('') const [domainHost, setDomainHost] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false) const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('') const [createName, setCreateName] = useState('')
@@ -108,11 +211,31 @@ export function BusinessesPage() {
const [createOwnerCell, setCreateOwnerCell] = useState('') const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('') const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false) const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = useState<string | null>(null) const [togglingId, setTogglingId] = useState<string | null>(null)
const [savingColorId, setSavingColorId] = useState<string | null>(null) const [savingColorId, setSavingColorId] = useState<string | null>(null)
const [migrateOpen, setMigrateOpen] = useState(false)
const [migrateBusiness, setMigrateBusiness] = useState<BusinessListItem | null>(null)
const [migrateOldId, setMigrateOldId] = useState('')
const [migrateEntities, setMigrateEntities] = useState<Record<MigrateFromOldEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [migrateSubmitting, setMigrateSubmitting] = useState(false)
const [migrateError, setMigrateError] = useState('')
const [migrateResult, setMigrateResult] = useState<MigrateFromOldResponse | null>(null)
const [purgeOpen, setPurgeOpen] = useState(false)
const [purgeBusiness, setPurgeBusiness] = useState<BusinessListItem | null>(null)
const [purgeEntities, setPurgeEntities] = useState<Record<PurgeBusinessDataEntity, boolean>>(
() => emptyMigrateEntities(),
)
const [purgeSubmitting, setPurgeSubmitting] = useState(false)
const [purgeError, setPurgeError] = useState('')
const [purgeResult, setPurgeResult] = useState<PurgeBusinessDataResponse | null>(null)
async function fetchList() { async function fetchList() {
setLoading(true) setLoading(true)
setError('') setError('')
@@ -171,8 +294,6 @@ export function BusinessesPage() {
return Math.max(1, Math.ceil(total / pageSize)) return Math.max(1, Math.ceil(total / pageSize))
}, [data?.total, pageSize]) }, [data?.total, pageSize])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data) return 0 if (!data) return 0
return (page - 1) * pageSize + 1 return (page - 1) * pageSize + 1
@@ -206,6 +327,7 @@ export function BusinessesPage() {
setEditBusiness(b) setEditBusiness(b)
setEditName(b.name) setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor)) setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditError('')
setEditOpen(true) setEditOpen(true)
setEditLoadingSettings(true) setEditLoadingSettings(true)
@@ -216,7 +338,7 @@ export function BusinessesPage() {
}) })
.catch((err) => { .catch((err) => {
if (isAbortError(err)) return if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load business theme.') setEditError(err instanceof ApiError ? err.message : 'Unable to load business theme.')
}) })
.finally(() => { .finally(() => {
if (!controller.signal.aborted) setEditLoadingSettings(false) if (!controller.signal.aborted) setEditLoadingSettings(false)
@@ -286,7 +408,7 @@ export function BusinessesPage() {
async function submitEdit() { async function submitEdit() {
if (!editBusiness) return if (!editBusiness) return
setEditSubmitting(true) setEditSubmitting(true)
setError('') setEditError('')
try { try {
await updateBusiness(editBusiness.id, { name: editName }) await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor) await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
@@ -306,7 +428,7 @@ export function BusinessesPage() {
showToast('Business updated.', 'success') showToast('Business updated.', 'success')
await fetchList() await fetchList()
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update business.') setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally { } finally {
setEditSubmitting(false) setEditSubmitting(false)
} }
@@ -316,16 +438,142 @@ export function BusinessesPage() {
setDomainBusiness(b) setDomainBusiness(b)
setDomainId(b.domainId) setDomainId(b.domainId)
setDomainHost(b.domain ?? '') setDomainHost(b.domain ?? '')
setDomainError('')
setDomainOpen(true) setDomainOpen(true)
} }
function openMigrate(b: BusinessListItem) {
setMigrateBusiness(b)
setMigrateOldId(b.oldBusinessId != null ? String(b.oldBusinessId) : '')
setMigrateEntities(emptyMigrateEntities())
setMigrateError('')
setMigrateResult(null)
setMigrateOpen(true)
}
const migrateSubmittingRef = useRef(false)
migrateSubmittingRef.current = migrateSubmitting
const closeMigrate = useCallback(() => {
if (migrateSubmittingRef.current) return
setMigrateOpen(false)
setMigrateBusiness(null)
setMigrateError('')
setMigrateResult(null)
}, [])
function selectMigrateEntity(entity: MigrateFromOldEntity) {
setMigrateResult(null)
setMigrateError('')
setMigrateEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitMigrate() {
if (!migrateBusiness) return
const oldBusinessId = Number(String(migrateOldId).trim())
if (!Number.isInteger(oldBusinessId) || oldBusinessId <= 0) {
setMigrateResult(null)
setMigrateError('Enter a valid old business id (positive integer).')
return
}
const entities = (Object.keys(migrateEntities) as MigrateFromOldEntity[]).filter(
(key) => migrateEntities[key],
)
if (entities.length === 0) {
setMigrateResult(null)
setMigrateError('Select a data type to migrate.')
return
}
setMigrateSubmitting(true)
setMigrateError('')
setMigrateResult(null)
try {
const result = await migrateBusinessFromOld(migrateBusiness.id, {
oldBusinessId,
entities,
})
setData((prev) => {
if (!prev) return prev
return {
...prev,
items: prev.items.map((item) =>
item.id === migrateBusiness.id
? { ...item, oldBusinessId: String(oldBusinessId) }
: item,
),
}
})
setMigrateResult(result)
} catch (err) {
setMigrateError(
err instanceof ApiError ? err.message : 'Unable to migrate data from old CMS.',
)
} finally {
setMigrateSubmitting(false)
}
}
function openPurge(b: BusinessListItem) {
setPurgeBusiness(b)
setPurgeEntities(emptyMigrateEntities())
setPurgeError('')
setPurgeResult(null)
setPurgeOpen(true)
}
const purgeSubmittingRef = useRef(false)
purgeSubmittingRef.current = purgeSubmitting
const closePurge = useCallback(() => {
if (purgeSubmittingRef.current) return
setPurgeOpen(false)
setPurgeBusiness(null)
setPurgeError('')
setPurgeResult(null)
}, [])
function selectPurgeEntity(entity: PurgeBusinessDataEntity) {
setPurgeResult(null)
setPurgeError('')
setPurgeEntities({ ...emptyMigrateEntities(), [entity]: true })
}
async function submitPurge() {
if (!purgeBusiness) return
const entities = (Object.keys(purgeEntities) as PurgeBusinessDataEntity[]).filter(
(key) => purgeEntities[key],
)
if (entities.length === 0) {
setPurgeResult(null)
setPurgeError('Select a data type to delete.')
return
}
setPurgeSubmitting(true)
setPurgeError('')
setPurgeResult(null)
try {
const result = await purgeBusinessData(purgeBusiness.id, { entities })
setPurgeResult(result)
showToast(result.message, 'success')
} catch (err) {
setPurgeError(
err instanceof ApiError ? err.message : 'Unable to delete selected business data.',
)
} finally {
setPurgeSubmitting(false)
}
}
async function submitDomain() { async function submitDomain() {
if (!domainBusiness) return if (!domainBusiness) return
const host = domainHost.trim() const host = domainHost.trim()
if (!host) return if (!host) return
setDomainSubmitting(true) setDomainSubmitting(true)
setError('') setDomainError('')
try { try {
if (domainId) { if (domainId) {
await updateBusinessDomain(domainBusiness.id, domainId, { host }) await updateBusinessDomain(domainBusiness.id, domainId, { host })
@@ -366,7 +614,7 @@ export function BusinessesPage() {
setDomainBusiness(null) setDomainBusiness(null)
setDomainId(null) setDomainId(null)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save domain.') setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally { } finally {
setDomainSubmitting(false) setDomainSubmitting(false)
} }
@@ -381,6 +629,7 @@ export function BusinessesPage() {
setCreateOwnerLastName('') setCreateOwnerLastName('')
setCreateOwnerCell('') setCreateOwnerCell('')
setCreateOwnerPassword('') setCreateOwnerPassword('')
setCreateError('')
} }
function openCreate() { function openCreate() {
@@ -393,7 +642,7 @@ export function BusinessesPage() {
if (!ownerCellNumber) return if (!ownerCellNumber) return
setCreateSubmitting(true) setCreateSubmitting(true)
setError('') setCreateError('')
try { try {
await createBusiness({ await createBusiness({
name: createName.trim(), name: createName.trim(),
@@ -410,7 +659,7 @@ export function BusinessesPage() {
showToast(`"${createName.trim()}" has been created.`, 'success') showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList() await fetchList()
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to create business.') setCreateError(err instanceof ApiError ? err.message : 'Unable to create business.')
} finally { } finally {
setCreateSubmitting(false) setCreateSubmitting(false)
} }
@@ -468,6 +717,13 @@ export function BusinessesPage() {
toE164CellNumber(createOwnerCell.trim()).length > 0 && toE164CellNumber(createOwnerCell.trim()).length > 0 &&
createOwnerPassword.length >= 8 createOwnerPassword.length >= 8
const canSubmitMigrate =
Number.isInteger(Number(String(migrateOldId).trim())) &&
Number(String(migrateOldId).trim()) > 0 &&
Object.values(migrateEntities).some(Boolean)
const canSubmitPurge = Object.values(purgeEntities).some(Boolean)
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
<div className={pageStyles.pageHeader}> <div className={pageStyles.pageHeader}>
@@ -681,6 +937,32 @@ export function BusinessesPage() {
> >
<Users size={16} /> <Users size={16} />
</button> </button>
<button
type="button"
className={styles.controlBtn}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openMigrate(b)
}}
title="Migrate from old CMS"
aria-label="Migrate from old CMS"
>
<Database size={16} />
</button>
<button
type="button"
className={`${styles.controlBtn} ${styles.controlBtnDanger}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openPurge(b)
}}
title="Delete data"
aria-label="Delete business data"
>
<Eraser size={16} />
</button>
<button <button
type="button" type="button"
className={styles.controlBtn} className={styles.controlBtn}
@@ -718,37 +1000,15 @@ export function BusinessesPage() {
<div className={styles.pagination}> <div className={styles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -769,8 +1029,14 @@ export function BusinessesPage() {
onClose={() => { onClose={() => {
setEditOpen(false) setEditOpen(false)
setEditBusiness(null) setEditBusiness(null)
setEditError('')
}} }}
> >
{editError ? (
<p className={styles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="edit-name">Business name</label> <label htmlFor="edit-name">Business name</label>
<input <input
@@ -820,8 +1086,14 @@ export function BusinessesPage() {
setDomainOpen(false) setDomainOpen(false)
setDomainBusiness(null) setDomainBusiness(null)
setDomainId(null) setDomainId(null)
setDomainError('')
}} }}
> >
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="domain-host">Domain</label> <label htmlFor="domain-host">Domain</label>
<input <input
@@ -853,6 +1125,207 @@ export function BusinessesPage() {
</div> </div>
</Modal> </Modal>
<Modal
open={migrateOpen}
title={
migrateBusiness
? `Migrate from old CMS — ${migrateBusiness.name}`
: 'Migrate from old CMS'
}
onClose={closeMigrate}
>
{migrateSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Migrating selected data This can take a moment.
</p>
) : null}
{migrateError ? (
<p className={styles.alertError} role="alert">
{migrateError}
</p>
) : null}
{migrateResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{migrateResult.message}</p>
{migrateResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(migrateResult.results) as MigrateFromOldEntity[]).map((key) => {
const item = migrateResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatMigrateEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Link this business to a legacy WillaEngine business id, then choose one
data type to migrate. Selecting blogs also migrates news under a top-level
News category. Selecting customers also migrates client categories when
needed.
</p>
<div className={styles.field}>
<label htmlFor="migrate-old-id">Old business id</label>
<input
id="migrate-old-id"
type="number"
min={1}
step={1}
value={String(migrateOldId)}
onChange={(e) => {
setMigrateOldId(e.target.value)
setMigrateResult(null)
setMigrateError('')
}}
placeholder="e.g. 2410"
disabled={migrateSubmitting}
/>
</div>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to migrate (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="migrate-entity"
checked={migrateEntities[value]}
onChange={() => selectMigrateEntity(value)}
disabled={migrateSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closeMigrate}
disabled={migrateSubmitting}
>
{migrateResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
onClick={() => void submitMigrate()}
disabled={migrateSubmitting || !canSubmitMigrate}
>
{migrateSubmitting
? 'Migrating…'
: migrateResult
? 'Migrate again'
: 'Save & migrate'}
</button>
</div>
</Modal>
<Modal
open={purgeOpen}
title={
purgeBusiness ? `Delete data — ${purgeBusiness.name}` : 'Delete data'
}
onClose={closePurge}
>
{purgeSubmitting ? (
<p className={styles.alertInfo} role="status" aria-live="polite">
Deleting selected data This can take a moment.
</p>
) : null}
{purgeError ? (
<p className={styles.alertError} role="alert">
{purgeError}
</p>
) : null}
{purgeResult ? (
<div className={styles.alertSuccess} role="status" aria-live="polite">
<p className={styles.migrateResultTitle}>{purgeResult.message}</p>
{purgeResult.results ? (
<ul className={styles.migrateResultList}>
{(Object.keys(purgeResult.results) as PurgeBusinessDataEntity[]).map((key) => {
const item = purgeResult.results?.[key]
if (!item) return null
return (
<li key={key}>
<strong>{MIGRATE_ENTITY_LABELS[key]}:</strong>{' '}
{formatPurgeEntityResult(item)}
</li>
)
})}
</ul>
) : null}
</div>
) : null}
<p className={styles.helperText}>
Permanently remove one selected data type from this business so you can
migrate again. Deleting blogs or portfolios also removes their images
from storage. Deleting customers removes business links (not global user
accounts).
</p>
<div className={styles.field} style={{ marginTop: 12 }}>
<span className={styles.entityGroupLabel}>Data to delete (one)</span>
<div className={styles.entityChecks}>
{DATA_ENTITY_GROUPS.map((group) => (
<div key={group.title} className={styles.entityGroup}>
<span className={styles.entityGroupTitle}>{group.title}</span>
<div className={styles.entityNestedChecks}>
{group.items.map(([value, label]) => (
<label key={value} className={styles.entityCheck}>
<input
type="radio"
name="purge-entity"
checked={purgeEntities[value]}
onChange={() => selectPurgeEntity(value)}
disabled={purgeSubmitting}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
<div style={{ height: 12 }} />
<div className={styles.actionsRow}>
<button
type="button"
className={`${styles.btn} ${styles.btnGhost}`}
onClick={closePurge}
disabled={purgeSubmitting}
>
{purgeResult ? 'Close' : 'Cancel'}
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnDanger}`}
onClick={() => void submitPurge()}
disabled={purgeSubmitting || !canSubmitPurge}
>
{purgeSubmitting
? 'Deleting…'
: purgeResult
? 'Delete again'
: 'Delete selected data'}
</button>
</div>
</Modal>
<Modal <Modal
open={createOpen} open={createOpen}
title="Add business" title="Add business"
@@ -862,6 +1335,11 @@ export function BusinessesPage() {
resetCreateForm() resetCreateForm()
}} }}
> >
{createError ? (
<p className={styles.alertError} role="alert">
{createError}
</p>
) : null}
<div className={styles.formGrid}> <div className={styles.formGrid}>
<div className={styles.field}> <div className={styles.field}>
<label htmlFor="create-name-en">Name (EN)</label> <label htmlFor="create-name-en">Name (EN)</label>
+115 -36
View File
@@ -8,14 +8,17 @@ import { ApiError, isAbortError } from '../lib/api'
import { getBusiness, type BusinessDetail } from '../services/businessService' import { getBusiness, type BusinessDetail } from '../services/businessService'
import { import {
createBusinessInvoice, createBusinessInvoice,
getBusinessInvoice,
listInvoiceItemTemplates, listInvoiceItemTemplates,
listInvoiceTemplates, listInvoiceTemplates,
updateBusinessInvoice,
} from '../services/invoiceService' } from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice' import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import { import {
buildAccountsPayload, buildAccountsPayload,
buildKeyPointsPayload, buildKeyPointsPayload,
buildLineItemsPayload, buildLineItemsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate, draftsFromInvoiceTemplate,
emptyDraftItem, emptyDraftItem,
type DraftAccount, type DraftAccount,
@@ -36,7 +39,8 @@ function effectivePrice(price: number, discountedPrice: number | null | undefine
} }
export function IssueInvoicePage() { export function IssueInvoicePage() {
const { businessId = '' } = useParams() const { businessId = '', invoiceId = '' } = useParams()
const isEdit = Boolean(invoiceId)
const navigate = useNavigate() const navigate = useNavigate()
const { showToast } = useToast() const { showToast } = useToast()
@@ -45,6 +49,7 @@ export function IssueInvoicePage() {
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([]) const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [locked, setLocked] = useState(false)
const [sourceTemplateId, setSourceTemplateId] = useState('') const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>() const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
@@ -77,6 +82,7 @@ export function IssueInvoicePage() {
async function load() { async function load() {
setLoading(true) setLoading(true)
setError('') setError('')
setLocked(false)
try { try {
const [biz, items, templates] = await Promise.all([ const [biz, items, templates] = await Promise.all([
getBusiness(businessId, controller.signal), getBusiness(businessId, controller.signal),
@@ -86,6 +92,24 @@ export function IssueInvoicePage() {
setBusiness(biz) setBusiness(biz)
setItemTemplates(items.items.filter((t) => t.isActive)) setItemTemplates(items.items.filter((t) => t.isActive))
setInvoiceTemplates(templates.items.filter((t) => t.isActive)) setInvoiceTemplates(templates.items.filter((t) => t.isActive))
if (invoiceId) {
const invoice = await getBusinessInvoice(businessId, invoiceId, controller.signal)
if (invoice.status === 'approved') {
setLocked(true)
setError('This invoice is approved and can no longer be edited.')
}
const drafts = draftsFromInvoice(invoice)
setSourceTemplateId('')
setInvoiceTemplateId(invoice.invoiceTemplateId ?? undefined)
setInvoiceName(drafts.name)
setTopText(drafts.topText)
setNotes(drafts.notes)
setDraftItems(drafts.items)
setKeyPoints(drafts.keyPoints)
setAccounts(drafts.accounts)
setSelectedItemTemplateId('')
}
} catch (err) { } catch (err) {
if (isAbortError(err)) return if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.') setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.')
@@ -96,7 +120,7 @@ export function IssueInvoicePage() {
void load() void load()
return () => controller.abort() return () => controller.abort()
}, [businessId]) }, [businessId, invoiceId])
function resetBlankDraft() { function resetBlankDraft() {
setSourceTemplateId('') setSourceTemplateId('')
@@ -112,6 +136,7 @@ export function IssueInvoicePage() {
} }
function applyInvoiceTemplate(templateId: string) { function applyInvoiceTemplate(templateId: string) {
if (locked) return
setSourceTemplateId(templateId) setSourceTemplateId(templateId)
if (!templateId) { if (!templateId) {
resetBlankDraft() resetBlankDraft()
@@ -130,7 +155,8 @@ export function IssueInvoicePage() {
setFormError('') setFormError('')
} }
async function handleCreate() { async function handleSubmit() {
if (locked) return
setFormError('') setFormError('')
let items let items
try { try {
@@ -142,19 +168,38 @@ export function IssueInvoicePage() {
setSubmitting(true) setSubmitting(true)
try { try {
await createBusinessInvoice(businessId, { if (isEdit) {
items, await updateBusinessInvoice(businessId, invoiceId, {
name: invoiceName.trim() || undefined, items,
topText: isEmptyRichText(topText) ? undefined : topText, name: invoiceName.trim() || null,
notes: notes.trim() || undefined, topText: isEmptyRichText(topText) ? null : topText,
invoiceTemplateId, notes: notes.trim() || null,
keyPoints: buildKeyPointsPayload(keyPoints), invoiceTemplateId: invoiceTemplateId ?? null,
accounts: buildAccountsPayload(accounts), keyPoints: buildKeyPointsPayload(keyPoints),
}) accounts: buildAccountsPayload(accounts),
showToast('Invoice issued.', 'success') })
showToast('Invoice updated.', 'success')
} else {
await createBusinessInvoice(businessId, {
items,
name: invoiceName.trim() || undefined,
topText: isEmptyRichText(topText) ? undefined : topText,
notes: notes.trim() || undefined,
invoiceTemplateId,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
showToast('Invoice issued.', 'success')
}
navigate(listPath) navigate(listPath)
} catch (err) { } catch (err) {
setFormError(err instanceof ApiError ? err.message : 'Unable to create invoice.') setFormError(
err instanceof ApiError
? err.message
: isEdit
? 'Unable to update invoice.'
: 'Unable to create invoice.',
)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -168,9 +213,13 @@ export function IssueInvoicePage() {
<ArrowLeft size={16} /> <ArrowLeft size={16} />
Back to invoices Back to invoices
</Link> </Link>
<h2 className={pageStyles.pageTitle}>Issue invoice · {businessName}</h2> <h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit invoice' : 'Issue invoice'} · {businessName}
</h2>
<p className={pageStyles.pageSubtitle}> <p className={pageStyles.pageSubtitle}>
Select an invoice template and edit it for this business, or create a blank invoice. {isEdit
? 'Update invoice content for this business. Approved invoices cannot be changed.'
: 'Select an invoice template and edit it for this business, or create a blank invoice.'}
</p> </p>
</div> </div>
</div> </div>
@@ -178,24 +227,36 @@ export function IssueInvoicePage() {
{error ? <p className={styles.alertError}>{error}</p> : null} {error ? <p className={styles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>Loading</p> : null} {loading ? <p className={tableStyles.meta}>Loading</p> : null}
{!loading ? ( {!loading && !locked ? (
<section className={settingsStyles.section}> <section className={settingsStyles.section}>
<div className={styles.metaGrid}> {!isEdit ? (
<div className={tableStyles.field}> <div className={styles.metaGrid}>
<label htmlFor="source-template">Start from template</label> <div className={tableStyles.field}>
<select <label htmlFor="source-template">Start from template</label>
id="source-template" <select
value={sourceTemplateId} id="source-template"
onChange={(e) => applyInvoiceTemplate(e.target.value)} value={sourceTemplateId}
> onChange={(e) => applyInvoiceTemplate(e.target.value)}
<option value="">Blank invoice</option> >
{invoiceTemplates.map((template) => ( <option value="">Blank invoice</option>
<option key={template.id} value={template.id}> {invoiceTemplates.map((template) => (
{template.name} · {template.items.length} items <option key={template.id} value={template.id}>
</option> {template.name} · {template.items.length} items
))} </option>
</select> ))}
</select>
</div>
<div className={tableStyles.field}>
<label htmlFor="invoice-name">Name (optional)</label>
<input
id="invoice-name"
value={invoiceName}
onChange={(e) => setInvoiceName(e.target.value)}
placeholder="e.g. Website redesign package"
/>
</div>
</div> </div>
) : (
<div className={tableStyles.field}> <div className={tableStyles.field}>
<label htmlFor="invoice-name">Name (optional)</label> <label htmlFor="invoice-name">Name (optional)</label>
<input <input
@@ -205,9 +266,9 @@ export function IssueInvoicePage() {
placeholder="e.g. Website redesign package" placeholder="e.g. Website redesign package"
/> />
</div> </div>
</div> )}
{invoiceTemplates.length === 0 ? ( {!isEdit && invoiceTemplates.length === 0 ? (
<p className={styles.templateHint}> <p className={styles.templateHint}>
No invoice templates yet. Manage them in{' '} No invoice templates yet. Manage them in{' '}
<Link to="/settings">Settings Invoice templates</Link>, or fill a blank invoice <Link to="/settings">Settings Invoice templates</Link>, or fill a blank invoice
@@ -265,14 +326,32 @@ export function IssueInvoicePage() {
<button <button
type="button" type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`} className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void handleCreate()} onClick={() => void handleSubmit()}
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Issuing…' : 'Issue invoice'} {submitting
? isEdit
? 'Saving…'
: 'Issuing…'
: isEdit
? 'Save changes'
: 'Issue invoice'}
</button> </button>
</div> </div>
</section> </section>
) : null} ) : null}
{!loading && locked ? (
<div className={tableStyles.actionsRow}>
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
onClick={() => navigate(listPath)}
>
Back to invoices
</button>
</div>
) : null}
</main> </main>
) )
} }
@@ -27,6 +27,44 @@
min-width: 180px; min-width: 180px;
} }
.toolbarActions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.toolbarError {
flex-basis: 100%;
margin: 0;
font-size: 12px;
color: #b91c1c;
}
.approveBtn {
display: inline-flex;
align-items: center;
gap: 8px;
height: var(--field-height, 38px);
padding: 0 14px;
border-radius: var(--radius-sm, 12px);
border: 1px solid rgba(4, 120, 87, 0.35);
background: rgba(16, 185, 129, 0.12);
color: #047857;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.approveBtn:hover:not(:disabled) {
background: rgba(16, 185, 129, 0.2);
}
.approveBtn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.printBtn { .printBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -66,6 +104,26 @@
border-bottom: 1px solid rgba(148, 163, 184, 0.22); border-bottom: 1px solid rgba(148, 163, 184, 0.22);
} }
.headerTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.approvedBadge {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 4px 10px;
border-radius: 999px;
background: rgba(16, 185, 129, 0.14);
color: #047857;
font-size: 12px;
font-weight: 650;
}
.title { .title {
margin: 0 0 4px; margin: 0 0 4px;
font-size: 22px; font-size: 22px;
+59 -51
View File
@@ -23,6 +23,7 @@ import {
updateUserRole, updateUserRole,
} from '../services/userService' } from '../services/userService'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css' import tableStyles from './BusinessesPage.module.css'
import styles from './UsersPage.module.css' import styles from './UsersPage.module.css'
@@ -35,14 +36,6 @@ function formatDate(value: string) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }) 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(user: UserListItem) { function displayName(user: UserListItem) {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return name || '—' return name || '—'
@@ -80,17 +73,20 @@ export function UsersPage() {
const [editEmail, setEditEmail] = useState('') const [editEmail, setEditEmail] = useState('')
const [editCell, setEditCell] = useState('') const [editCell, setEditCell] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [resetOpen, setResetOpen] = useState(false) const [resetOpen, setResetOpen] = useState(false)
const [resetUser, setResetUser] = useState<UserListItem | null>(null) const [resetUser, setResetUser] = useState<UserListItem | null>(null)
const [resetPassword, setResetPassword] = useState('') const [resetPassword, setResetPassword] = useState('')
const [resetConfirm, setResetConfirm] = useState('') const [resetConfirm, setResetConfirm] = useState('')
const [resetSubmitting, setResetSubmitting] = useState(false) const [resetSubmitting, setResetSubmitting] = useState(false)
const [resetError, setResetError] = useState('')
const [messageOpen, setMessageOpen] = useState(false) const [messageOpen, setMessageOpen] = useState(false)
const [messageUser, setMessageUser] = useState<UserListItem | null>(null) const [messageUser, setMessageUser] = useState<UserListItem | null>(null)
const [messageText, setMessageText] = useState('') const [messageText, setMessageText] = useState('')
const [messageSubmitting, setMessageSubmitting] = useState(false) const [messageSubmitting, setMessageSubmitting] = useState(false)
const [messageError, setMessageError] = useState('')
const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null)
@@ -99,6 +95,7 @@ export function UsersPage() {
const [selectedRoleSlug, setSelectedRoleSlug] = useState('') const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('') const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('')
const [roleSubmitting, setRoleSubmitting] = useState(false) const [roleSubmitting, setRoleSubmitting] = useState(false)
const [roleError, setRoleError] = useState('')
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [createFirstName, setCreateFirstName] = useState('') const [createFirstName, setCreateFirstName] = useState('')
@@ -107,6 +104,7 @@ export function UsersPage() {
const [createPassword, setCreatePassword] = useState('') const [createPassword, setCreatePassword] = useState('')
const [createEmail, setCreateEmail] = useState('') const [createEmail, setCreateEmail] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false) const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [listVersion, setListVersion] = useState(0) const [listVersion, setListVersion] = useState(0)
const businessFilter = useMemo(() => { const businessFilter = useMemo(() => {
@@ -182,8 +180,6 @@ export function UsersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data) return 0 if (!data) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -222,6 +218,7 @@ export function UsersPage() {
setCreateCell('') setCreateCell('')
setCreatePassword('') setCreatePassword('')
setCreateEmail('') setCreateEmail('')
setCreateError('')
} }
function openCreate() { function openCreate() {
@@ -235,7 +232,7 @@ export function UsersPage() {
if (!cellNumber) return if (!cellNumber) return
setCreateSubmitting(true) setCreateSubmitting(true)
setError('') setCreateError('')
try { try {
await createUser({ await createUser({
businessId: businessFilter.businessId, businessId: businessFilter.businessId,
@@ -251,7 +248,7 @@ export function UsersPage() {
setPage(1) setPage(1)
setListVersion((v) => v + 1) setListVersion((v) => v + 1)
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to add user.') setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.')
} finally { } finally {
setCreateSubmitting(false) setCreateSubmitting(false)
} }
@@ -266,6 +263,7 @@ export function UsersPage() {
setRoleUser(user) setRoleUser(user)
setSelectedRoleSlug(user.roleSlug ?? '') setSelectedRoleSlug(user.roleSlug ?? '')
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '') setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '')
setRoleError('')
setRoleOpen(true) setRoleOpen(true)
} }
@@ -280,7 +278,7 @@ export function UsersPage() {
async function submitRoleChange() { async function submitRoleChange() {
if (!roleUser || !selectedRoleSlug || !canSaveRole) return if (!roleUser || !selectedRoleSlug || !canSaveRole) return
setRoleSubmitting(true) setRoleSubmitting(true)
setError('') setRoleError('')
try { try {
await updateUserRole(roleUser.id, selectedRoleSlug) await updateUserRole(roleUser.id, selectedRoleSlug)
@@ -340,7 +338,7 @@ export function UsersPage() {
setRoleUser(null) setRoleUser(null)
setSelectedTeamRoleSlug('') setSelectedTeamRoleSlug('')
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update role.') setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
} finally { } finally {
setRoleSubmitting(false) setRoleSubmitting(false)
} }
@@ -352,13 +350,14 @@ export function UsersPage() {
setEditLastName(user.lastName ?? '') setEditLastName(user.lastName ?? '')
setEditEmail('') setEditEmail('')
setEditCell(user.cellNumber) setEditCell(user.cellNumber)
setEditError('')
setEditOpen(true) setEditOpen(true)
} }
async function submitEdit() { async function submitEdit() {
if (!editUser) return if (!editUser) return
setEditSubmitting(true) setEditSubmitting(true)
setError('') setEditError('')
try { try {
await updateUser(editUser.id, { await updateUser(editUser.id, {
firstName: editFirstName.trim(), firstName: editFirstName.trim(),
@@ -386,7 +385,7 @@ export function UsersPage() {
} }
}) })
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update user.') setEditError(err instanceof ApiError ? err.message : 'Unable to update user.')
} finally { } finally {
setEditSubmitting(false) setEditSubmitting(false)
} }
@@ -396,24 +395,25 @@ export function UsersPage() {
setResetUser(user) setResetUser(user)
setResetPassword('') setResetPassword('')
setResetConfirm('') setResetConfirm('')
setResetError('')
setResetOpen(true) setResetOpen(true)
} }
async function submitResetPassword() { async function submitResetPassword() {
if (!resetUser) return if (!resetUser) return
if (resetPassword !== resetConfirm) { if (resetPassword !== resetConfirm) {
setError('Passwords do not match.') setResetError('Passwords do not match.')
return return
} }
setResetSubmitting(true) setResetSubmitting(true)
setError('') setResetError('')
try { try {
await adminResetUserPassword(resetUser.id, resetPassword) await adminResetUserPassword(resetUser.id, resetPassword)
setResetOpen(false) setResetOpen(false)
setResetUser(null) setResetUser(null)
showToast(`Password reset for "${displayName(resetUser)}".`, 'success') showToast(`Password reset for "${displayName(resetUser)}".`, 'success')
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to reset password.') setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.')
} finally { } finally {
setResetSubmitting(false) setResetSubmitting(false)
} }
@@ -422,20 +422,21 @@ export function UsersPage() {
function openSendMessage(user: UserListItem) { function openSendMessage(user: UserListItem) {
setMessageUser(user) setMessageUser(user)
setMessageText('') setMessageText('')
setMessageError('')
setMessageOpen(true) setMessageOpen(true)
} }
async function submitSendMessage() { async function submitSendMessage() {
if (!messageUser) return if (!messageUser) return
setMessageSubmitting(true) setMessageSubmitting(true)
setError('') setMessageError('')
try { try {
const result = await sendUserMessage(messageUser.id, messageText.trim()) const result = await sendUserMessage(messageUser.id, messageText.trim())
setMessageOpen(false) setMessageOpen(false)
setMessageUser(null) setMessageUser(null)
showToast(result.message, result.enabled ? 'success' : 'info') showToast(result.message, result.enabled ? 'success' : 'info')
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to send message.') setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.')
} finally { } finally {
setMessageSubmitting(false) setMessageSubmitting(false)
} }
@@ -693,37 +694,15 @@ export function UsersPage() {
<div className={tableStyles.pagination}> <div className={tableStyles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -734,8 +713,14 @@ export function UsersPage() {
onClose={() => { onClose={() => {
setEditOpen(false) setEditOpen(false)
setEditUser(null) setEditUser(null)
setEditError('')
}} }}
> >
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.formGrid}> <div className={tableStyles.formGrid}>
<div className={tableStyles.field}> <div className={tableStyles.field}>
<label htmlFor="edit-first-name">First name</label> <label htmlFor="edit-first-name">First name</label>
@@ -808,8 +793,14 @@ export function UsersPage() {
onClose={() => { onClose={() => {
setResetOpen(false) setResetOpen(false)
setResetUser(null) setResetUser(null)
setResetError('')
}} }}
> >
{resetError ? (
<p className={tableStyles.alertError} role="alert">
{resetError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}> <p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Set a new password for {resetUser ? displayName(resetUser) : 'user'}. Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
</p> </p>
@@ -863,8 +854,14 @@ export function UsersPage() {
onClose={() => { onClose={() => {
setMessageOpen(false) setMessageOpen(false)
setMessageUser(null) setMessageUser(null)
setMessageError('')
}} }}
> >
{messageError ? (
<p className={tableStyles.alertError} role="alert">
{messageError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}> <p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}. Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
</p> </p>
@@ -906,8 +903,14 @@ export function UsersPage() {
setRoleOpen(false) setRoleOpen(false)
setRoleUser(null) setRoleUser(null)
setSelectedTeamRoleSlug('') setSelectedTeamRoleSlug('')
setRoleError('')
}} }}
> >
{roleError ? (
<p className={tableStyles.alertError} role="alert">
{roleError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}> <p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Select a role for {roleUser ? displayName(roleUser) : 'user'}. Select a role for {roleUser ? displayName(roleUser) : 'user'}.
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''} {businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
@@ -1021,6 +1024,11 @@ export function UsersPage() {
resetCreateForm() resetCreateForm()
}} }}
> >
{createError ? (
<p className={tableStyles.alertError} role="alert">
{createError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}> <p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Creates a new account or links an existing user as a customer of this business. Creates a new account or links an existing user as a customer of this business.
Password is required only for new accounts. Password is required only for new accounts.
+19 -42
View File
@@ -26,20 +26,13 @@ import {
updateDomain, updateDomain,
} from '../services/domainService' } from '../services/domainService'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css' import tableStyles from './BusinessesPage.module.css'
import styles from './WebsitesPage.module.css' import styles from './WebsitesPage.module.css'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
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 daysUntilExpiry(expiresAt: string | null) { function daysUntilExpiry(expiresAt: string | null) {
if (!expiresAt) return null if (!expiresAt) return null
const diff = new Date(expiresAt).getTime() - Date.now() const diff = new Date(expiresAt).getTime() - Date.now()
@@ -103,6 +96,7 @@ export function WebsitesPage() {
const [editHost, setEditHost] = useState('') const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('') const [editExpiresAt, setEditExpiresAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false) const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null) const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null) const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
@@ -142,8 +136,6 @@ export function WebsitesPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE)) return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total]) }, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => { const showingFrom = useMemo(() => {
if (!data) return 0 if (!data) return 0
return (page - 1) * PAGE_SIZE + 1 return (page - 1) * PAGE_SIZE + 1
@@ -169,13 +161,14 @@ export function WebsitesPage() {
setEditDomain(domain) setEditDomain(domain)
setEditHost(domain.host) setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '') setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditError('')
setEditOpen(true) setEditOpen(true)
} }
async function submitEdit() { async function submitEdit() {
if (!editDomain) return if (!editDomain) return
setEditSubmitting(true) setEditSubmitting(true)
setError('') setEditError('')
try { try {
await updateDomain(editDomain.id, { await updateDomain(editDomain.id, {
host: editHost.trim(), host: editHost.trim(),
@@ -200,7 +193,7 @@ export function WebsitesPage() {
} }
}) })
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update domain.') setEditError(err instanceof ApiError ? err.message : 'Unable to update domain.')
} finally { } finally {
setEditSubmitting(false) setEditSubmitting(false)
} }
@@ -575,37 +568,15 @@ export function WebsitesPage() {
<div className={tableStyles.pagination}> <div className={tableStyles.pagination}>
<div> <div>
Page {page} / {totalPages} Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</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>
</div> </div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -616,8 +587,14 @@ export function WebsitesPage() {
onClose={() => { onClose={() => {
setEditOpen(false) setEditOpen(false)
setEditDomain(null) setEditDomain(null)
setEditError('')
}} }}
> >
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.field}> <div className={tableStyles.field}>
<label htmlFor="edit-domain-host">Domain name</label> <label htmlFor="edit-domain-host">Domain name</label>
<input <input
@@ -1,5 +1,13 @@
import { apiRequest } from '../lib/api' import { apiRequest } from '../lib/api'
import type { BusinessesListResponse, BusinessListItem, CreateBusinessPayload } from '../types/business' import type {
BusinessesListResponse,
BusinessListItem,
CreateBusinessPayload,
MigrateFromOldPayload,
MigrateFromOldResponse,
PurgeBusinessDataPayload,
PurgeBusinessDataResponse,
} from '../types/business'
export interface ListBusinessesParams { export interface ListBusinessesParams {
page?: number page?: number
@@ -12,6 +20,7 @@ export interface ListBusinessesParams {
export interface UpdateBusinessPayload { export interface UpdateBusinessPayload {
name?: string name?: string
slug?: string slug?: string
oldBusinessId?: number | null
} }
export interface AddDomainPayload { export interface AddDomainPayload {
@@ -91,12 +100,35 @@ export async function removeBusiness(businessId: string) {
}) })
} }
export async function migrateBusinessFromOld(
businessId: string,
payload: MigrateFromOldPayload,
) {
return apiRequest<MigrateFromOldResponse>(`/businesses/${businessId}/migrate-from-old`, {
method: 'POST',
auth: true,
body: payload,
})
}
export async function purgeBusinessData(
businessId: string,
payload: PurgeBusinessDataPayload,
) {
return apiRequest<PurgeBusinessDataResponse>(`/businesses/${businessId}/purge-data`, {
method: 'POST',
auth: true,
body: payload,
})
}
export interface BusinessDetail { export interface BusinessDetail {
id: string id: string
name: string name: string
nameFa: string | null nameFa: string | null
slug: string slug: string
isActive: boolean isActive: boolean
oldBusinessId: string | null
} }
export async function getBusiness(businessId: string, signal?: AbortSignal) { export async function getBusiness(businessId: string, signal?: AbortSignal) {
@@ -12,6 +12,7 @@ import type {
InvoicesListResponse, InvoicesListResponse,
PublicInvoice, PublicInvoice,
UpdateInvoiceItemTemplatePayload, UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload,
UpdateInvoiceTemplatePayload, UpdateInvoiceTemplatePayload,
} from '../types/invoice' } from '../types/invoice'
@@ -122,6 +123,18 @@ export function createBusinessInvoice(businessId: string, payload: CreateInvoice
}) })
} }
export function updateBusinessInvoice(
businessId: string,
invoiceId: string,
payload: UpdateInvoicePayload,
) {
return apiRequest<Invoice>(`/businesses/${businessId}/invoices/${invoiceId}`, {
method: 'PUT',
auth: true,
body: payload,
})
}
export function updateBusinessInvoiceStatus( export function updateBusinessInvoiceStatus(
businessId: string, businessId: string,
invoiceId: string, invoiceId: string,
@@ -148,3 +161,11 @@ export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
signal, signal,
}) })
} }
/** Public approve (no auth). issued → approved. */
export function approvePublicInvoice(publicId: string) {
return apiRequest<PublicInvoice>(`/public/invoices/${publicId}/approve`, {
method: 'POST',
auth: false,
})
}

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