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 { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { toE164CellNumber } from '../lib/cellNumber'
@@ -96,7 +97,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -205,5 +206,7 @@ export function AddCustomerModal({ open, onClose, onCreated }: AddCustomerModalP
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
@@ -123,7 +124,7 @@ export function AddProductByAiModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isSubmitting && onClose()}
@@ -230,5 +231,7 @@ export function AddProductByAiModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import type { Variation, VariationType } from '../types/variation'
import { COLOR_OPTIONS, SIZE_OPTIONS } from '../types/variation'
@@ -114,7 +115,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
? selectedSizes.length > 0
: customValues.some((v) => v.trim())
return (
return createPortal(
<div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -243,6 +244,7 @@ export function AddVariationModal({ open, onClose, onSubmit }: AddVariationModal
</div>
</form>
</div>
</div>
</div>,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper'
import modalStyles from './CategoryModal.module.css'
@@ -70,7 +71,7 @@ export function AddWebsiteSliderSlideModal({
})
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -149,5 +150,7 @@ export function AddWebsiteSliderSlideModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -91,6 +91,7 @@
}
.title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
@@ -102,6 +103,14 @@
overflow: hidden;
}
.title[dir='rtl'] {
text-align: right;
}
.title[dir='ltr'] {
text-align: left;
}
.abstract {
margin: 0;
font-size: 13px;
@@ -113,6 +122,14 @@
overflow: hidden;
}
.abstract[dir='rtl'] {
text-align: right;
}
.abstract[dir='ltr'] {
text-align: left;
}
.meta {
margin: 0;
font-size: 12px;
+24 -4
View File
@@ -6,6 +6,7 @@ import {
formatBlogCardDate,
isBlogVerified,
} from '../services/blogService'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip'
import cardStyles from './BlogCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -31,6 +32,9 @@ export function BlogCard({
}: BlogCardProps) {
const verified = isBlogVerified(blog)
const publishDate = formatBlogCardDate(blog.publishedAt ?? blog.createdAt)
const titleLocale = textLocaleAttrs(blog.title)
const abstractLocale = textLocaleAttrs(blog.abstract)
const hasAbstract = Boolean(blog.abstract?.trim())
return (
<article className={cardStyles.card}>
@@ -60,11 +64,27 @@ export function BlogCard({
</div>
<div className={cardStyles.body}>
<h3 className={cardStyles.title}>{blog.title}</h3>
{blog.abstract ? (
<p className={cardStyles.abstract}>{blog.abstract}</p>
<h3
className={[cardStyles.title, titleLocale.className].filter(Boolean).join(' ')}
lang={titleLocale.lang}
dir={titleLocale.dir}
>
{blog.title}
</h3>
{hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractLocale.dir}
>
{blog.abstract}
</p>
) : (
<p className={cardStyles.abstract}>No summary yet.</p>
<p className={cardStyles.abstract} lang="en" dir="ltr">
No summary yet.
</p>
)}
<p className={cardStyles.meta}>
<span>{formatBlogAuthor(blog.author)}</span>
@@ -1,4 +1,4 @@
import { FolderPlus, Trash2, ChevronRight } from 'lucide-react'
import { FolderPlus, Pencil, Trash2, ChevronRight } from 'lucide-react'
import type { Category } from '../types/category'
import { Tooltip } from './Tooltip'
import styles from './CategoryRow.module.css'
@@ -10,6 +10,7 @@ interface BlogCategoryRowProps {
expanded: boolean
onToggle: () => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
}
@@ -20,10 +21,11 @@ export function BlogCategoryRow({
expanded,
onToggle,
onAddSub,
onEdit,
onRemove,
}: BlogCategoryRowProps) {
return (
<div className={styles.row} style={{ marginLeft: depth * 28 }}>
<div className={styles.row} style={{ marginLeft: depth * 8 }}>
<div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
onClick={hasChildren ? onToggle : undefined}
@@ -59,6 +61,7 @@ export function BlogCategoryRow({
<div className={styles.controls}>
<Tooltip label="Add sub category">
<button
type="button"
className={styles.controlBtn}
onClick={() => onAddSub(category.id)}
aria-label="Add sub category"
@@ -66,8 +69,19 @@ export function BlogCategoryRow({
<FolderPlus size={17} />
</button>
</Tooltip>
<Tooltip label="Edit category">
<button
type="button"
className={styles.controlBtn}
onClick={() => onEdit(category.id)}
aria-label="Edit category"
>
<Pencil size={17} />
</button>
</Tooltip>
<Tooltip label="Remove category">
<button
type="button"
className={`${styles.controlBtn} ${styles.danger}`}
onClick={() => onRemove(category.id)}
aria-label="Remove"
@@ -8,6 +8,7 @@ interface BlogCategoryTreeProps {
expandedIds: Set<string>
onToggle: (id: string) => void
onAddSub: (id: string) => void
onEdit: (id: string) => void
onRemove: (id: string) => void
parentId?: string | null
depth?: number
@@ -18,6 +19,7 @@ export function BlogCategoryTree({
expandedIds,
onToggle,
onAddSub,
onEdit,
onRemove,
parentId = null,
depth = 0,
@@ -39,6 +41,7 @@ export function BlogCategoryTree({
expanded={expanded}
onToggle={() => onToggle(category.id)}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
/>
{childCount && (
@@ -49,6 +52,7 @@ export function BlogCategoryTree({
expandedIds={expandedIds}
onToggle={onToggle}
onAddSub={onAddSub}
onEdit={onEdit}
onRemove={onRemove}
parentId={category.id}
depth={depth + 1}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function BlogCommentsModal({
}
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -258,5 +259,7 @@ export function BlogCommentsModal({
</div>
</div>
</div>
,
document.body,
)
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ImageCropper } from './ImageCropper'
import type { Brand, BrandFormData } from '../types/brand'
@@ -73,7 +74,7 @@ export function BrandModal({
})
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -167,5 +168,7 @@ export function BrandModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import modalStyles from './CategoryModal.module.css'
@@ -79,7 +80,7 @@ export function CategoryAiPromptModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
@@ -157,5 +158,7 @@ export function CategoryAiPromptModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories'
@@ -79,7 +80,7 @@ export function CategoryModal({
categories.filter((category) => category.id !== editingCategory?.id),
)
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -170,5 +171,7 @@ export function CategoryModal({
</form>
</div>
</div>
,
document.body,
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function CategoryRow({
return (
<div
className={styles.row}
style={{ marginLeft: depth * 28 }}
style={{ marginLeft: depth * 8 }}
>
<div
className={`${styles.info} ${hasChildren ? styles.clickable : ''}`}
@@ -10,4 +10,7 @@
.childrenInner {
overflow: hidden;
margin-left: 20px;
padding-left: 20px;
border-left: 2px solid rgba(148, 163, 184, 0.3);
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
@@ -47,7 +48,7 @@ export function ConfirmDeleteModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel}
@@ -82,5 +83,7 @@ export function ConfirmDeleteModal({
</button>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { ContactSubmission } from '../types/contactSubmission'
import { formatCellForDisplay } from '../lib/cellNumber'
@@ -57,7 +58,7 @@ export function ContactSubmissionDetailModal({
const { date, time } = formatDateTime(submission.createdAt)
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -119,5 +120,7 @@ export function ContactSubmissionDetailModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { ProductVariant } from '../types/productVariant'
import { buildVariantLabel } from '../types/productVariant'
@@ -96,7 +97,7 @@ export function CreateProductVariantModal({
onSubmit({ variationId, optionId })
}
return (
return createPortal(
<div
className={`${styles.overlay} ${styles.overlayNested} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -184,5 +185,7 @@ export function CreateProductVariantModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
@@ -241,7 +242,7 @@ export function CreateStoreItemsModal({
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -395,5 +396,7 @@ export function CreateStoreItemsModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { formatCellForDisplay, toE164CellNumber } from '../lib/cellNumber'
@@ -96,7 +97,7 @@ export function EditCustomerModal({
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -186,5 +187,7 @@ export function EditCustomerModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
@@ -209,7 +210,7 @@ export function EditStoreItemsModal({
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -346,5 +347,7 @@ export function EditStoreItemsModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import styles from './ImageLightbox.module.css'
@@ -59,7 +60,7 @@ export function ImageLightbox({
const hasPrev = index > 0
const hasNext = index < images.length - 1
return (
return createPortal(
<div
className={`${styles.overlay} ${open ? styles.overlayIn : styles.overlayOut}`}
onClick={onClose}
@@ -118,5 +119,7 @@ export function ImageLightbox({
</div>
)}
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
const itemCount = totalQuantity(order)
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -148,5 +149,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { ApiError } from '../lib/api'
@@ -86,7 +87,7 @@ export function OrderStepModal({
steps.find((step) => step.id === order.processStepId)?.label ||
order.processStepId
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={() => {
@@ -161,5 +162,7 @@ export function OrderStepModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { Order, OrderTransaction, TransactionStatus, TransactionType } from '../services/orderService'
import { formatIrtPrice } from '../utils/irtPrice'
@@ -116,7 +117,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
if (!mounted || !order) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -188,5 +189,7 @@ export function OrderTransactionsModal({ open, order, onClose }: OrderTransactio
</div>
</div>
</div>
,
document.body,
)
}
@@ -52,6 +52,12 @@
gap: 24px;
}
.gridCols4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.gridFour {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -76,6 +82,12 @@
}
}
@media (max-width: 1400px) {
.gridCols4 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1200px) {
.gridHome {
grid-template-columns: repeat(3, 1fr);
@@ -84,7 +96,8 @@
@media (max-width: 900px) {
.gridHome,
.grid {
.grid,
.gridCols4 {
grid-template-columns: repeat(2, 1fr);
}
}
@@ -109,7 +122,8 @@
.grid,
.gridHome,
.gridFour {
.gridFour,
.gridCols4 {
grid-template-columns: 1fr;
gap: 16px;
}
@@ -1,61 +0,0 @@
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 32px;
padding-top: 24px;
}
.navBtn {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
transition: background 0.2s, color 0.2s, opacity 0.2s;
}
.navBtn:hover:not(:disabled) {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.navBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pages {
display: flex;
align-items: center;
gap: 4px;
}
.pageBtn {
min-width: 38px;
height: 38px;
padding: 0 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.pageBtn:hover {
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.pageBtn.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
+1 -53
View File
@@ -1,53 +1 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import styles from './Pagination.module.css'
interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null
const pages = Array.from({ length: totalPages }, (_, i) => i + 1)
return (
<nav className={styles.pagination} aria-label="Product pages">
<button
type="button"
className={styles.navBtn}
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<ChevronLeft size={18} />
</button>
<div className={styles.pages}>
{pages.map((page) => (
<button
key={page}
type="button"
className={`${styles.pageBtn} ${page === currentPage ? styles.active : ''}`}
onClick={() => onPageChange(page)}
aria-label={`Page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
</div>
<button
type="button"
className={styles.navBtn}
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
<ChevronRight size={18} />
</button>
</nav>
)
}
export { Pagination, type PaginationProps } from '@meshkee/dashboard-ui'
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllStoreItems } from '../services/storeItemService'
@@ -118,7 +119,7 @@ export function PickStoreSpecialItemsModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -175,5 +176,7 @@ export function PickStoreSpecialItemsModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { StoreItem } from '../services/storeItemService'
import { StoreItemPrice } from './StoreItemPrice'
@@ -52,7 +53,7 @@ export function PickStoreVariantModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -112,5 +113,7 @@ export function PickStoreVariantModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listAllBrands } from '../services/brandService'
@@ -111,7 +112,7 @@ export function PickWebsiteBrandsModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -170,5 +171,7 @@ export function PickWebsiteBrandsModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
@@ -112,7 +113,7 @@ export function PickWebsiteCategoriesModal({
if (!mounted) return null
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
@@ -171,5 +172,7 @@ export function PickWebsiteCategoriesModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -25,31 +25,28 @@
cursor: pointer;
}
.clickable:hover .title {
.clickable:hover .titleFa,
.clickable:hover .titleEn {
color: var(--primary);
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
background: transparent;
background: rgba(148, 163, 184, 0.08);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.image {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
width: 100%;
height: auto;
}
.imagePlaceholder {
width: 100%;
height: 100%;
aspect-ratio: 3 / 2;
background: linear-gradient(
135deg,
rgba(148, 163, 184, 0.12) 0%,
@@ -65,11 +62,15 @@
flex: 1;
}
.title {
.titleFa {
margin: 0;
font-family: var(--font-fa), var(--font-en);
font-size: 15px;
font-weight: 600;
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
direction: rtl;
text-align: right;
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -77,6 +78,20 @@
overflow: hidden;
}
.titleEn {
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
color: var(--text-secondary);
text-align: left;
transition: color 0.2s;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.abstract {
margin: 0;
font-size: 13px;
@@ -88,6 +103,14 @@
overflow: hidden;
}
.abstract[dir='rtl'] {
text-align: right;
}
.abstract[dir='ltr'] {
text-align: left;
}
.meta {
margin: 0;
font-size: 12px;
+35 -7
View File
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { Pencil, MessageSquare, Trash2 } from 'lucide-react'
import type { Portfolio } from '../types/portfolio'
import { formatPortfolioCardDate } from '../services/portfolioService'
import { textLocaleAttrs } from '../utils/textLocale'
import { Tooltip } from './Tooltip'
import cardStyles from './PortfolioCard.module.css'
import controlStyles from './ProductCard.module.css'
@@ -22,6 +23,12 @@ export function PortfolioCard({
onRemove,
}: PortfolioCardProps) {
const publishDate = formatPortfolioCardDate(portfolio.publishedAt ?? portfolio.createdAt)
const titleFa = (portfolio.titleFa || portfolio.title || '').trim()
const titleEn = (portfolio.titleEn || '').trim()
const showBothTitles = Boolean(titleFa && titleEn)
const showAbstract = !showBothTitles
const abstractLocale = textLocaleAttrs(portfolio.abstract)
const hasAbstract = Boolean(portfolio.abstract?.trim())
return (
<article className={cardStyles.card}>
@@ -30,7 +37,7 @@ export function PortfolioCard({
{portfolio.titleImageUrl ? (
<img
src={portfolio.titleImageUrl}
alt={portfolio.title}
alt={titleFa || titleEn || 'Portfolio'}
className={cardStyles.image}
loading="lazy"
/>
@@ -40,12 +47,33 @@ export function PortfolioCard({
</div>
<div className={cardStyles.body}>
<h3 className={cardStyles.title}>{portfolio.title}</h3>
{portfolio.abstract ? (
<p className={cardStyles.abstract}>{portfolio.abstract}</p>
) : (
<p className={cardStyles.abstract}>No summary yet.</p>
)}
{titleFa ? (
<h3 className={`${cardStyles.titleFa} faText`} lang="fa" dir="rtl">
{titleFa}
</h3>
) : null}
{titleEn ? (
<p className={cardStyles.titleEn} lang="en" dir="ltr">
{titleEn}
</p>
) : null}
{showAbstract ? (
hasAbstract ? (
<p
className={[cardStyles.abstract, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractLocale.dir}
>
{portfolio.abstract}
</p>
) : (
<p className={cardStyles.abstract} lang="en" dir="ltr">
No summary yet.
</p>
)
) : null}
<p className={cardStyles.meta}>
{portfolio.categoryName ? (
<>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function PortfolioCommentsModal({
}
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -258,5 +259,7 @@ export function PortfolioCommentsModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -35,6 +35,7 @@
overflow: hidden;
background: #ffffff;
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
}
.image {
@@ -80,6 +81,7 @@
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
direction: rtl;
text-align: left;
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, ThumbsUp, Check, XCircle, Trash2 } from 'lucide-react'
import type { ProductComment } from '../types/comment'
import { formatCommentDate } from '../data/productComments'
@@ -147,7 +148,7 @@ export function ProductCommentsModal({
}
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -258,5 +259,7 @@ export function ProductCommentsModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
@@ -210,7 +211,7 @@ export function ProductTechnicalInfoModal({
)
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -271,5 +272,7 @@ export function ProductTechnicalInfoModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
@@ -119,7 +120,7 @@ export function ProductVariantsModal({
}
}
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -202,5 +203,7 @@ export function ProductVariantsModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -131,6 +131,7 @@
.optionFa {
font-family: var(--font-fa), var(--font-en);
font-weight: 500;
direction: rtl;
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ArrowLeft, Minus, Plus, Search, Trash2, User, X } from 'lucide-react'
import { useDraftCart } from '../context/DraftCartContext'
import { useToast } from '../context/ToastContext'
@@ -341,7 +342,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
const canAddPayment = payments.length < PAYMENT_METHOD_OPTIONS.length
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -700,5 +701,7 @@ export function ShoppingCartModal({ open, onClose, onOrderCreated }: ShoppingCar
</div>
</div>
</div>
,
document.body,
)
}
@@ -103,6 +103,7 @@
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
direction: rtl;
text-align: left;
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
@@ -119,7 +120,7 @@ export function StoreItemDiscountModal({
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -196,5 +197,7 @@ export function StoreItemDiscountModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { ApiError } from '../lib/api'
import {
@@ -114,7 +115,7 @@ export function StoreItemFestivalModal({
}
}
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -193,5 +194,7 @@ export function StoreItemFestivalModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import styles from './CategoryModal.module.css'
@@ -63,13 +64,18 @@ export function StoreSpecialModal({
onSubmit(trimmed)
}
return (
return createPortal(
<div
className={styles.overlay}
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div className={styles.modal} role="dialog" aria-modal="true" aria-labelledby="special-modal-title">
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="special-modal-title"
>
<div className={styles.header}>
<h2 id="special-modal-title" className={styles.title}>
{title}
@@ -107,5 +113,7 @@ export function StoreSpecialModal({
</form>
</div>
</div>
,
document.body,
)
}
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Sparkles, X } from 'lucide-react'
import modalStyles from './CategoryModal.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './CategoryAiPromptModal.module.css'
export function buildTechnicalFormAiPrompt(categoryName: string) {
return `Suggest technical data form fields for the "${categoryName}" product category.
For each field include:
- A short English label (e.g. "Screen Size", "Material", "Color")
- The input type: text, textarea, select, or multi_select
- Whether it is required
- For select / multi_select: the list of options
Focus on specs and attributes that shoppers typically compare when buying this type of product.`
}
interface TechnicalFormAiPromptModalProps {
open: boolean
categoryName: string
onClose: () => void
onRun: (prompt: string) => Promise<void>
isRunning: boolean
}
const ANIMATION_MS = 220
export function TechnicalFormAiPromptModal({
open,
categoryName,
onClose,
onRun,
isRunning,
}: TechnicalFormAiPromptModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [prompt, setPrompt] = useState(() => buildTechnicalFormAiPrompt(categoryName))
const [error, setError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setPrompt(buildTechnicalFormAiPrompt(categoryName))
setError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, categoryName])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isRunning) onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, isRunning, onClose])
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmed = prompt.trim()
if (trimmed.length < 10) {
setError('Prompt must be at least 10 characters.')
return
}
setError('')
try {
await onRun(trimmed)
} catch (err) {
if (err instanceof Error) {
setError(err.message)
} else {
setError('Unable to generate technical form with AI.')
}
}
}
if (!mounted) return null
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && !isRunning && onClose()}
role="presentation"
>
<div
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="tech-form-ai-prompt-title"
>
<div className={`${modalStyles.header} ${styles.header}`}>
<div className={styles.headerBlock}>
<h2 id="tech-form-ai-prompt-title" className={modalStyles.title}>
Generate Technical Form with AI
</h2>
<p className={styles.subtitle}>
Edit the prompt below, then run it to fill the form fields.
</p>
</div>
<button
type="button"
className={modalStyles.closeBtn}
onClick={onClose}
disabled={isRunning}
aria-label="Close"
>
<X size={20} />
</button>
</div>
<form className={modalStyles.form} onSubmit={(e) => void handleSubmit(e)}>
<div className={`${modalStyles.field} ${aiStyles.aiPromptField}`}>
<label htmlFor="tech-form-ai-prompt">Prompt</label>
<textarea
id="tech-form-ai-prompt"
className={aiStyles.aiPromptTextarea}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={8}
disabled={isRunning}
required
/>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isRunning}
>
Cancel
</button>
<button
type="submit"
className={aiStyles.aiBtn}
disabled={isRunning || prompt.trim().length < 10}
>
<Sparkles size={16} />
{isRunning ? 'Generating…' : 'Run'}
</button>
</div>
</form>
</div>
</div>
,
document.body,
)
}
@@ -156,4 +156,5 @@
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, Sparkles, Trash2, X } from 'lucide-react'
import type { TechnicalFieldType, TechnicalFormFieldDraft } from '../types/technicalForm'
import { createId } from '../utils/id'
@@ -18,6 +19,7 @@ interface TechnicalFormModalProps {
onChange: (fields: TechnicalFormFieldDraft[]) => void
onSave: () => void
onGenerate?: () => void
onGenerateClick?: () => void
}
const ANIMATION_MS = 220
@@ -51,6 +53,7 @@ export function TechnicalFormModal({
onChange,
onSave,
onGenerate,
onGenerateClick,
}: TechnicalFormModalProps) {
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
@@ -166,7 +169,7 @@ export function TechnicalFormModal({
return true
})
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
@@ -314,11 +317,11 @@ export function TechnicalFormModal({
</div>
<div className={fieldStyles.toolbar}>
{onGenerate && (
{(onGenerateClick ?? onGenerate) && (
<button
type="button"
className={aiStyles.aiBtn}
onClick={onGenerate}
onClick={onGenerateClick ?? onGenerate}
disabled={isLoading || isSaving || isGenerating}
>
<Sparkles size={16} />
@@ -356,5 +359,7 @@ export function TechnicalFormModal({
</div>
</div>
</div>
,
document.body,
)
}
@@ -1,18 +1,18 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.25);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 1000;
padding: 20px;
}
.overlayNested {
z-index: 210;
z-index: 1010;
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
@@ -119,6 +119,14 @@
color: var(--text-primary);
}
.listItemName:global(.faText),
.listItemValues:global(.faText),
.subtitle:global(.faText) {
font-weight: 500;
text-align: left;
unicode-bidi: plaintext;
}
.listItemValues {
font-size: 12px;
color: var(--text-muted);
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Plus, X } from 'lucide-react'
import type { Variation, VariationType } from '../types/variation'
import { createId } from '../utils/id'
import { textLocaleAttrs } from '../utils/textLocale'
import { AddVariationModal } from './AddVariationModal'
import styles from './VariationsModal.module.css'
@@ -57,6 +59,7 @@ export function VariationsModal({
if (!mounted) return null
const list = variations
const categoryLocale = textLocaleAttrs(categoryName)
function handleAdd(variation: Omit<Variation, 'id'>) {
onChange([
@@ -76,7 +79,7 @@ export function VariationsModal({
return 'Custom'
}
return (
return createPortal(
<>
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
@@ -95,7 +98,13 @@ export function VariationsModal({
Variations
</h3>
{categoryName && (
<p className={styles.subtitle}>{categoryName}</p>
<p
className={[styles.subtitle, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang}
dir={categoryLocale.dir}
>
{categoryName}
</p>
)}
</div>
<button className={styles.closeBtn} onClick={onClose} aria-label="Close">
@@ -105,12 +114,26 @@ export function VariationsModal({
<div className={styles.body}>
<div className={styles.list}>
{list.map((variation) => (
{list.map((variation) => {
const nameLocale = textLocaleAttrs(variation.name)
const valuesText = variation.values.join(' · ') || 'No values'
const valuesLocale = textLocaleAttrs(valuesText)
return (
<div key={variation.id} className={styles.listItem}>
<div className={styles.listItemInfo}>
<div className={styles.listItemName}>{variation.name}</div>
<div className={styles.listItemValues}>
{variation.values.join(' · ') || 'No values'}
<div
className={[styles.listItemName, nameLocale.className].filter(Boolean).join(' ')}
lang={nameLocale.lang}
dir={nameLocale.dir}
>
{variation.name}
</div>
<div
className={[styles.listItemValues, valuesLocale.className].filter(Boolean).join(' ')}
lang={valuesLocale.lang}
dir={valuesLocale.dir}
>
{valuesText}
</div>
</div>
<div className={styles.listItemEnd}>
@@ -125,7 +148,8 @@ export function VariationsModal({
</button>
</div>
</div>
))}
)
})}
{!list.length && (
<p className={styles.emptyText}>No variations yet. Add color, size, or custom options.</p>
)}
@@ -153,6 +177,7 @@ export function VariationsModal({
onClose={() => setAddOpen(false)}
onSubmit={handleAdd}
/>
</>
</>,
document.body,
)
}
@@ -31,10 +31,13 @@
.nameFa {
margin-top: 4px;
font-family: var(--font-fa), var(--font-en);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
text-align: left;
line-height: 1.4;
direction: rtl;
}
.subtitle {
+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,
}))
export const PRODUCTS_PER_PAGE = 12
export const PRODUCTS_PER_PAGE = 24
export function getProductById(id: string): Product | undefined {
return dummyProducts.find((p) => p.id === id)
+2 -26
View File
@@ -1,9 +1,7 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
@@ -11,30 +9,8 @@
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+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,
textarea::placeholder {
font-family: var(--font-en);
opacity: 1;
}
[dir='rtl'],
:lang(fa),
.faText {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
font-weight: 400; /* IRANYekan Regular */
text-align: right;
}
[dir='rtl']::placeholder,
:lang(fa)::placeholder,
.faText::placeholder {
.faText::placeholder,
input.faText::placeholder,
input[dir='rtl']::placeholder,
input[lang='fa']::placeholder {
font-family: var(--font-fa), var(--font-en);
font-weight: 400;
opacity: 1;
}
+37 -15
View File
@@ -49,7 +49,8 @@ export function AddNewPortfolioPage() {
const [categories, setCategories] = useState<Category[]>([])
const [categoryId, setCategoryId] = useState('')
const [title, setTitle] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [abstract, setAbstract] = useState('')
const [mainTextHtml, setMainTextHtml] = useState('')
const [titleImage, setTitleImage] = useState<string | null>(null)
@@ -98,7 +99,8 @@ export function AddNewPortfolioPage() {
const portfolio = await getPortfolio(id!, controller.signal)
const form = mapPortfolioApiToFormState(portfolio)
setCategoryId(form.categoryId)
setTitle(form.title)
setTitleFa(form.titleFa)
setTitleEn(form.titleEn)
setAbstract(form.abstract)
setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage)
@@ -140,7 +142,8 @@ export function AddNewPortfolioPage() {
}
const payload = {
title: title.trim(),
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim(),
mainTextHtml: resolvedMainTextHtml,
categoryId: categoryId || undefined,
@@ -267,18 +270,37 @@ export function AddNewPortfolioPage() {
/>
</div>
<div className={styles.field}>
<label htmlFor="portfolio-title">Title</label>
<input
id="portfolio-title"
name="title"
type="text"
placeholder="Portfolio title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={isSubmitting}
/>
<div className={styles.titleRow}>
<div className={styles.field}>
<label htmlFor="portfolio-title-fa">Title (FA)</label>
<input
id="portfolio-title-fa"
name="titleFa"
type="text"
dir="rtl"
lang="fa"
className="faText"
placeholder="عنوان فارسی"
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<div className={styles.field}>
<label htmlFor="portfolio-title-en">Title (EN)</label>
<input
id="portfolio-title-en"
name="titleEn"
type="text"
dir="ltr"
placeholder="English title"
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<div className={styles.field}>
@@ -34,6 +34,13 @@
min-width: 0;
}
.titleRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
min-width: 0;
}
.thumbnailField {
align-self: start;
}
@@ -244,6 +251,10 @@
grid-template-columns: 1fr;
}
.titleRow {
grid-template-columns: 1fr;
}
.fieldFull,
.fieldHalf,
.fieldCategory,
@@ -63,6 +63,13 @@ export function BlogCategoriesPage() {
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category')
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
@@ -174,6 +181,10 @@ export function BlogCategoriesPage() {
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
+32 -4
View File
@@ -11,6 +11,7 @@ import {
} from '../services/blogService'
import type { BlogDetail } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css'
import styles from './BlogDetailsPage.module.css'
@@ -71,6 +72,11 @@ export function BlogDetailsPage() {
const displayDate = formatBlogDate(blog.publishedAt ?? blog.createdAt)
const typeLabel =
BLOG_TYPE_OPTIONS.find((option) => option.value === blog.type)?.label ?? blog.type
const titleLocale = textLocaleAttrs(blog.title)
const abstractLocale = textLocaleAttrs(blog.abstract)
const contentLocale = textLocaleAttrs(
blog.mainTextHtml?.replace(/<[^>]+>/g, ' ') ?? '',
)
return (
<main className={pageStyles.content}>
@@ -85,8 +91,26 @@ export function BlogDetailsPage() {
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{blog.title}</h2>
{blog.abstract && <p className={pageStyles.pageSubtitle}>{blog.abstract}</p>}
<h2
className={[pageStyles.pageTitle, titleLocale.className]
.filter(Boolean)
.join(' ')}
lang={titleLocale.lang}
dir={titleLocale.dir}
>
{blog.title}
</h2>
{blog.abstract ? (
<p
className={[pageStyles.pageSubtitle, abstractLocale.className]
.filter(Boolean)
.join(' ')}
lang={abstractLocale.lang}
dir={abstractLocale.dir}
>
{blog.abstract}
</p>
) : null}
</div>
</div>
@@ -124,11 +148,15 @@ export function BlogDetailsPage() {
<div className={styles.contentPanel}>
{blog.mainTextHtml ? (
<div
className={styles.content}
className={[styles.content, contentLocale.className].filter(Boolean).join(' ')}
lang={contentLocale.lang}
dir={contentLocale.dir}
dangerouslySetInnerHTML={{ __html: blog.mainTextHtml }}
/>
) : (
<p className={styles.content}>No content yet.</p>
<p className={styles.content} lang="en" dir="ltr">
No content yet.
</p>
)}
<div className={styles.authorRow}>
+20 -2
View File
@@ -8,6 +8,7 @@ import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { VariationsModal } from '../components/VariationsModal'
import { TechnicalFormModal } from '../components/TechnicalFormModal'
import { TechnicalFormAiPromptModal } from '../components/TechnicalFormAiPromptModal'
import type { Category, CategoryFormData } from '../types/category'
import type { Variation } from '../types/variation'
import type { TechnicalFormFieldDraft } from '../types/technicalForm'
@@ -68,6 +69,7 @@ export function CategoriesPage() {
const [technicalError, setTechnicalError] = useState('')
const [aiModalOpen, setAiModalOpen] = useState(false)
const [aiGenerating, setAiGenerating] = useState(false)
const [techAiPromptOpen, setTechAiPromptOpen] = useState(false)
useEffect(() => {
const controller = new AbortController()
@@ -287,14 +289,19 @@ export function CategoriesPage() {
return categoryTechnicalFields[categoryId] ?? []
}
async function handleGenerateTechnicalForm() {
function handleGenerateTechnicalFormClick() {
setTechAiPromptOpen(true)
}
async function handleGenerateTechnicalForm(hint?: string) {
if (!technicalTarget) return
setTechAiPromptOpen(false)
setTechnicalGenerating(true)
setTechnicalError('')
try {
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId)
const suggested = await suggestCategoryTechnicalFormByAi(technicalTarget.categoryId, hint)
const drafts: TechnicalFormFieldDraft[] = suggested.map((field) => ({
id: createId(),
label: field.label,
@@ -523,6 +530,17 @@ export function CategoriesPage() {
}}
onSave={() => void handleSaveTechnicalForm()}
onGenerate={() => void handleGenerateTechnicalForm()}
onGenerateClick={handleGenerateTechnicalFormClick}
/>
)}
{technicalTarget && (
<TechnicalFormAiPromptModal
open={techAiPromptOpen}
categoryName={technicalTarget.categoryName}
onClose={() => setTechAiPromptOpen(false)}
onRun={(prompt) => handleGenerateTechnicalForm(prompt)}
isRunning={technicalGenerating}
/>
)}
@@ -102,6 +102,11 @@
line-height: 1.35;
}
.customerName[dir='rtl'] {
font-weight: 400;
text-align: left;
}
.emailCell {
overflow: hidden;
text-overflow: ellipsis;
@@ -139,7 +144,7 @@
}
.toggleInActions {
margin-right: 0;
margin-right: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -185,14 +190,22 @@
.pagination {
padding: 12px 16px;
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagination > div:first-child {
justify-self: start;
}
.pagination > nav {
justify-self: center;
}
.pagerBtns {
display: flex;
gap: 8px;
+41 -50
View File
@@ -4,6 +4,7 @@ import { AddCustomerModal } from '../components/AddCustomerModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { EditCustomerModal } from '../components/EditCustomerModal'
import { Pagination } from '../components/Pagination'
import { ToggleSwitch } from '../components/ToggleSwitch'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
@@ -17,6 +18,7 @@ import {
type CustomersListResponse,
} from '../services/customerService'
import { formatIrtPrice } from '../utils/irtPrice'
import { textLocaleAttrs } from '../utils/textLocale'
import filterStyles from '../components/ListFiltersPanel.module.css'
import pageStyles from '../components/PageContent.module.css'
import styles from './CustomersPage.module.css'
@@ -30,14 +32,6 @@ function formatDate(value: string) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(customer: BusinessCustomerListItem) {
const name = [customer.firstName, customer.lastName].filter(Boolean).join(' ').trim()
return name || '—'
@@ -112,8 +106,6 @@ export function CustomersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -365,7 +357,24 @@ export function CustomersPage() {
className={!customer.isEnabled ? styles.inactiveRow : undefined}
>
<td className={styles.td}>
<div className={styles.customerName}>{displayName(customer)}</div>
{(() => {
const name = displayName(customer)
const locale = textLocaleAttrs(name)
return (
<div
className={[
styles.customerName,
locale.className,
]
.filter(Boolean)
.join(' ')}
lang={locale.lang}
dir={locale.dir}
>
{name}
</div>
)
})()}
{!customer.isEnabled && (
<div className={styles.statusDisabled}>Disabled</div>
)}
@@ -383,15 +392,19 @@ export function CustomersPage() {
</td>
<td className={`${styles.td} ${styles.tdActions}`}>
<div className={styles.rowActions}>
<span className={styles.toggleInActions}>
<ToggleSwitch
checked={customer.isEnabled}
disabled={togglingId === customer.id || removing}
size="compact"
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
/>
</span>
<Tooltip
label={`${customer.isEnabled ? 'Disable' : 'Enable'} customer`}
>
<span className={styles.toggleInActions}>
<ToggleSwitch
checked={customer.isEnabled}
disabled={togglingId === customer.id || removing}
size="compact"
ariaLabel={`${customer.isEnabled ? 'Disable' : 'Enable'} ${displayName(customer)}`}
onChange={(isEnabled) => void handleToggleEnabled(customer, isEnabled)}
/>
</span>
</Tooltip>
<Tooltip label="Edit customer">
<button
type="button"
@@ -442,37 +455,15 @@ export function CustomersPage() {
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -24,6 +24,25 @@
border-radius: var(--radius-sm);
}
.pagination {
margin-top: 20px;
padding: 12px 4px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagination > div:first-child {
justify-self: start;
}
.pagination > nav {
justify-self: center;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(3, 1fr);
+13 -5
View File
@@ -260,11 +260,19 @@ export function MyProductsPage() {
))}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<div className={styles.pagination}>
<div>
Page {currentPage} / {totalPages} · {PRODUCTS_PER_PAGE} per page ·{' '}
{totalProducts} total
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
disabled={isLoading}
variant="inline"
/>
</div>
</>
)}
+10 -2
View File
@@ -267,14 +267,22 @@
.pagination {
padding: 12px 16px;
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagination > nav {
justify-self: center;
}
.pagination > div:first-child {
justify-self: start;
}
.pagerBtns {
display: flex;
gap: 8px;
+9 -40
View File
@@ -5,6 +5,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { OrderItemsModal } from '../components/OrderItemsModal'
import { OrderStepModal } from '../components/OrderStepModal'
import { OrderTransactionsModal } from '../components/OrderTransactionsModal'
import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
@@ -39,14 +40,6 @@ interface AppliedFilters {
maxTotal?: number
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(order: Order) {
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
return name || '—'
@@ -202,8 +195,6 @@ export function OrdersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -547,37 +538,15 @@ export function OrdersPage() {
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -63,6 +63,13 @@ export function PortfolioCategoriesPage() {
}
}
function openEditModal(category: Category) {
setEditingCategory(category)
setDefaultParentId(category.parentId ?? '')
setModalTitle('Edit Category')
setModalOpen(true)
}
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
@@ -174,6 +181,10 @@ export function PortfolioCategoriesPage() {
expandedIds={expandedIds}
onToggle={toggleExpanded}
onAddSub={(id) => openCreateModal(id, 'Add Sub Category')}
onEdit={(id) => {
const category = categories.find((item) => item.id === id)
if (category) openEditModal(category)
}}
onRemove={(id) => {
const category = categories.find((item) => item.id === id)
if (category) setDeleteTarget(category)
@@ -151,7 +151,7 @@ export function PortfolioListPage() {
<p className={styles.empty}>No portfolio items found.</p>
) : (
<>
<div className={pageStyles.grid}>
<div className={pageStyles.gridCols4}>
{portfolios.map((portfolio) => (
<PortfolioCard
key={portfolio.id}
@@ -119,12 +119,15 @@
}
.nameFa {
font-family: var(--font-fa), var(--font-en);
font-size: 18px;
font-weight: 400;
color: var(--text-secondary);
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.nameFa:global(.faText) {
font-weight: 400;
text-align: left;
}
.summary {
@@ -136,13 +139,26 @@
}
.description {
font-family: var(--font-fa), var(--font-en);
font-size: 14px;
font-weight: 300; /* IRANYekan Light */
line-height: 1.7;
color: var(--text-primary);
text-align: justify;
}
.description:global(.faText),
.description:global(.faText) :where(*) {
font-family: var(--font-fa), var(--font-en);
font-weight: 300;
text-align: justify;
}
.description p {
margin-bottom: 0.75em;
font-family: inherit;
font-weight: inherit;
text-align: inherit;
}
.description p:last-child {
+61 -10
View File
@@ -9,6 +9,7 @@ import {
mapProductApiToUi,
} from '../services/productService'
import type { Product } from '../types/product'
import { textLocaleAttrs } from '../utils/textLocale'
import pageStyles from '../components/PageContent.module.css'
import styles from './ProductDetailsPage.module.css'
@@ -78,6 +79,14 @@ export function ProductDetailsPage() {
)
}
const nameEnLocale = textLocaleAttrs(product.nameEn)
const nameFaLocale = textLocaleAttrs(product.nameFa)
const summaryLocale = textLocaleAttrs(product.summary)
const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
)
const categoryLocale = textLocaleAttrs(product.category)
return (
<main className={pageStyles.content}>
<Breadcrumbs
@@ -127,27 +136,69 @@ export function ProductDetailsPage() {
</div>
<div className={styles.details}>
{product.category && <span className={styles.categoryChip}>{product.category}</span>}
{product.category && (
<span
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang}
dir={categoryLocale.dir}
>
{product.category}
</span>
)}
<h1 className={styles.nameEn}>{product.nameEn}</h1>
{product.nameFa && <p className={styles.nameFa}>{product.nameFa}</p>}
<h1
className={[styles.nameEn, nameEnLocale.className].filter(Boolean).join(' ')}
lang={nameEnLocale.lang}
dir={nameEnLocale.dir}
>
{product.nameEn}
</h1>
{product.nameFa && (
<p
className={[styles.nameFa, nameFaLocale.className].filter(Boolean).join(' ')}
lang={nameFaLocale.lang}
dir={nameFaLocale.dir}
>
{product.nameFa}
</p>
)}
{product.summary && <p className={styles.summary}>{product.summary}</p>}
{product.summary && (
<p
className={[styles.summary, summaryLocale.className].filter(Boolean).join(' ')}
lang={summaryLocale.lang}
dir={summaryLocale.dir}
>
{product.summary}
</p>
)}
{product.description && (
<div
className={styles.description}
className={[styles.description, descriptionLocale.className]
.filter(Boolean)
.join(' ')}
lang={descriptionLocale.lang}
dir={descriptionLocale.dir}
dangerouslySetInnerHTML={{ __html: product.description }}
/>
)}
{product.tags.length > 0 && (
<div className={styles.tags}>
{product.tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
</span>
))}
{product.tags.map((tag) => {
const tagLocale = textLocaleAttrs(tag)
return (
<span
key={tag}
className={[styles.tag, tagLocale.className].filter(Boolean).join(' ')}
lang={tagLocale.lang}
dir={tagLocale.dir}
>
{tag}
</span>
)
})}
</div>
)}
</div>
+9 -40
View File
@@ -3,6 +3,7 @@ import { Play, RotateCcw, Search, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { Pagination } from '../components/Pagination'
import { Tooltip } from '../components/Tooltip'
import { useToast } from '../context/ToastContext'
import { ApiError, isAbortError } from '../lib/api'
@@ -31,14 +32,6 @@ interface AppliedFilters {
maxTotal?: number
}
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function displayName(card: ShoppingCard) {
const name = [card.customer.firstName, card.customer.lastName]
.filter(Boolean)
@@ -123,8 +116,6 @@ export function ShoppingCardsPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -403,37 +394,15 @@ export function ShoppingCardsPage() {
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -88,14 +88,18 @@
.pagination {
padding: 12px 16px;
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
.pagination > nav {
justify-self: center;
}
.pagerBtns {
display: flex;
align-items: center;
+9 -40
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { RotateCcw, Search } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ContactSubmissionDetailModal } from '../components/ContactSubmissionDetailModal'
import { Pagination } from '../components/Pagination'
import { ApiError, isAbortError } from '../lib/api'
import { formatCellForDisplay } from '../lib/cellNumber'
import {
@@ -18,14 +19,6 @@ import styles from './WebsiteContactPage.module.css'
const PAGE_SIZE = 20
const COLUMN_COUNT = 6
function buildPageNumbers(page: number, totalPages: number) {
const start = Math.max(1, page - 2)
const end = Math.min(totalPages, page + 2)
const numbers: number[] = []
for (let i = start; i <= end; i++) numbers.push(i)
return numbers
}
function formatDateTime(value: string) {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return { date: value, time: '' }
@@ -83,8 +76,6 @@ export function WebsiteContactPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -252,37 +243,15 @@ export function WebsiteContactPage() {
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
+14 -3
View File
@@ -9,6 +9,8 @@ export interface PortfolioApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
mainTextHtml: string
@@ -51,7 +53,10 @@ function businessPath(suffix = '') {
}
export interface CreatePortfolioPayload {
title: string
titleFa: string
titleEn?: string
/** @deprecated Prefer titleFa */
title?: string
abstract?: string
mainTextHtml?: string
categoryId?: string
@@ -63,6 +68,9 @@ export interface CreatePortfolioPayload {
}
export interface UpdatePortfolioPayload {
titleFa?: string
titleEn?: string | null
/** @deprecated Prefer titleFa */
title?: string
abstract?: string | null
mainTextHtml?: string | null
@@ -87,7 +95,9 @@ export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string |
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
return {
id: portfolio.id,
title: portfolio.title,
title: portfolio.titleFa || portfolio.title,
titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? null,
slug: portfolio.slug,
abstract: portfolio.abstract,
status: portfolio.status,
@@ -134,7 +144,8 @@ export function mapPortfolioDetailFromApi(portfolio: PortfolioApi): PortfolioDet
export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
return {
title: portfolio.title,
titleFa: portfolio.titleFa || portfolio.title,
titleEn: portfolio.titleEn ?? '',
abstract: portfolio.abstract,
mainTextHtml: portfolio.mainTextHtml,
categoryId: portfolio.categoryId ?? '',
+1 -1
View File
@@ -2,7 +2,7 @@ import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type { Product } from '../types/product'
export const PRODUCTS_PER_PAGE = 12
export const PRODUCTS_PER_PAGE = 24
export interface ProductApi {
id: string
+2
View File
@@ -8,6 +8,8 @@ export interface PortfolioGalleryItem {
export interface Portfolio {
id: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
status: PortfolioStatus
+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 { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import type { Order } from '../services/orderService'
import { formatCellForDisplay } from '../lib/cellNumber'
@@ -59,7 +60,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
const itemCount = totalQuantity(order)
return (
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
onClick={onClose}
@@ -148,5 +149,7 @@ export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps)
</div>
</div>
</div>
,
document.body,
)
}
+7 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from '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 { ApiError, isAbortError } from '../lib/api'
import {
@@ -113,25 +113,12 @@ export function FavoritesPage() {
)}
{data && data.total > PAGE_SIZE && (
<div className={styles.pagination}>
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
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>
<Pagination
currentPage={page}
totalPages={Math.max(1, Math.ceil(data.total / data.pageSize))}
onPageChange={setPage}
disabled={loading}
/>
)}
</main>
)
+9 -41
View File
@@ -1,5 +1,5 @@
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 { OrderRow } from '../components/OrderRow'
import { ApiError, isAbortError } from '../lib/api'
@@ -15,14 +15,6 @@ import styles from './OrdersPage.module.css'
const PAGE_SIZE = 20
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() {
const [data, setData] = useState<OrdersListResponse | null>(null)
const [loading, setLoading] = useState(false)
@@ -58,8 +50,6 @@ export function OrdersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data || data.total === 0) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -155,37 +145,15 @@ export function OrdersPage() {
{data && data.total > PAGE_SIZE && (
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data.total} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</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 { ToastProvider } from './context/ToastContext'
import { AdminDomainGuard } from './components/AdminDomainGuard'
import { ProtectedRoute } from './components/ProtectedRoute'
import { GuestRoute } from './components/GuestRoute'
import { PageLayout } from './components/PageLayout'
import { RouteErrorBoundary } from './components/RouteErrorBoundary'
import { HomePage } from './pages/HomePage'
import { BusinessesPage } from './pages/BusinessesPage'
import { BusinessInvoicesPage } from './pages/BusinessInvoicesPage'
@@ -17,41 +18,65 @@ import { PublicInvoicePage } from './pages/PublicInvoicePage'
import { ProfilePage } from './pages/ProfilePage'
import { LoginPage } from './pages/LoginPage'
function ProtectedPages() {
return (
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
)
}
function App() {
return (
<AdminDomainGuard>
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
{/* Host-agnostic: can be served from meshkee.com or other public domains */}
<Route path="invoices/:invoiceId" element={<PublicInvoicePage />} />
<Route element={<AdminDomainGuard />}>
<Route element={<GuestRoute />}>
<Route path="login" element={<LoginPage />} />
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<PageLayout />}>
<Route index element={<HomePage />} />
<Route path="businesses" element={<BusinessesPage />} />
<Route path="businesses/:businessId/invoices" element={<BusinessInvoicesPage />} />
<Route path="businesses/:businessId/invoices/new" 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 element={<ProtectedPages />}>
<Route index element={<HomePage />} />
<Route path="businesses" element={<BusinessesPage />} />
<Route
path="businesses/:businessId/invoices"
element={<BusinessInvoicesPage />}
/>
<Route
path="businesses/:businessId/invoices/new"
element={<IssueInvoicePage />}
/>
<Route
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>
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
</AdminDomainGuard>
</Route>
</Routes>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
)
}
@@ -1,10 +1,11 @@
import type { ReactNode } from 'react'
import { Outlet } from 'react-router-dom'
import { getAdminDomain, isAllowedAdminHost } from '../lib/config'
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()) {
return children
return <Outlet />
}
const expectedDomain = getAdminDomain()
@@ -1,13 +1,13 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(127, 29, 29, 0.2);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
background: rgba(127, 29, 29, 0.28);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
z-index: 300;
padding: 20px;
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { AlertTriangle, X } from 'lucide-react'
import styles from './ConfirmDeleteModal.module.css'
@@ -41,13 +42,18 @@ export function ConfirmDeleteModal({
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [mounted, closing, onCancel])
if (!mounted) return null
return (
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onCancel}
@@ -81,6 +87,7 @@ export function ConfirmDeleteModal({
<X size={18} />
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -1,9 +1,9 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.25);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
@@ -73,4 +73,3 @@
flex: 1;
min-height: 0;
}
+19 -6
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import styles from './Modal.module.css'
@@ -18,17 +19,29 @@ export function Modal({ open, title, children, onClose, wide, xl }: ModalProps)
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prevOverflow
document.removeEventListener('keydown', onKey)
}
}, [open, onClose])
if (!open) return null
const sizeClass = xl ? styles.modalXl : wide ? styles.modalWide : ''
return (
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={onClose}>
<div className={`${styles.modal} ${sizeClass}`} onClick={(e) => e.stopPropagation()}>
return createPortal(
<div
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}>
<h3 className={styles.title}>{title}</h3>
<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 className={styles.body}>{children}</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/ */
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebLight.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebLight.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
@@ -11,30 +9,8 @@
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebRegular.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebRegular.woff') format('woff');
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebMedium.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebMedium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src:
url('/fonts/iranyekan/IRANYekanWebBold.woff2') format('woff2'),
url('/fonts/iranyekan/IRANYekanWebBold.woff') format('woff');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@@ -27,30 +27,76 @@
.statusChip {
display: inline-flex;
align-items: center;
padding: 3px 8px;
max-width: 100%;
padding: 4px 10px;
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-weight: 600;
}
.status_draft {
background: rgba(148, 163, 184, 0.18);
color: #475569;
.statusOptionSelected {
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.status_draft,
.status_issued {
background: rgba(var(--primary-rgb) / 0.12);
color: var(--primary);
color: #0f172a;
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 {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
background: rgba(34, 197, 94, 0.14);
border-color: rgba(34, 197, 94, 0.32);
}
.status_cancelled {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.28);
}
.itemTitle {
@@ -314,6 +360,10 @@
margin-bottom: 2px;
}
.detailMeta .statusChipBtn {
margin-top: 4px;
}
.detailNotes {
margin: 0 0 14px;
font-size: 13px;
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
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 { Modal } from '../components/Modal'
import { isEmptyRichText } from '../components/RichTextEditor'
@@ -15,11 +15,12 @@ import {
} from '../services/invoiceService'
import type { Invoice, InvoiceStatus } from '../types/invoice'
import { formatIrtPrice } from '../utils/irtPrice'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.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) {
const d = new Date(value)
@@ -35,6 +36,21 @@ function statusLabel(status: InvoiceStatus) {
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() {
const { businessId = '' } = useParams()
const navigate = useNavigate()
@@ -49,7 +65,8 @@ export function BusinessInvoicesPage() {
const [total, setTotal] = useState(0)
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)
async function reload(signal?: AbortSignal) {
@@ -80,20 +97,30 @@ export function BusinessInvoicesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [businessId, page])
async function handleStatusChange(next: InvoiceStatus) {
if (!detailInvoice) return
setStatusUpdating(true)
function openStatusModal(invoice: Invoice) {
setStatusTarget(invoice)
}
async function handleStatusSelect(next: InvoiceStatus) {
if (!statusTarget || statusUpdating) return
if (next === statusTarget.status) {
setStatusTarget(null)
return
}
setStatusUpdating(next)
try {
const updated = await updateBusinessInvoiceStatus(businessId, detailInvoice.id, {
const updated = await updateBusinessInvoiceStatus(businessId, statusTarget.id, {
status: next,
})
setDetailInvoice(updated)
setInvoices((rows) => rows.map((row) => (row.id === updated.id ? updated : row)))
setDetailInvoice((current) => (current?.id === updated.id ? updated : current))
showToast('Invoice status updated.', 'success')
setStatusTarget(null)
} catch (err) {
showToast(err instanceof ApiError ? err.message : 'Unable to update status.', 'error')
} finally {
setStatusUpdating(false)
setStatusUpdating(null)
}
}
@@ -191,9 +218,15 @@ export function BusinessInvoicesPage() {
</td>
<td className={tableStyles.td}>{formatDate(invoice.issuedAt)}</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)}
</span>
</button>
</td>
<td className={tableStyles.td}>{formatIrtPrice(invoice.total ?? 0)}</td>
<td className={tableStyles.td}>
@@ -229,6 +262,30 @@ export function BusinessInvoicesPage() {
>
<Eye size={16} />
</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
type="button"
className={`${tableStyles.controlBtn} ${tableStyles.controlBtnDanger}`}
@@ -249,26 +306,15 @@ export function BusinessInvoicesPage() {
{totalPages > 1 ? (
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</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>
Page {page} / {totalPages} · 20 per page · {total} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
) : null}
@@ -287,17 +333,15 @@ export function BusinessInvoicesPage() {
<div className={styles.detailMeta}>
<div>
<span className={styles.detailLabel}>Status</span>
<select
value={detailInvoice.status}
disabled={statusUpdating}
onChange={(e) => void handleStatusChange(e.target.value as InvoiceStatus)}
<button
type="button"
className={`${styles.statusChip} ${styles.statusChipBtn} ${statusClass(detailInvoice.status)}`}
onClick={() => openStatusModal(detailInvoice)}
title="Change status"
aria-label={`Change status (${statusLabel(detailInvoice.status)})`}
>
{STATUS_OPTIONS.map((option) => (
<option key={option} value={option}>
{statusLabel(option)}
</option>
))}
</select>
{statusLabel(detailInvoice.status)}
</button>
</div>
<div>
<span className={styles.detailLabel}>Total</span>
@@ -396,6 +440,40 @@ export function BusinessInvoicesPage() {
) : null}
</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
open={!!removeTarget}
title="Remove invoice"
@@ -60,6 +60,140 @@
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 select,
.field textarea {
@@ -182,6 +316,17 @@
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 {
margin-top: 12px;
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 {
AlertTriangle,
Database,
Eraser,
FileText,
Globe,
Lock,
@@ -16,7 +18,16 @@ import {
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { ToggleSwitch } from '../components/ToggleSwitch'
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 { ListBusinessesParams } from '../services/businessService'
import { ApiError, isAbortError } from '../lib/api'
@@ -24,6 +35,8 @@ import {
addBusinessDomain,
createBusiness,
listBusinesses,
migrateBusinessFromOld,
purgeBusinessData,
removeBusiness,
setBusinessActive,
updateBusiness,
@@ -45,6 +58,7 @@ import {
updateBusinessPrimaryColor,
} from '../services/businessSettingsService'
import { flattenBusinessCategories } from '../utils/categories'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.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' })
}
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
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
product_categories: 'Product categories',
product: 'Products',
customer_categories: 'Customer categories',
customer: 'Customers',
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() {
@@ -91,12 +192,14 @@ export function BusinessesPage() {
)
const [editLoadingSettings, setEditLoadingSettings] = useState(false)
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [domainOpen, setDomainOpen] = useState(false)
const [domainBusiness, setDomainBusiness] = useState<BusinessListItem | null>(null)
const [domainId, setDomainId] = useState<number | null>(null)
const [domainHost, setDomainHost] = useState('')
const [domainSubmitting, setDomainSubmitting] = useState(false)
const [domainError, setDomainError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
@@ -108,11 +211,31 @@ export function BusinessesPage() {
const [createOwnerCell, setCreateOwnerCell] = useState('')
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
const [togglingId, setTogglingId] = 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() {
setLoading(true)
setError('')
@@ -171,8 +294,6 @@ export function BusinessesPage() {
return Math.max(1, Math.ceil(total / pageSize))
}, [data?.total, pageSize])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * pageSize + 1
@@ -206,6 +327,7 @@ export function BusinessesPage() {
setEditBusiness(b)
setEditName(b.name)
setEditPrimaryColor(normalizeBusinessPrimaryColorId(b.primaryColor))
setEditError('')
setEditOpen(true)
setEditLoadingSettings(true)
@@ -216,7 +338,7 @@ export function BusinessesPage() {
})
.catch((err) => {
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(() => {
if (!controller.signal.aborted) setEditLoadingSettings(false)
@@ -286,7 +408,7 @@ export function BusinessesPage() {
async function submitEdit() {
if (!editBusiness) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateBusiness(editBusiness.id, { name: editName })
await updateBusinessPrimaryColor(editBusiness.id, editPrimaryColor)
@@ -306,7 +428,7 @@ export function BusinessesPage() {
showToast('Business updated.', 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update business.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update business.')
} finally {
setEditSubmitting(false)
}
@@ -316,16 +438,142 @@ export function BusinessesPage() {
setDomainBusiness(b)
setDomainId(b.domainId)
setDomainHost(b.domain ?? '')
setDomainError('')
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() {
if (!domainBusiness) return
const host = domainHost.trim()
if (!host) return
setDomainSubmitting(true)
setError('')
setDomainError('')
try {
if (domainId) {
await updateBusinessDomain(domainBusiness.id, domainId, { host })
@@ -366,7 +614,7 @@ export function BusinessesPage() {
setDomainBusiness(null)
setDomainId(null)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to save domain.')
setDomainError(err instanceof ApiError ? err.message : 'Unable to save domain.')
} finally {
setDomainSubmitting(false)
}
@@ -381,6 +629,7 @@ export function BusinessesPage() {
setCreateOwnerLastName('')
setCreateOwnerCell('')
setCreateOwnerPassword('')
setCreateError('')
}
function openCreate() {
@@ -393,7 +642,7 @@ export function BusinessesPage() {
if (!ownerCellNumber) return
setCreateSubmitting(true)
setError('')
setCreateError('')
try {
await createBusiness({
name: createName.trim(),
@@ -410,7 +659,7 @@ export function BusinessesPage() {
showToast(`"${createName.trim()}" has been created.`, 'success')
await fetchList()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to create business.')
setCreateError(err instanceof ApiError ? err.message : 'Unable to create business.')
} finally {
setCreateSubmitting(false)
}
@@ -468,6 +717,13 @@ export function BusinessesPage() {
toE164CellNumber(createOwnerCell.trim()).length > 0 &&
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 (
<main className={pageStyles.content}>
<div className={pageStyles.pageHeader}>
@@ -681,6 +937,32 @@ export function BusinessesPage() {
>
<Users size={16} />
</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
type="button"
className={styles.controlBtn}
@@ -718,37 +1000,15 @@ export function BusinessesPage() {
<div className={styles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={styles.pagerBtns}>
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${styles.pageBtn} ${n === page ? styles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={styles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {pageSize} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -769,8 +1029,14 @@ export function BusinessesPage() {
onClose={() => {
setEditOpen(false)
setEditBusiness(null)
setEditError('')
}}
>
{editError ? (
<p className={styles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="edit-name">Business name</label>
<input
@@ -820,8 +1086,14 @@ export function BusinessesPage() {
setDomainOpen(false)
setDomainBusiness(null)
setDomainId(null)
setDomainError('')
}}
>
{domainError ? (
<p className={styles.alertError} role="alert">
{domainError}
</p>
) : null}
<div className={styles.field}>
<label htmlFor="domain-host">Domain</label>
<input
@@ -853,6 +1125,207 @@ export function BusinessesPage() {
</div>
</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
open={createOpen}
title="Add business"
@@ -862,6 +1335,11 @@ export function BusinessesPage() {
resetCreateForm()
}}
>
{createError ? (
<p className={styles.alertError} role="alert">
{createError}
</p>
) : null}
<div className={styles.formGrid}>
<div className={styles.field}>
<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 {
createBusinessInvoice,
getBusinessInvoice,
listInvoiceItemTemplates,
listInvoiceTemplates,
updateBusinessInvoice,
} from '../services/invoiceService'
import type { InvoiceItemTemplate, InvoiceTemplate } from '../types/invoice'
import {
buildAccountsPayload,
buildKeyPointsPayload,
buildLineItemsPayload,
draftsFromInvoice,
draftsFromInvoiceTemplate,
emptyDraftItem,
type DraftAccount,
@@ -36,7 +39,8 @@ function effectivePrice(price: number, discountedPrice: number | null | undefine
}
export function IssueInvoicePage() {
const { businessId = '' } = useParams()
const { businessId = '', invoiceId = '' } = useParams()
const isEdit = Boolean(invoiceId)
const navigate = useNavigate()
const { showToast } = useToast()
@@ -45,6 +49,7 @@ export function IssueInvoicePage() {
const [invoiceTemplates, setInvoiceTemplates] = useState<InvoiceTemplate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [locked, setLocked] = useState(false)
const [sourceTemplateId, setSourceTemplateId] = useState('')
const [invoiceTemplateId, setInvoiceTemplateId] = useState<string | undefined>()
@@ -77,6 +82,7 @@ export function IssueInvoicePage() {
async function load() {
setLoading(true)
setError('')
setLocked(false)
try {
const [biz, items, templates] = await Promise.all([
getBusiness(businessId, controller.signal),
@@ -86,6 +92,24 @@ export function IssueInvoicePage() {
setBusiness(biz)
setItemTemplates(items.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) {
if (isAbortError(err)) return
setError(err instanceof ApiError ? err.message : 'Unable to load invoice form.')
@@ -96,7 +120,7 @@ export function IssueInvoicePage() {
void load()
return () => controller.abort()
}, [businessId])
}, [businessId, invoiceId])
function resetBlankDraft() {
setSourceTemplateId('')
@@ -112,6 +136,7 @@ export function IssueInvoicePage() {
}
function applyInvoiceTemplate(templateId: string) {
if (locked) return
setSourceTemplateId(templateId)
if (!templateId) {
resetBlankDraft()
@@ -130,7 +155,8 @@ export function IssueInvoicePage() {
setFormError('')
}
async function handleCreate() {
async function handleSubmit() {
if (locked) return
setFormError('')
let items
try {
@@ -142,19 +168,38 @@ export function IssueInvoicePage() {
setSubmitting(true)
try {
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')
if (isEdit) {
await updateBusinessInvoice(businessId, invoiceId, {
items,
name: invoiceName.trim() || null,
topText: isEmptyRichText(topText) ? null : topText,
notes: notes.trim() || null,
invoiceTemplateId: invoiceTemplateId ?? null,
keyPoints: buildKeyPointsPayload(keyPoints),
accounts: buildAccountsPayload(accounts),
})
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)
} 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 {
setSubmitting(false)
}
@@ -168,9 +213,13 @@ export function IssueInvoicePage() {
<ArrowLeft size={16} />
Back to invoices
</Link>
<h2 className={pageStyles.pageTitle}>Issue invoice · {businessName}</h2>
<h2 className={pageStyles.pageTitle}>
{isEdit ? 'Edit invoice' : 'Issue invoice'} · {businessName}
</h2>
<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>
</div>
</div>
@@ -178,24 +227,36 @@ export function IssueInvoicePage() {
{error ? <p className={styles.alertError}>{error}</p> : null}
{loading ? <p className={tableStyles.meta}>Loading</p> : null}
{!loading ? (
{!loading && !locked ? (
<section className={settingsStyles.section}>
<div className={styles.metaGrid}>
<div className={tableStyles.field}>
<label htmlFor="source-template">Start from template</label>
<select
id="source-template"
value={sourceTemplateId}
onChange={(e) => applyInvoiceTemplate(e.target.value)}
>
<option value="">Blank invoice</option>
{invoiceTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} · {template.items.length} items
</option>
))}
</select>
{!isEdit ? (
<div className={styles.metaGrid}>
<div className={tableStyles.field}>
<label htmlFor="source-template">Start from template</label>
<select
id="source-template"
value={sourceTemplateId}
onChange={(e) => applyInvoiceTemplate(e.target.value)}
>
<option value="">Blank invoice</option>
{invoiceTemplates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} · {template.items.length} items
</option>
))}
</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 className={tableStyles.field}>
<label htmlFor="invoice-name">Name (optional)</label>
<input
@@ -205,9 +266,9 @@ export function IssueInvoicePage() {
placeholder="e.g. Website redesign package"
/>
</div>
</div>
)}
{invoiceTemplates.length === 0 ? (
{!isEdit && invoiceTemplates.length === 0 ? (
<p className={styles.templateHint}>
No invoice templates yet. Manage them in{' '}
<Link to="/settings">Settings Invoice templates</Link>, or fill a blank invoice
@@ -265,14 +326,32 @@ export function IssueInvoicePage() {
<button
type="button"
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
onClick={() => void handleCreate()}
onClick={() => void handleSubmit()}
disabled={submitting}
>
{submitting ? 'Issuing…' : 'Issue invoice'}
{submitting
? isEdit
? 'Saving…'
: 'Issuing…'
: isEdit
? 'Save changes'
: 'Issue invoice'}
</button>
</div>
</section>
) : 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>
)
}
@@ -27,6 +27,44 @@
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 {
display: inline-flex;
align-items: center;
@@ -66,6 +104,26 @@
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 {
margin: 0 0 4px;
font-size: 22px;
+59 -51
View File
@@ -23,6 +23,7 @@ import {
updateUserRole,
} from '../services/userService'
import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.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' })
}
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) {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
return name || '—'
@@ -80,17 +73,20 @@ export function UsersPage() {
const [editEmail, setEditEmail] = useState('')
const [editCell, setEditCell] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [resetOpen, setResetOpen] = useState(false)
const [resetUser, setResetUser] = useState<UserListItem | null>(null)
const [resetPassword, setResetPassword] = useState('')
const [resetConfirm, setResetConfirm] = useState('')
const [resetSubmitting, setResetSubmitting] = useState(false)
const [resetError, setResetError] = useState('')
const [messageOpen, setMessageOpen] = useState(false)
const [messageUser, setMessageUser] = useState<UserListItem | null>(null)
const [messageText, setMessageText] = useState('')
const [messageSubmitting, setMessageSubmitting] = useState(false)
const [messageError, setMessageError] = useState('')
const [removeTarget, setRemoveTarget] = useState<UserListItem | null>(null)
@@ -99,6 +95,7 @@ export function UsersPage() {
const [selectedRoleSlug, setSelectedRoleSlug] = useState('')
const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('')
const [roleSubmitting, setRoleSubmitting] = useState(false)
const [roleError, setRoleError] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [createFirstName, setCreateFirstName] = useState('')
@@ -107,6 +104,7 @@ export function UsersPage() {
const [createPassword, setCreatePassword] = useState('')
const [createEmail, setCreateEmail] = useState('')
const [createSubmitting, setCreateSubmitting] = useState(false)
const [createError, setCreateError] = useState('')
const [listVersion, setListVersion] = useState(0)
const businessFilter = useMemo(() => {
@@ -182,8 +180,6 @@ export function UsersPage() {
return Math.max(1, Math.ceil(total / PAGE_SIZE))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -222,6 +218,7 @@ export function UsersPage() {
setCreateCell('')
setCreatePassword('')
setCreateEmail('')
setCreateError('')
}
function openCreate() {
@@ -235,7 +232,7 @@ export function UsersPage() {
if (!cellNumber) return
setCreateSubmitting(true)
setError('')
setCreateError('')
try {
await createUser({
businessId: businessFilter.businessId,
@@ -251,7 +248,7 @@ export function UsersPage() {
setPage(1)
setListVersion((v) => v + 1)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to add user.')
setCreateError(err instanceof ApiError ? err.message : 'Unable to add user.')
} finally {
setCreateSubmitting(false)
}
@@ -266,6 +263,7 @@ export function UsersPage() {
setRoleUser(user)
setSelectedRoleSlug(user.roleSlug ?? '')
setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '')
setRoleError('')
setRoleOpen(true)
}
@@ -280,7 +278,7 @@ export function UsersPage() {
async function submitRoleChange() {
if (!roleUser || !selectedRoleSlug || !canSaveRole) return
setRoleSubmitting(true)
setError('')
setRoleError('')
try {
await updateUserRole(roleUser.id, selectedRoleSlug)
@@ -340,7 +338,7 @@ export function UsersPage() {
setRoleUser(null)
setSelectedTeamRoleSlug('')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update role.')
setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.')
} finally {
setRoleSubmitting(false)
}
@@ -352,13 +350,14 @@ export function UsersPage() {
setEditLastName(user.lastName ?? '')
setEditEmail('')
setEditCell(user.cellNumber)
setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editUser) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateUser(editUser.id, {
firstName: editFirstName.trim(),
@@ -386,7 +385,7 @@ export function UsersPage() {
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update user.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update user.')
} finally {
setEditSubmitting(false)
}
@@ -396,24 +395,25 @@ export function UsersPage() {
setResetUser(user)
setResetPassword('')
setResetConfirm('')
setResetError('')
setResetOpen(true)
}
async function submitResetPassword() {
if (!resetUser) return
if (resetPassword !== resetConfirm) {
setError('Passwords do not match.')
setResetError('Passwords do not match.')
return
}
setResetSubmitting(true)
setError('')
setResetError('')
try {
await adminResetUserPassword(resetUser.id, resetPassword)
setResetOpen(false)
setResetUser(null)
showToast(`Password reset for "${displayName(resetUser)}".`, 'success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to reset password.')
setResetError(err instanceof ApiError ? err.message : 'Unable to reset password.')
} finally {
setResetSubmitting(false)
}
@@ -422,20 +422,21 @@ export function UsersPage() {
function openSendMessage(user: UserListItem) {
setMessageUser(user)
setMessageText('')
setMessageError('')
setMessageOpen(true)
}
async function submitSendMessage() {
if (!messageUser) return
setMessageSubmitting(true)
setError('')
setMessageError('')
try {
const result = await sendUserMessage(messageUser.id, messageText.trim())
setMessageOpen(false)
setMessageUser(null)
showToast(result.message, result.enabled ? 'success' : 'info')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to send message.')
setMessageError(err instanceof ApiError ? err.message : 'Unable to send message.')
} finally {
setMessageSubmitting(false)
}
@@ -693,37 +694,15 @@ export function UsersPage() {
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -734,8 +713,14 @@ export function UsersPage() {
onClose={() => {
setEditOpen(false)
setEditUser(null)
setEditError('')
}}
>
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.formGrid}>
<div className={tableStyles.field}>
<label htmlFor="edit-first-name">First name</label>
@@ -808,8 +793,14 @@ export function UsersPage() {
onClose={() => {
setResetOpen(false)
setResetUser(null)
setResetError('')
}}
>
{resetError ? (
<p className={tableStyles.alertError} role="alert">
{resetError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Set a new password for {resetUser ? displayName(resetUser) : 'user'}.
</p>
@@ -863,8 +854,14 @@ export function UsersPage() {
onClose={() => {
setMessageOpen(false)
setMessageUser(null)
setMessageError('')
}}
>
{messageError ? (
<p className={tableStyles.alertError} role="alert">
{messageError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Send an SMS to {messageUser ? formatCellForDisplay(messageUser.cellNumber) : 'user'}.
</p>
@@ -906,8 +903,14 @@ export function UsersPage() {
setRoleOpen(false)
setRoleUser(null)
setSelectedTeamRoleSlug('')
setRoleError('')
}}
>
{roleError ? (
<p className={tableStyles.alertError} role="alert">
{roleError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Select a role for {roleUser ? displayName(roleUser) : 'user'}.
{businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''}
@@ -1021,6 +1024,11 @@ export function UsersPage() {
resetCreateForm()
}}
>
{createError ? (
<p className={tableStyles.alertError} role="alert">
{createError}
</p>
) : null}
<p className={tableStyles.meta} style={{ marginBottom: 12 }}>
Creates a new account or links an existing user as a customer of this business.
Password is required only for new accounts.
+19 -42
View File
@@ -26,20 +26,13 @@ import {
updateDomain,
} from '../services/domainService'
import { useToast } from '../context/ToastContext'
import { Pagination } from '@meshkee/dashboard-ui'
import pageStyles from '../components/PageContent.module.css'
import tableStyles from './BusinessesPage.module.css'
import styles from './WebsitesPage.module.css'
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) {
if (!expiresAt) return null
const diff = new Date(expiresAt).getTime() - Date.now()
@@ -103,6 +96,7 @@ export function WebsitesPage() {
const [editHost, setEditHost] = useState('')
const [editExpiresAt, setEditExpiresAt] = useState('')
const [editSubmitting, setEditSubmitting] = useState(false)
const [editError, setEditError] = useState('')
const [removeTarget, setRemoveTarget] = useState<DomainListItem | 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))
}, [data?.total])
const pageNumbers = useMemo(() => buildPageNumbers(page, totalPages), [page, totalPages])
const showingFrom = useMemo(() => {
if (!data) return 0
return (page - 1) * PAGE_SIZE + 1
@@ -169,13 +161,14 @@ export function WebsitesPage() {
setEditDomain(domain)
setEditHost(domain.host)
setEditExpiresAt(domain.expiresAt ? domain.expiresAt.slice(0, 10) : '')
setEditError('')
setEditOpen(true)
}
async function submitEdit() {
if (!editDomain) return
setEditSubmitting(true)
setError('')
setEditError('')
try {
await updateDomain(editDomain.id, {
host: editHost.trim(),
@@ -200,7 +193,7 @@ export function WebsitesPage() {
}
})
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Unable to update domain.')
setEditError(err instanceof ApiError ? err.message : 'Unable to update domain.')
} finally {
setEditSubmitting(false)
}
@@ -575,37 +568,15 @@ export function WebsitesPage() {
<div className={tableStyles.pagination}>
<div>
Page {page} / {totalPages}
</div>
<div className={tableStyles.pagerBtns}>
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || loading}
>
Prev
</button>
{pageNumbers.map((n) => (
<button
key={n}
type="button"
className={`${tableStyles.pageBtn} ${n === page ? tableStyles.pageBtnActive : ''}`}
onClick={() => setPage(n)}
disabled={loading}
>
{n}
</button>
))}
<button
type="button"
className={tableStyles.pageBtn}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
Next
</button>
Page {page} / {totalPages} · {PAGE_SIZE} per page · {data?.total ?? 0} total
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
disabled={loading}
variant="inline"
/>
</div>
</div>
</div>
@@ -616,8 +587,14 @@ export function WebsitesPage() {
onClose={() => {
setEditOpen(false)
setEditDomain(null)
setEditError('')
}}
>
{editError ? (
<p className={tableStyles.alertError} role="alert">
{editError}
</p>
) : null}
<div className={tableStyles.field}>
<label htmlFor="edit-domain-host">Domain name</label>
<input
@@ -1,5 +1,13 @@
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 {
page?: number
@@ -12,6 +20,7 @@ export interface ListBusinessesParams {
export interface UpdateBusinessPayload {
name?: string
slug?: string
oldBusinessId?: number | null
}
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 {
id: string
name: string
nameFa: string | null
slug: string
isActive: boolean
oldBusinessId: string | null
}
export async function getBusiness(businessId: string, signal?: AbortSignal) {
@@ -12,6 +12,7 @@ import type {
InvoicesListResponse,
PublicInvoice,
UpdateInvoiceItemTemplatePayload,
UpdateInvoicePayload,
UpdateInvoiceTemplatePayload,
} 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(
businessId: string,
invoiceId: string,
@@ -148,3 +161,11 @@ export function getPublicInvoice(publicId: string, signal?: AbortSignal) {
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