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