+
{sections.map((section) => (
-
+
+
+
))}
diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css
index 330bf58..f225fa7 100644
--- a/apps/customer/src/pages/LoginPage.module.css
+++ b/apps/customer/src/pages/LoginPage.module.css
@@ -15,12 +15,12 @@
width: 100%;
max-width: 420px;
padding: 36px 32px 32px;
- background: rgba(255, 255, 255, 0.75);
+ background: var(--glass-bg);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
- box-shadow: 0 20px 60px rgba(31, 38, 135, 0.12);
+ box-shadow: var(--glass-shadow);
}
.brand {
@@ -148,8 +148,8 @@
line-height: 1.4;
font-family: inherit;
color: var(--text-primary);
- background: rgba(255, 255, 255, 0.8);
- border: 1px solid rgba(148, 163, 184, 0.35);
+ background: var(--surface);
+ border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
diff --git a/apps/customer/src/pages/MyProductDetailsPage.module.css b/apps/customer/src/pages/MyProductDetailsPage.module.css
new file mode 100644
index 0000000..b206e36
--- /dev/null
+++ b/apps/customer/src/pages/MyProductDetailsPage.module.css
@@ -0,0 +1,327 @@
+.headerRow {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 8px;
+}
+
+.editBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: var(--field-height);
+ padding: var(--field-padding-y) 14px;
+ border-radius: var(--radius-sm);
+ font-size: var(--field-font-size);
+ font-weight: 600;
+ font-family: var(--font-ui);
+ color: #fff;
+ background: var(--primary);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.editBtn:hover {
+ filter: brightness(1.05);
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: minmax(0, 320px) 1fr;
+ gap: 28px;
+ background: var(--glass-bg);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+ box-shadow: var(--glass-shadow);
+ padding: 24px;
+}
+
+.gallery {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 320px;
+}
+
+.mainImage {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ overflow: hidden;
+ background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
+}
+
+.galleryThumbs {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.galleryThumb {
+ aspect-ratio: 1 / 1;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(148, 163, 184, 0.15);
+ overflow: hidden;
+ background: var(--card-media-bg, rgba(148, 163, 184, 0.12));
+}
+
+.galleryThumb img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.imagePlaceholder {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(148, 163, 184, 0.75);
+}
+
+.badge {
+ position: absolute;
+ top: 10px;
+ inset-inline-start: 10px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 600;
+ border-radius: 50px;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ z-index: 1;
+}
+
+.badge[data-status='draft'] {
+ color: #fff;
+ background: linear-gradient(
+ 145deg,
+ rgba(251, 191, 36, 0.55) 0%,
+ rgba(245, 158, 11, 0.32) 100%
+ );
+}
+
+.badge[data-status='published'] {
+ color: #047857;
+ background: linear-gradient(
+ 145deg,
+ rgba(167, 243, 208, 0.55) 0%,
+ rgba(52, 211, 153, 0.28) 100%
+ );
+}
+
+.badge[data-status='archived'] {
+ color: #e2e8f0;
+ background: linear-gradient(
+ 145deg,
+ rgba(148, 163, 184, 0.45) 0%,
+ rgba(100, 116, 139, 0.28) 100%
+ );
+}
+
+.promotedBadge {
+ position: absolute;
+ top: 10px;
+ inset-inline-end: 10px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 600;
+ color: #fff;
+ border-radius: 50px;
+ background: linear-gradient(
+ 145deg,
+ rgba(99, 102, 241, 0.7) 0%,
+ rgba(168, 85, 247, 0.45) 100%
+ );
+ z-index: 1;
+}
+
+.details {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+.categoryChip {
+ align-self: flex-start;
+ padding: 4px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ background: rgba(148, 163, 184, 0.15);
+ border-radius: 50px;
+}
+
+.title {
+ margin: 0;
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.3;
+ font-family: var(--font-ui);
+}
+
+.secondary {
+ margin: 0;
+ font-size: 14px;
+ color: var(--text-secondary);
+ font-family: var(--font-ui);
+}
+
+.summaryRow {
+ display: flex;
+ align-items: center;
+ gap: 12px 16px;
+ flex-wrap: wrap;
+ direction: ltr;
+}
+
+.price {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ white-space: nowrap;
+ flex-shrink: 0;
+ font-variant-numeric: tabular-nums;
+}
+
+.summaryMeta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px 14px;
+ margin-inline-start: auto;
+ min-width: 0;
+}
+
+.metaItem {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ font-family: var(--font-ui);
+ min-width: 0;
+}
+
+.metaGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
+ gap: 12px 16px;
+ margin: 4px 0 0;
+}
+
+.metaGrid dt {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
+.metaGrid dd {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+}
+
+.location {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.section {
+ padding-top: 8px;
+ border-top: 1px solid rgba(148, 163, 184, 0.15);
+}
+
+.section h3 {
+ margin: 0 0 8px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.prose {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.65;
+ color: var(--text-primary);
+ white-space: pre-wrap;
+ font-family: var(--font-ui);
+}
+
+.techGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin: 0;
+}
+
+.techGrid dt {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
+.techGrid dd {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-primary);
+ font-family: var(--font-ui);
+}
+
+.status,
+.error {
+ margin: 24px 0;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+.error {
+ color: #ef4444;
+}
+
+.backLink {
+ color: var(--primary);
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.backLink:hover {
+ text-decoration: underline;
+}
+
+@media (max-width: 800px) {
+ .headerRow {
+ flex-direction: column;
+ }
+
+ .layout {
+ grid-template-columns: 1fr;
+ padding: 16px;
+ }
+
+ .gallery {
+ max-width: none;
+ }
+}
diff --git a/apps/customer/src/pages/MyProductDetailsPage.tsx b/apps/customer/src/pages/MyProductDetailsPage.tsx
new file mode 100644
index 0000000..c17850d
--- /dev/null
+++ b/apps/customer/src/pages/MyProductDetailsPage.tsx
@@ -0,0 +1,285 @@
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { ImageOff, MapPin, Pencil } from 'lucide-react'
+import { Breadcrumbs, useLocale, useToast } from '@meshkee/dashboard-ui'
+import { useT } from '../i18n/useT'
+import { ApiError, isAbortError } from '../lib/api'
+import {
+ getMyUserProduct,
+ getMyUserProductCategoryTechnicalForm,
+ type TechnicalFormField,
+ type UserProductDetail,
+ type UserProductTechnicalValueInput,
+} from '../services/userProductsService'
+import { formatIrtPrice } from '../utils/irtPrice'
+import pageStyles from '../components/PageContent.module.css'
+import styles from './MyProductDetailsPage.module.css'
+
+function formatTechnicalValue(
+ field: TechnicalFormField,
+ value: UserProductTechnicalValueInput | undefined,
+): string {
+ if (!value) return '—'
+ if (value.textValue != null && value.textValue.trim()) return value.textValue
+ if (value.optionId) {
+ return field.options.find((option) => option.id === value.optionId)?.label ?? value.optionId
+ }
+ if (value.optionIds?.length) {
+ return value.optionIds
+ .map(
+ (optionId) =>
+ field.options.find((option) => option.id === optionId)?.label ?? optionId,
+ )
+ .join(', ')
+ }
+ return '—'
+}
+
+export function MyProductDetailsPage() {
+ const { id } = useParams<{ id: string }>()
+ const t = useT()
+ const { locale } = useLocale()
+ const navigate = useNavigate()
+ const { showToast } = useToast()
+ const isFa = locale === 'fa'
+
+ const [product, setProduct] = useState
(null)
+ const [technicalFields, setTechnicalFields] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!id) return
+
+ const controller = new AbortController()
+
+ async function load() {
+ setLoading(true)
+ setError('')
+ try {
+ const response = await getMyUserProduct(id!, controller.signal)
+ if (controller.signal.aborted) return
+ setProduct(response.product)
+
+ if (response.product.categoryId) {
+ const formResponse = await getMyUserProductCategoryTechnicalForm(
+ response.product.categoryId,
+ controller.signal,
+ )
+ if (controller.signal.aborted) return
+ setTechnicalFields(formResponse.form?.fields ?? [])
+ } else {
+ setTechnicalFields([])
+ }
+ } catch (err) {
+ if (isAbortError(err) || controller.signal.aborted) return
+ const message =
+ err instanceof ApiError ? err.message : t('myProducts.error.loadDetail')
+ setError(message)
+ setProduct(null)
+ showToast(message, 'error')
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ }
+
+ void load()
+ return () => controller.abort()
+ }, [id, showToast, t])
+
+ const technicalRows = useMemo(() => {
+ if (!product) return []
+ const byField = new Map(
+ (product.technicalValues ?? []).map((item) => [item.fieldId, item]),
+ )
+ return technicalFields.map((field) => ({
+ id: field.id,
+ label: field.label,
+ value: formatTechnicalValue(field, byField.get(field.id)),
+ }))
+ }, [product, technicalFields])
+
+ if (loading) {
+ return (
+
+ {t('myProducts.loading')}
+
+ )
+ }
+
+ if (error || !product) {
+ return (
+
+ {error || t('myProducts.error.loadDetail')}
+
+ {t('myProducts.backToList')}
+
+
+ )
+ }
+
+ const titleFa = product.titleFa || product.title
+ const titleEn = product.titleEn?.trim() || ''
+ const title = isFa ? titleFa : titleEn || titleFa
+ const secondary = isFa ? titleEn : titleEn ? titleFa : ''
+ const city = isFa ? product.cityNameFa || product.cityName : product.cityName
+ const country = isFa
+ ? product.countryNameFa || product.countryName
+ : product.countryName
+ const category = isFa
+ ? product.categoryNameFa || product.categoryName
+ : product.categoryName
+ const location = [city, country].filter(Boolean).join(isFa ? '، ' : ', ')
+ const statusLabel =
+ product.status === 'published'
+ ? t('myProducts.status.published')
+ : product.status === 'archived'
+ ? t('myProducts.status.archived')
+ : product.status === 'rejected'
+ ? t('myProducts.status.rejected')
+ : t('myProducts.status.pending')
+ const conditionLabel = product.condition
+ ? t(`myProducts.condition.${product.condition}`)
+ : '—'
+ const imageSrc = product.imageUrl?.trim() || ''
+ const galleryImages = (product.images ?? [])
+ .map((item) => item.url?.trim())
+ .filter((url): url is string => Boolean(url))
+ const currency = (product.priceCurrency || 'IRT').toUpperCase()
+ const priceLabel =
+ product.price == null
+ ? t('myProducts.priceUnavailable')
+ : currency === 'IRT'
+ ? formatIrtPrice(product.price)
+ : `${Number(product.price).toLocaleString('en-US')} ${currency}`
+
+ return (
+
+
+
+
+
+
{t('myProducts.detailsTitle')}
+
{t('myProducts.detailsSubtitle')}
+
+
+
+
+
+
+
+ {imageSrc ? (
+

+ ) : (
+
+
+
+ )}
+
+ {statusLabel}
+
+ {product.promoted ? (
+
{t('myProducts.promoted')}
+ ) : null}
+
+ {galleryImages.length > 0 ? (
+
+ {galleryImages.map((url) => (
+
+

+
+ ))}
+
+ ) : null}
+
+
+
+ {category ?
{category} : null}
+
{title}
+ {secondary ?
{secondary}
: null}
+
+
+
+ {priceLabel}
+
+
+
+
+ {location || '—'}
+
+
+
+
+
+
+
- {t('myProducts.fields.country')}
+ - {country || '—'}
+
+
+
- {t('myProducts.fields.city')}
+ - {city || '—'}
+
+
+
- {t('myProducts.fields.condition')}
+ - {conditionLabel}
+
+ {product.priceByExpert ? (
+
+
- {t('myProducts.fields.priceByExpert')}
+ - {t('myProducts.yes')}
+
+ ) : null}
+
+
+ {product.description ? (
+
+ {t('myProducts.fields.description')}
+ {product.description}
+
+ ) : null}
+
+ {product.deliveryNote ? (
+
+ {t('myProducts.fields.deliveryNote')}
+ {product.deliveryNote}
+
+ ) : null}
+
+ {product.technicalNotes ? (
+
+ {t('myProducts.fields.technicalNotes')}
+ {product.technicalNotes}
+
+ ) : null}
+
+ {technicalRows.length > 0 ? (
+
+ {t('myProducts.technical.categoryForm')}
+
+ {technicalRows.map((row) => (
+
+
- {row.label}
+ - {row.value}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/apps/customer/src/pages/MyProductsPage.module.css b/apps/customer/src/pages/MyProductsPage.module.css
new file mode 100644
index 0000000..905ae7c
--- /dev/null
+++ b/apps/customer/src/pages/MyProductsPage.module.css
@@ -0,0 +1,103 @@
+.error {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ font-size: 13px;
+ color: #b91c1c;
+ background: rgba(254, 226, 226, 0.85);
+ border: 1px solid rgba(248, 113, 113, 0.45);
+ border-radius: var(--radius-sm);
+}
+
+.status {
+ margin: 8px 0 16px;
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+.empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 48px 24px;
+ text-align: center;
+ font-size: 14px;
+ color: var(--text-secondary);
+ background: var(--glass-bg);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius);
+}
+
+.emptyLink {
+ margin-top: 4px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--primary);
+ text-decoration: none;
+}
+
+.emptyLink:hover {
+ text-decoration: underline;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+
+.fabDock {
+ position: fixed;
+ inset-inline-end: 32px;
+ inset-inline-start: auto;
+ bottom: 32px;
+ z-index: 110;
+}
+
+.addFab {
+ width: 56px;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ color: white;
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
+ box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.addFab:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
+}
+
+@media (min-width: 640px) {
+ .grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (min-width: 1024px) {
+ .grid {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+@media (min-width: 1280px) {
+ .grid {
+ grid-template-columns: repeat(6, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .fabDock {
+ inset-inline-end: 20px;
+ bottom: 20px;
+ }
+
+ .addFab {
+ width: 52px;
+ height: 52px;
+ }
+}
diff --git a/apps/customer/src/pages/MyProductsPage.tsx b/apps/customer/src/pages/MyProductsPage.tsx
new file mode 100644
index 0000000..407bcf3
--- /dev/null
+++ b/apps/customer/src/pages/MyProductsPage.tsx
@@ -0,0 +1,190 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { Package, Plus } from 'lucide-react'
+import { Breadcrumbs, Pagination, useToast } from '@meshkee/dashboard-ui'
+import { UserProductCard } from '../components/UserProductCard'
+import { useT } from '../i18n/useT'
+import { ApiError, isAbortError } from '../lib/api'
+import {
+ deleteMyUserProduct,
+ listMyUserProducts,
+ promoteMyUserProduct,
+ type MyUserProductsListResponse,
+} from '../services/userProductsService'
+import pageStyles from '../components/PageContent.module.css'
+import styles from './MyProductsPage.module.css'
+
+const PAGE_SIZE = 24
+
+export function MyProductsPage() {
+ const t = useT()
+ const navigate = useNavigate()
+ const { showToast } = useToast()
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [page, setPage] = useState(1)
+ const [busyId, setBusyId] = useState(null)
+ const [busyAction, setBusyAction] = useState<'remove' | 'promote' | null>(null)
+
+ useEffect(() => {
+ const controller = new AbortController()
+
+ async function load() {
+ setLoading(true)
+ setError('')
+ try {
+ const response = await listMyUserProducts(
+ { page, pageSize: PAGE_SIZE },
+ controller.signal,
+ )
+ if (controller.signal.aborted) return
+ setData(response)
+ } catch (err) {
+ if (isAbortError(err) || controller.signal.aborted) return
+ setError(err instanceof ApiError ? err.message : t('myProducts.error.load'))
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ }
+
+ void load()
+ return () => controller.abort()
+ }, [page, t])
+
+ const products = data?.items ?? []
+ const total = data?.total ?? 0
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
+
+ function handleEdit(id: string) {
+ navigate(`/my-products/${id}/edit`)
+ }
+
+ async function handleRemove(id: string) {
+ if (!window.confirm(t('myProducts.removeConfirm'))) return
+
+ setBusyId(id)
+ setBusyAction('remove')
+ try {
+ await deleteMyUserProduct(id)
+ setData((prev) =>
+ prev
+ ? {
+ ...prev,
+ items: prev.items.filter((item) => item.id !== id),
+ total: Math.max(0, prev.total - 1),
+ }
+ : prev,
+ )
+ showToast(t('myProducts.removeSuccess'), 'success')
+ } catch (err) {
+ showToast(
+ err instanceof ApiError ? err.message : t('myProducts.error.remove'),
+ 'error',
+ )
+ } finally {
+ setBusyId(null)
+ setBusyAction(null)
+ }
+ }
+
+ async function handlePromote(id: string) {
+ setBusyId(id)
+ setBusyAction('promote')
+ try {
+ const response = await promoteMyUserProduct(id)
+ setData((prev) =>
+ prev
+ ? {
+ ...prev,
+ items: prev.items.map((item) =>
+ item.id === id ? { ...item, ...response.product } : item,
+ ),
+ }
+ : prev,
+ )
+ showToast(t('myProducts.promoteSuccess'), 'success')
+ } catch (err) {
+ showToast(
+ err instanceof ApiError ? err.message : t('myProducts.error.promote'),
+ 'error',
+ )
+ } finally {
+ setBusyId(null)
+ setBusyAction(null)
+ }
+ }
+
+ return (
+
+
+
+
+
+
{t('myProducts.title')}
+
{t('myProducts.subtitle')}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {loading ? {t('myProducts.loading')}
: null}
+
+ {!loading && !error && products.length === 0 ? (
+
+
+
{t('myProducts.empty')}
+
+ {t('myProducts.add')}
+
+
+ ) : null}
+
+ {!loading && products.length > 0 ? (
+ <>
+
+ {products.map((product) => (
+
+ ))}
+
+ {totalPages > 1 ? (
+
+ ) : null}
+ >
+ ) : null}
+
+
+
+ )
+}
diff --git a/apps/customer/src/pages/OrdersPage.module.css b/apps/customer/src/pages/OrdersPage.module.css
index a1404a6..21c2814 100644
--- a/apps/customer/src/pages/OrdersPage.module.css
+++ b/apps/customer/src/pages/OrdersPage.module.css
@@ -80,7 +80,7 @@
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
- background: rgba(255, 255, 255, 0.35);
+ background: var(--elevated-surface);
}
.td {
@@ -282,7 +282,7 @@
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.25);
- background: rgba(255, 255, 255, 0.35);
+ background: var(--elevated-surface);
color: var(--text-secondary);
font-weight: 700;
font-size: 12px;
diff --git a/apps/customer/src/pages/checkout/CheckoutSteps.module.css b/apps/customer/src/pages/checkout/CheckoutSteps.module.css
index 9c8aab3..10423f5 100644
--- a/apps/customer/src/pages/checkout/CheckoutSteps.module.css
+++ b/apps/customer/src/pages/checkout/CheckoutSteps.module.css
@@ -125,7 +125,7 @@
align-items: center;
gap: 14px;
padding: 12px 14px;
- background: rgba(255, 255, 255, 0.6);
+ background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: var(--radius-sm);
}
@@ -299,7 +299,7 @@
padding: 16px 12px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.5);
+ background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: center;
@@ -345,7 +345,7 @@
padding: 12px 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.5);
+ background: var(--surface);
cursor: pointer;
text-align: left;
transition: border-color 0.2s, background 0.2s;
@@ -435,7 +435,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
- background: rgba(255, 255, 255, 0.8);
+ background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
@@ -482,7 +482,7 @@
padding: 14px;
border: 2px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.5);
+ background: var(--surface);
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
text-align: right;
@@ -538,7 +538,7 @@
padding: 12px 14px;
border: 1.5px solid rgba(148, 163, 184, 0.3);
border-radius: var(--radius-sm);
- background: rgba(255, 255, 255, 0.65);
+ background: var(--surface);
cursor: pointer;
text-align: right;
font-size: 13px;
@@ -765,7 +765,7 @@
font-size: var(--field-font-size);
font-family: inherit;
color: var(--text-primary);
- background: rgba(255, 255, 255, 0.8);
+ background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
diff --git a/apps/customer/src/services/citiesService.ts b/apps/customer/src/services/citiesService.ts
index ce80a87..551f94d 100644
--- a/apps/customer/src/services/citiesService.ts
+++ b/apps/customer/src/services/citiesService.ts
@@ -3,14 +3,33 @@ import type { CityOption } from '@meshkee/dashboard-ui'
export type { CityOption }
-export async function listIranProvinces(signal?: AbortSignal) {
+export async function listCountries(signal?: AbortSignal) {
+ const data = await apiRequest<{ items: CityOption[] }>('/cities?level=country', { signal })
+ return data.items
+}
+
+/** Cities under a country (direct + via provinces). Province is optional in the tree. */
+export async function listCitiesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
- '/cities?level=province&parentSlug=iran',
+ `/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
{ signal },
)
return data.items
}
+export async function listProvincesByCountrySlug(parentSlug: string, signal?: AbortSignal) {
+ const data = await apiRequest<{ items: CityOption[] }>(
+ `/cities?level=province&parentSlug=${encodeURIComponent(parentSlug)}`,
+ { signal },
+ )
+ return data.items
+}
+
+/** @deprecated Prefer listProvincesByCountrySlug('iran') */
+export async function listIranProvinces(signal?: AbortSignal) {
+ return listProvincesByCountrySlug('iran', signal)
+}
+
export async function listCitiesByProvinceSlug(parentSlug: string, signal?: AbortSignal) {
const data = await apiRequest<{ items: CityOption[] }>(
`/cities?level=city&parentSlug=${encodeURIComponent(parentSlug)}`,
diff --git a/apps/customer/src/services/mediaService.ts b/apps/customer/src/services/mediaService.ts
new file mode 100644
index 0000000..7754a6f
--- /dev/null
+++ b/apps/customer/src/services/mediaService.ts
@@ -0,0 +1,155 @@
+import {
+ clearTokens,
+ getAccessToken,
+ setTokens,
+} from '../lib/api'
+import { getActiveBusinessId } from '../lib/businessContext'
+import { ensureJpegUploadFile, ensureUploadFile } from '../utils/imageUpload'
+
+const API_BASE_URL =
+ import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
+
+export interface MediaItem {
+ id: string
+ publicUrl: string
+ fileName: string
+ originalFileName: string
+ mimeType: string
+ width: number | null
+ height: number | null
+}
+
+function isDataUrl(value: string) {
+ return value.startsWith('data:')
+}
+
+function businessMediaPath() {
+ const businessId = getActiveBusinessId()
+ if (!businessId) {
+ throw new Error('No active business selected. Please sign in again.')
+ }
+ return `/businesses/${businessId}/my-user-products/media`
+}
+
+async function refreshAccessToken() {
+ const refreshToken = localStorage.getItem('meshkee_customer_refresh_token')
+ if (!refreshToken) return false
+
+ const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ refreshToken }),
+ })
+
+ if (!response.ok) {
+ clearTokens()
+ return false
+ }
+
+ const data = await response.json()
+ setTokens(data.accessToken, data.refreshToken)
+ return true
+}
+
+function parseUploadError(payload: unknown, status: number) {
+ if (payload && typeof payload === 'object') {
+ const message = (payload as { message?: string | string[] }).message
+ if (Array.isArray(message)) {
+ return message.join(', ')
+ }
+ if (typeof message === 'string' && message) {
+ return message
+ }
+ }
+
+ return `Upload failed with status ${status}`
+}
+
+export async function uploadMyUserProductMedia(
+ files: File[],
+ signal?: AbortSignal,
+): Promise {
+ if (!files.length) return []
+
+ const send = async () => {
+ const formData = new FormData()
+ files.forEach((file) => formData.append('files', file))
+
+ const headers = new Headers()
+ const accessToken = getAccessToken()
+ if (accessToken) {
+ headers.set('Authorization', `Bearer ${accessToken}`)
+ }
+
+ return fetch(`${API_BASE_URL}${businessMediaPath()}`, {
+ method: 'POST',
+ headers,
+ body: formData,
+ signal,
+ })
+ }
+
+ let response = await send()
+
+ if (response.status === 401) {
+ const refreshed = await refreshAccessToken()
+ if (refreshed) {
+ response = await send()
+ }
+ }
+
+ const payload = await response.json().catch(() => null)
+ if (!response.ok) {
+ throw new Error(parseUploadError(payload, response.status))
+ }
+
+ return (payload.items as Array>).map((item) => ({
+ id: String(item.id),
+ publicUrl: String(item.publicUrl),
+ fileName: String(item.fileName),
+ originalFileName: String(item.originalFileName),
+ mimeType: String(item.mimeType),
+ width: typeof item.width === 'number' ? item.width : null,
+ height: typeof item.height === 'number' ? item.height : null,
+ }))
+}
+
+export async function resolveDataUrlToMediaId(
+ value: string | null,
+ filename: string,
+ existingMediaId?: string | null,
+): Promise {
+ if (!value) return null
+ if (!isDataUrl(value)) {
+ return existingMediaId ?? null
+ }
+
+ const file = filename.toLowerCase().endsWith('.png')
+ ? await ensureUploadFile(value, filename)
+ : await ensureJpegUploadFile(value, filename)
+ const uploaded = await uploadMyUserProductMedia([file])
+ return uploaded[0]?.id ?? null
+}
+
+export async function resolveDataUrlsToMediaIds(
+ values: string[],
+ existingMediaIds: string[],
+): Promise {
+ const resolved: string[] = []
+
+ for (let index = 0; index < values.length; index += 1) {
+ const value = values[index]
+ if (isDataUrl(value)) {
+ const file = await ensureJpegUploadFile(
+ value,
+ `user-product-image-${index + 1}.jpg`,
+ )
+ const uploaded = await uploadMyUserProductMedia([file])
+ if (uploaded[0]) resolved.push(uploaded[0].id)
+ } else if (existingMediaIds[index]) {
+ resolved.push(existingMediaIds[index])
+ }
+ }
+
+ return resolved
+}
diff --git a/apps/customer/src/services/tenantService.ts b/apps/customer/src/services/tenantService.ts
index b54e997..3ff325c 100644
--- a/apps/customer/src/services/tenantService.ts
+++ b/apps/customer/src/services/tenantService.ts
@@ -1,6 +1,9 @@
import { apiRequest } from '../lib/api'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
+import type { BusinessModuleId } from '../utils/businessModules'
+
+export type DashboardThemeMode = 'light' | 'dark'
export interface ResolvedTenant {
id: string
@@ -10,6 +13,8 @@ export interface ResolvedTenant {
domain: string
primaryColor: BusinessPrimaryColorId
defaultLocale?: DashboardLocale
+ themeMode?: DashboardThemeMode
+ enabledModules?: BusinessModuleId[]
logoUrl?: string | null
faviconUrl?: string | null
}
diff --git a/apps/customer/src/services/userProductsService.ts b/apps/customer/src/services/userProductsService.ts
new file mode 100644
index 0000000..1cd6500
--- /dev/null
+++ b/apps/customer/src/services/userProductsService.ts
@@ -0,0 +1,213 @@
+import { apiRequest } from '../lib/api'
+import { getActiveBusinessId } from '../lib/businessContext'
+import type { UserProductListItem } from '../types/userProduct'
+
+export type UserProductCondition = 'new' | 'stock' | 'needs_repair' | 'scrap'
+export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
+export type TechnicalFieldType = 'text' | 'textarea' | 'select' | 'multi_select'
+
+export interface UserProductCategoryOption {
+ id: string
+ name: string
+ nameFa: string | null
+ parentId: string | null
+}
+
+export interface TechnicalFormFieldOption {
+ id: string
+ label: string
+ value: string
+ sortOrder: number
+}
+
+export interface TechnicalFormField {
+ id: string
+ label: string
+ key: string
+ type: TechnicalFieldType
+ isRequired: boolean
+ sortOrder: number
+ options: TechnicalFormFieldOption[]
+}
+
+export interface CategoryTechnicalForm {
+ id: string
+ categoryId: string
+ fields: TechnicalFormField[]
+}
+
+export type TechnicalFormValues = Record
+
+export interface UserProductTechnicalValueInput {
+ fieldId: string
+ textValue?: string
+ optionId?: string
+ optionIds?: string[]
+}
+
+export interface CreateUserProductInput {
+ titleFa: string
+ titleEn?: string
+ description?: string
+ categoryId: string
+ price?: number
+ priceCurrency?: UserProductPriceCurrency
+ priceByExpert?: boolean
+ countryId: string
+ cityId: string
+ deliveryNote?: string
+ condition: UserProductCondition
+ technicalNotes?: string
+ technicalValues?: UserProductTechnicalValueInput[]
+ featuredMediaId?: string
+ galleryMediaIds?: string[]
+}
+
+export type UpdateUserProductInput = CreateUserProductInput
+
+export interface UserProductDetail extends UserProductListItem {
+ countryId: string
+ cityId: string
+ countrySlug: string
+ featuredMediaId: string | null
+ galleryMediaIds: string[]
+ images: Array<{ mediaId: string; url: string }>
+ technicalValues: UserProductTechnicalValueInput[]
+ deliveryNote?: string | null
+ technicalNotes?: string | null
+}
+
+export interface ListMyUserProductsParams {
+ page?: number
+ pageSize?: number
+}
+
+export interface MyUserProductsListResponse {
+ items: UserProductListItem[]
+ total: number
+ page: number
+ pageSize: number
+}
+
+function businessPath(suffix = '') {
+ const businessId = getActiveBusinessId()
+ if (!businessId) {
+ throw new Error('No active business selected. Please sign in again.')
+ }
+ return `/businesses/${businessId}/my-user-products${suffix}`
+}
+
+export function buildTechnicalValuesPayload(
+ fields: TechnicalFormField[],
+ values: TechnicalFormValues,
+): UserProductTechnicalValueInput[] {
+ const payload: UserProductTechnicalValueInput[] = []
+
+ for (const field of fields) {
+ const value = values[field.id]
+
+ if (field.type === 'text' || field.type === 'textarea') {
+ const text = typeof value === 'string' ? value.trim() : ''
+ if (!text) continue
+ payload.push({ fieldId: field.id, textValue: text })
+ continue
+ }
+
+ if (field.type === 'select') {
+ const optionId = typeof value === 'string' ? value.trim() : ''
+ if (!optionId) continue
+ payload.push({ fieldId: field.id, optionId })
+ continue
+ }
+
+ const optionIds = Array.isArray(value) ? value.filter(Boolean) : []
+ if (!optionIds.length) continue
+ payload.push({ fieldId: field.id, optionIds })
+ }
+
+ return payload
+}
+
+export async function listMyUserProducts(
+ params: ListMyUserProductsParams = {},
+ signal?: AbortSignal,
+) {
+ const q = new URLSearchParams()
+ if (params.page !== undefined) q.set('page', String(params.page))
+ if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize))
+
+ const query = q.toString()
+ return apiRequest(
+ `${businessPath()}${query ? `?${query}` : ''}`,
+ { auth: true, signal },
+ )
+}
+
+export async function createMyUserProduct(input: CreateUserProductInput) {
+ return apiRequest<{ message: string; product: UserProductListItem }>(
+ businessPath(),
+ {
+ method: 'POST',
+ auth: true,
+ body: input,
+ },
+ )
+}
+
+export async function getMyUserProduct(
+ productId: string,
+ signal?: AbortSignal,
+) {
+ return apiRequest<{ product: UserProductDetail }>(
+ businessPath(`/${productId}`),
+ { auth: true, signal },
+ )
+}
+
+export async function updateMyUserProduct(
+ productId: string,
+ input: UpdateUserProductInput,
+) {
+ return apiRequest<{ message: string; product: UserProductListItem }>(
+ businessPath(`/${productId}`),
+ {
+ method: 'PATCH',
+ auth: true,
+ body: input,
+ },
+ )
+}
+
+export async function deleteMyUserProduct(productId: string) {
+ return apiRequest<{ message: string }>(businessPath(`/${productId}`), {
+ method: 'DELETE',
+ auth: true,
+ })
+}
+
+export async function promoteMyUserProduct(productId: string) {
+ return apiRequest<{ message: string; product: UserProductListItem }>(
+ businessPath(`/${productId}/promote`),
+ {
+ method: 'POST',
+ auth: true,
+ },
+ )
+}
+
+export async function listMyUserProductCategories(signal?: AbortSignal) {
+ return apiRequest<{ items: UserProductCategoryOption[] }>(
+ businessPath('/categories'),
+ { auth: true, signal },
+ )
+}
+
+export async function getMyUserProductCategoryTechnicalForm(
+ categoryId: string,
+ signal?: AbortSignal,
+) {
+ return apiRequest<{ form: CategoryTechnicalForm | null }>(
+ businessPath(`/categories/${categoryId}/technical-form`),
+ { auth: true, signal },
+ )
+}
diff --git a/apps/customer/src/types/userProduct.ts b/apps/customer/src/types/userProduct.ts
new file mode 100644
index 0000000..2423592
--- /dev/null
+++ b/apps/customer/src/types/userProduct.ts
@@ -0,0 +1,26 @@
+export type UserProductStatus = 'draft' | 'published' | 'archived' | 'rejected'
+
+export type UserProductPriceCurrency = 'IRT' | 'USD' | 'EUR' | 'AED'
+
+export interface UserProductListItem {
+ id: string
+ title: string
+ titleFa?: string | null
+ titleEn?: string | null
+ description?: string | null
+ price: number | null
+ priceCurrency?: UserProductPriceCurrency | string | null
+ priceByExpert?: boolean
+ promoted?: boolean
+ status: UserProductStatus
+ condition?: string | null
+ cityName: string
+ cityNameFa?: string | null
+ countryName?: string | null
+ countryNameFa?: string | null
+ imageUrl: string | null
+ categoryId?: string | null
+ categoryName?: string | null
+ categoryNameFa?: string | null
+ createdAt?: string
+}
diff --git a/apps/customer/src/utils/businessModules.ts b/apps/customer/src/utils/businessModules.ts
new file mode 100644
index 0000000..7b366e0
--- /dev/null
+++ b/apps/customer/src/utils/businessModules.ts
@@ -0,0 +1,53 @@
+/** Optional modules that can gate customer-dashboard sections. */
+export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
+
+export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
+
+/** All optional module ids returned on tenant resolve (business + customer). */
+export const BUSINESS_MODULE_IDS = [
+ 'products',
+ 'store',
+ 'portfolio',
+ 'blog',
+ 'warehouse',
+ 'videos',
+ ...CUSTOMER_MODULE_IDS,
+] as const
+
+export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
+
+/** Legacy tenants without modules: business modules on, customer modules opt-in. */
+export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
+ 'products',
+ 'store',
+ 'portfolio',
+ 'blog',
+ 'warehouse',
+ 'videos',
+]
+
+const MODULE_ID_SET = new Set(BUSINESS_MODULE_IDS)
+
+export function isBusinessModuleId(value: unknown): value is BusinessModuleId {
+ return typeof value === 'string' && MODULE_ID_SET.has(value)
+}
+
+export function normalizeEnabledBusinessModules(value: unknown): BusinessModuleId[] {
+ if (!Array.isArray(value)) {
+ return [...DEFAULT_ENABLED_BUSINESS_MODULES]
+ }
+
+ const selected = new Set()
+ for (const item of value) {
+ if (isBusinessModuleId(item)) selected.add(item)
+ }
+
+ return BUSINESS_MODULE_IDS.filter((id) => selected.has(id))
+}
+
+export function hasBusinessModule(
+ enabledModules: readonly BusinessModuleId[] | null | undefined,
+ moduleId: BusinessModuleId,
+): boolean {
+ return normalizeEnabledBusinessModules(enabledModules).includes(moduleId)
+}
diff --git a/apps/customer/src/utils/cropImage.ts b/apps/customer/src/utils/cropImage.ts
new file mode 100644
index 0000000..c20b95d
--- /dev/null
+++ b/apps/customer/src/utils/cropImage.ts
@@ -0,0 +1,46 @@
+import type { Area } from 'react-easy-crop'
+
+function createImage(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const image = new Image()
+ image.addEventListener('load', () => resolve(image))
+ image.addEventListener('error', reject)
+ image.src = url
+ })
+}
+
+export async function getCroppedImage(
+ imageSrc: string,
+ pixelCrop: Area,
+ format: 'jpeg' | 'png' = 'jpeg',
+): Promise {
+ const image = await createImage(imageSrc)
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ if (!ctx) throw new Error('Could not get canvas context')
+
+ canvas.width = pixelCrop.width
+ canvas.height = pixelCrop.height
+
+ if (format === 'png') {
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+ }
+
+ ctx.drawImage(
+ image,
+ pixelCrop.x,
+ pixelCrop.y,
+ pixelCrop.width,
+ pixelCrop.height,
+ 0,
+ 0,
+ pixelCrop.width,
+ pixelCrop.height,
+ )
+
+ if (format === 'png') {
+ return canvas.toDataURL('image/png')
+ }
+
+ return canvas.toDataURL('image/jpeg', 0.92)
+}
diff --git a/apps/customer/src/utils/imageUpload.ts b/apps/customer/src/utils/imageUpload.ts
new file mode 100644
index 0000000..f03f40c
--- /dev/null
+++ b/apps/customer/src/utils/imageUpload.ts
@@ -0,0 +1,84 @@
+const ALLOWED_IMAGE_TYPES = new Set([
+ 'image/jpeg',
+ 'image/jpg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif',
+])
+
+function parseDataUrl(dataUrl: string): { mime: string; bytes: Uint8Array } {
+ const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
+ if (!match) {
+ throw new Error('Invalid image data')
+ }
+
+ const mime = match[1] === 'image/jpg' ? 'image/jpeg' : match[1]
+ const binary = atob(match[2])
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+
+ return { mime, bytes }
+}
+
+function loadImage(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const image = new Image()
+ image.addEventListener('load', () => resolve(image))
+ image.addEventListener('error', () => reject(new Error('Could not load image')))
+ image.src = src
+ })
+}
+
+export function dataUrlToFile(dataUrl: string, filename: string): File {
+ const { mime, bytes } = parseDataUrl(dataUrl)
+ const copy = new Uint8Array(bytes)
+ return new File([copy], filename, { type: mime })
+}
+
+export async function ensureUploadFile(dataUrl: string, filename: string): Promise {
+ if (dataUrl.startsWith('data:')) {
+ const mime = dataUrl.slice(5, dataUrl.indexOf(';'))
+ const normalized = mime === 'image/jpg' ? 'image/jpeg' : mime
+
+ if (ALLOWED_IMAGE_TYPES.has(normalized)) {
+ const file = dataUrlToFile(dataUrl, filename)
+ if (file.size > 0) {
+ return file
+ }
+ }
+ }
+
+ const image = await loadImage(dataUrl)
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ if (!ctx) {
+ throw new Error('Could not prepare image for upload')
+ }
+
+ const wantsPng = filename.toLowerCase().endsWith('.png')
+ canvas.width = image.naturalWidth
+ canvas.height = image.naturalHeight
+
+ if (wantsPng) {
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
+ }
+
+ ctx.drawImage(image, 0, 0)
+
+ const outputDataUrl = wantsPng
+ ? canvas.toDataURL('image/png')
+ : canvas.toDataURL('image/jpeg', 0.92)
+ const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
+ const extension = wantsPng ? 'png' : 'jpg'
+ return dataUrlToFile(outputDataUrl, `${safeName}.${extension}`)
+}
+
+export async function ensureJpegUploadFile(
+ dataUrl: string,
+ filename: string,
+): Promise {
+ const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
+ return ensureUploadFile(dataUrl, `${safeName}.jpg`)
+}
diff --git a/apps/super-admin/src/components/PrimaryColorSwatchControl.module.css b/apps/super-admin/src/components/PrimaryColorSwatchControl.module.css
index 3d18fed..d5bf814 100644
--- a/apps/super-admin/src/components/PrimaryColorSwatchControl.module.css
+++ b/apps/super-admin/src/components/PrimaryColorSwatchControl.module.css
@@ -43,12 +43,12 @@
z-index: 1200;
transform: translateX(-50%);
display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- gap: 6px;
+ flex-direction: column;
+ gap: 10px;
+ width: max-content;
+ min-width: 196px;
max-width: 280px;
- padding: 8px 10px;
+ padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.78);
@@ -57,6 +57,63 @@
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.16);
}
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.sectionLabel {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.themeRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.themeOption {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-height: 32px;
+ padding: 0 10px;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ background: rgba(255, 255, 255, 0.55);
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
+}
+
+.themeOption:hover {
+ border-color: rgba(148, 163, 184, 0.45);
+ color: var(--text-primary);
+}
+
+.themeOptionSelected {
+ border-color: var(--primary);
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+ box-shadow: 0 0 0 2px rgba(var(--primary-rgb) / 0.12);
+}
+
+.colorRow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 6px;
+}
+
.option {
width: 28px;
height: 28px;
diff --git a/apps/super-admin/src/components/PrimaryColorSwatchControl.tsx b/apps/super-admin/src/components/PrimaryColorSwatchControl.tsx
index 45d0db3..81ae18a 100644
--- a/apps/super-admin/src/components/PrimaryColorSwatchControl.tsx
+++ b/apps/super-admin/src/components/PrimaryColorSwatchControl.tsx
@@ -1,15 +1,19 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
+import { Moon, Sun } from 'lucide-react'
import {
BUSINESS_PRIMARY_COLOR_IDS,
BUSINESS_PRIMARY_COLOR_PALETTE,
type BusinessPrimaryColorId,
} from '../utils/businessPrimaryColors'
+import type { DashboardThemeMode } from '../services/businessSettingsService'
import styles from './PrimaryColorSwatchControl.module.css'
interface PrimaryColorSwatchControlProps {
value: BusinessPrimaryColorId
+ themeMode: DashboardThemeMode
onChange: (value: BusinessPrimaryColorId) => void
+ onThemeModeChange: (value: DashboardThemeMode) => void
disabled?: boolean
}
@@ -20,7 +24,9 @@ type PopoverPosition = {
export function PrimaryColorSwatchControl({
value,
+ themeMode,
onChange,
+ onThemeModeChange,
disabled = false,
}: PrimaryColorSwatchControlProps) {
const [open, setOpen] = useState(false)
@@ -60,6 +66,12 @@ export function PrimaryColorSwatchControl({
setOpen(false)
}
+ function selectThemeMode(mode: DashboardThemeMode) {
+ if (mode !== themeMode) {
+ onThemeModeChange(mode)
+ }
+ }
+
useEffect(() => {
if (!open) return
@@ -99,10 +111,10 @@ export function PrimaryColorSwatchControl({
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={toggleOpen}
disabled={disabled}
- aria-label={`Dashboard primary color: ${tokens.label}`}
- aria-haspopup="listbox"
+ aria-label={`Dashboard theme: ${themeMode}, color: ${tokens.label}`}
+ aria-haspopup="dialog"
aria-expanded={open}
- title={`Theme: ${tokens.label}`}
+ title={`Theme: ${themeMode} · ${tokens.label}`}
>
- {BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
- const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
- const selected = colorId === value
-
- return (
+
+
Theme
+
- )
- })}
+
+
+
+
+
+
Color
+
+ {BUSINESS_PRIMARY_COLOR_IDS.map((colorId) => {
+ const option = BUSINESS_PRIMARY_COLOR_PALETTE[colorId]
+ const selected = colorId === value
+
+ return (
+
+ )
+ })}
+
+
,
document.body,
)}
diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css
index 481ea59..219cff3 100644
--- a/apps/super-admin/src/pages/BusinessesPage.module.css
+++ b/apps/super-admin/src/pages/BusinessesPage.module.css
@@ -459,13 +459,27 @@
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
+.modulesSection {
+ margin-top: 14px;
+}
+
+.modulesSection .entityGroupLabel {
+ margin: 0;
+}
+
.modulesChecks {
- display: flex;
- flex-direction: column;
- gap: 10px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px 16px;
margin-top: 10px;
}
+@media (max-width: 560px) {
+ .modulesChecks {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
.modulesCharts {
display: flex;
flex-direction: column;
diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx
index 2a24042..de8ea05 100644
--- a/apps/super-admin/src/pages/BusinessesPage.tsx
+++ b/apps/super-admin/src/pages/BusinessesPage.tsx
@@ -53,11 +53,16 @@ import {
updateBusinessDefaultLocale,
updateBusinessModules,
updateBusinessPrimaryColor,
+ updateBusinessThemeMode,
type DashboardLocale,
+ type DashboardThemeMode,
} from '../services/businessSettingsService'
import {
+ BUSINESS_DASHBOARD_MODULE_IDS,
+ BUSINESS_DASHBOARD_MODULE_LABELS,
BUSINESS_MODULE_IDS,
- BUSINESS_MODULE_LABELS,
+ CUSTOMER_MODULE_IDS,
+ CUSTOMER_MODULE_LABELS,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
HOME_CHART_IDS,
@@ -74,11 +79,16 @@ import styles from './BusinessesPage.module.css'
const DEFAULT_PAGE_SIZE = 10
const DEFAULT_LOCALE: DashboardLocale = 'fa'
+const DEFAULT_THEME_MODE: DashboardThemeMode = 'light'
function normalizeDefaultLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_LOCALE
}
+function normalizeThemeMode(value: unknown): DashboardThemeMode {
+ return value === 'dark' || value === 'light' ? value : DEFAULT_THEME_MODE
+}
+
function emptyModuleSelection(
enabled: BusinessModuleId[] = DEFAULT_ENABLED_BUSINESS_MODULES,
): Record {
@@ -400,7 +410,7 @@ export function BusinessesPage() {
try {
await updateBusinessPrimaryColor(b.id, primaryColor)
- showToast(`Theme updated for "${b.name}".`, 'success')
+ showToast(`Theme color updated for "${b.name}".`, 'success')
} catch (err) {
setData((prev) => {
if (!prev) return prev
@@ -413,7 +423,53 @@ export function BusinessesPage() {
})
showToast(
- err instanceof ApiError ? err.message : 'Unable to update theme.',
+ err instanceof ApiError ? err.message : 'Unable to update theme color.',
+ 'error',
+ )
+ } finally {
+ setSavingColorId(null)
+ }
+ }
+
+ async function handleThemeModeChange(
+ b: BusinessListItem,
+ themeMode: DashboardThemeMode,
+ ) {
+ const currentMode = normalizeThemeMode(b.themeMode)
+ if (currentMode === themeMode) return
+
+ setSavingColorId(b.id)
+ setError('')
+
+ setData((prev) => {
+ if (!prev) return prev
+ return {
+ ...prev,
+ items: prev.items.map((item) =>
+ item.id === b.id ? { ...item, themeMode } : item,
+ ),
+ }
+ })
+
+ try {
+ await updateBusinessThemeMode(b.id, themeMode)
+ showToast(
+ `${themeMode === 'dark' ? 'Dark' : 'Light'} theme set for "${b.name}".`,
+ 'success',
+ )
+ } catch (err) {
+ setData((prev) => {
+ if (!prev) return prev
+ return {
+ ...prev,
+ items: prev.items.map((item) =>
+ item.id === b.id ? { ...item, themeMode: currentMode } : item,
+ ),
+ }
+ })
+
+ showToast(
+ err instanceof ApiError ? err.message : 'Unable to update theme mode.',
'error',
)
} finally {
@@ -1103,10 +1159,14 @@ export function BusinessesPage() {
void handlePrimaryColorChange(b, primaryColor)
}
+ onThemeModeChange={(themeMode) =>
+ void handleThemeModeChange(b, themeMode)
+ }
/>
|
@@ -1384,26 +1444,50 @@ export function BusinessesPage() {
) : null}
- Choose which optional modules this business can use. Customers, website,
- and other general sections stay available for every business.
+ Choose which optional modules this business can use. Home, profile,
+ website, and other general sections stay available for every business.
-
- {BUSINESS_MODULE_IDS.map((id) => (
-
- ))}
+
+ Business modules
+
+ {BUSINESS_DASHBOARD_MODULE_IDS.map((id) => (
+
+ ))}
+
+
+
+ Customer modules
+
+ {CUSTOMER_MODULE_IDS.map((id) => (
+
+ ))}
+
Home charts
diff --git a/apps/super-admin/src/services/businessSettingsService.ts b/apps/super-admin/src/services/businessSettingsService.ts
index a7a0bc7..ed58511 100644
--- a/apps/super-admin/src/services/businessSettingsService.ts
+++ b/apps/super-admin/src/services/businessSettingsService.ts
@@ -4,9 +4,12 @@ import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
export type DashboardLocale = 'en' | 'fa'
+export type DashboardThemeMode = 'light' | 'dark'
+
export interface BrandingSettings {
primaryColor: BusinessPrimaryColorId
defaultLocale: DashboardLocale
+ themeMode: DashboardThemeMode
}
export interface ModulesSettings {
@@ -52,6 +55,19 @@ export async function updateBusinessPrimaryColor(
})
}
+export async function updateBusinessThemeMode(
+ businessId: string,
+ themeMode: DashboardThemeMode,
+) {
+ return apiRequest (`/businesses/${businessId}/settings`, {
+ method: 'PATCH',
+ auth: true,
+ body: {
+ branding: { themeMode },
+ },
+ })
+}
+
export async function updateBusinessDefaultLocale(
businessId: string,
defaultLocale: DashboardLocale,
diff --git a/apps/super-admin/src/types/business.ts b/apps/super-admin/src/types/business.ts
index 1ffa677..8478ea0 100644
--- a/apps/super-admin/src/types/business.ts
+++ b/apps/super-admin/src/types/business.ts
@@ -1,6 +1,9 @@
import type { BusinessPrimaryColorId } from '../utils/businessPrimaryColors'
import type { BusinessModuleId, HomeChartId } from '../utils/businessModules'
-import type { DashboardLocale } from '../services/businessSettingsService'
+import type {
+ DashboardLocale,
+ DashboardThemeMode,
+} from '../services/businessSettingsService'
export interface BusinessOwnerInfo {
name: string | null
@@ -28,6 +31,7 @@ export interface BusinessListItem {
ownerCellNumber: string | null
isActive: boolean
primaryColor: BusinessPrimaryColorId
+ themeMode: DashboardThemeMode
defaultLocale: DashboardLocale
enabledModules: BusinessModuleId[]
moduleCount: number
diff --git a/apps/super-admin/src/utils/businessModules.ts b/apps/super-admin/src/utils/businessModules.ts
index cfb2f51..655f6dd 100644
--- a/apps/super-admin/src/utils/businessModules.ts
+++ b/apps/super-admin/src/utils/businessModules.ts
@@ -1,5 +1,5 @@
-/** Optional CMS modules a business can have. Always-on areas are not listed. */
-export const BUSINESS_MODULE_IDS = [
+/** Optional business-dashboard CMS modules. Always-on areas are not listed. */
+export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'portfolio',
@@ -8,9 +8,24 @@ export const BUSINESS_MODULE_IDS = [
'videos',
] as const
+/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
+export const CUSTOMER_MODULE_IDS = ['customer_products'] as const
+
+/** All optional modules stored in `settings.modules.enabled`. */
+export const BUSINESS_MODULE_IDS = [
+ ...BUSINESS_DASHBOARD_MODULE_IDS,
+ ...CUSTOMER_MODULE_IDS,
+] as const
+
+export type BusinessDashboardModuleId =
+ (typeof BUSINESS_DASHBOARD_MODULE_IDS)[number]
+export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number]
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number]
-export const BUSINESS_MODULE_LABELS: Record = {
+export const BUSINESS_DASHBOARD_MODULE_LABELS: Record<
+ BusinessDashboardModuleId,
+ string
+> = {
products: 'Products',
store: 'Store',
portfolio: 'Portfolio',
@@ -19,6 +34,15 @@ export const BUSINESS_MODULE_LABELS: Record = {
videos: 'Videos',
}
+export const CUSTOMER_MODULE_LABELS: Record = {
+ customer_products: 'Customer products',
+}
+
+export const BUSINESS_MODULE_LABELS: Record = {
+ ...BUSINESS_DASHBOARD_MODULE_LABELS,
+ ...CUSTOMER_MODULE_LABELS,
+}
+
/** Home dashboard chart slots (super-admin selectable). */
export const HOME_CHART_IDS = [
'none',
@@ -38,9 +62,12 @@ export const HOME_CHART_LABELS: Record = {
products_added_1y: 'Added product in last year',
}
-/** Existing tenants without saved modules keep every module enabled. */
+/**
+ * Existing tenants without saved modules keep every business-dashboard module
+ * enabled. Customer modules stay opt-in.
+ */
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
- ...BUSINESS_MODULE_IDS,
+ ...BUSINESS_DASHBOARD_MODULE_IDS,
]
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index fac6bb0..c2f1628 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
-Last updated: August 7, 2026
+Last updated: August 9, 2026
---
@@ -165,6 +165,10 @@ Add to `/etc/hosts` (one line per tenant):
| `/addresses` | Addresses |
| `/orders` | Orders |
| `/favorites` | Favorites |
+| `/my-products` | Customer stock listings (gated by `customer_products`) — list API |
+| `/my-products/:id` | User product details |
+| `/my-products/new` | Add user product (3 steps: basics, images, technical) — create API |
+| `/my-products/:id/edit` | Edit user product — update API |
### Business (`apps/business`)
@@ -184,6 +188,10 @@ Add to `/etc/hosts` (one line per tenant):
| `/store/items` | Store items (product variants) | Yes |
| `/store/settings` | Online sell + order process steps | Yes |
| `/customers` | Business customers list | Yes |
+| `/customer-products` | Customer user-product listings (admin API) | Yes |
+| `/customer-products/new` | Admin create user product (under admin name) | Yes |
+| `/customer-products/:id` | Customer user-product details | Yes |
+| `/customer-products/:id/edit` | Admin edit user product | Yes |
| `/store/orders` | Orders list + filters | Yes |
| `/blog` | Blog hub | Yes |
| `/blog/list` | My Blogs grid | Yes |
@@ -257,9 +265,9 @@ Products accept optional `brandId` on create/update.
### Business settings
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`
-- `modules.enabled`: optional CMS modules (`products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`); missing key → all enabled (legacy). Super-admin only for modules PATCH.
+- `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH.
- `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`); `none` hides that slot; defaults orders + customers.
-- Always-on (not in modules list): customers, website, profile, settings, home
+- Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts`
### Portfolios
@@ -301,6 +309,10 @@ Products accept optional `brandId` on create/update.
### Media
- `POST /businesses/:businessId/media` — upload to S3
+### User products (customer listings)
+- Customer (own stock): `GET/POST /businesses/:businessId/my-user-products`, `GET/PATCH/DELETE .../:productId`, `POST .../:productId/promote`, categories + technical-form + media
+- Business admin (all listings): `GET/POST /businesses/:businessId/user-products`, `GET/PATCH/DELETE .../:productId`, `PATCH .../:productId/status` (`draft` | `published` | `rejected` | `archived`), `POST .../:productId/promote`, categories + technical-form. Admin create is attributed to the admin user.
+
---
## Feature status (business app)
@@ -441,7 +453,7 @@ Global input sizing lives in `packages/dashboard-core/src/styles/tokens.css` (cu
- **Farsi text:** Customer app → Yekan Bakh (`src/fonts/yekanbakh.css`, `--font-fa` in `index.css`); business/super-admin → IranYekan (`iranyekan.css`, `.faText`). Input + placeholder must share the same stack — see `.cursor/rules/ui-farsi-fonts.mdc`
- **Per-business theme:** Super Admin sets `branding.primaryColor`; business app applies via `BusinessThemeProvider`
-- **Per-business modules:** Super Admin **Modules** column on Businesses list; stored in `settings.modules.enabled`; business Home + Sidebar hide disabled modules
+- **Per-business modules:** Super Admin **Modules** column on Businesses list; stored in `settings.modules.enabled` (business + customer sections); business Home + Sidebar hide disabled business modules; `customer_products` gates customer My Products (`/my-products`, `/my-products/new`) which call `businesses/:id/my-user-products`
- **RTL:** Farsi inputs use `dir="rtl"` and the app’s `--font-fa`
- **IRT prices:** comma-separated thousands + `IRT` suffix — see `.cursor/rules/ui-irt-price.mdc`
- **Toasts:** see `.cursor/rules/ui-toasts.mdc`
diff --git a/package-lock.json b/package-lock.json
index 9963a2e..85a0650 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,6 +40,7 @@
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
+ "react-easy-crop": "^6.2.3",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
@@ -1287,9 +1288,9 @@
}
},
"node_modules/react-easy-crop": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.2.tgz",
- "integrity": "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ==",
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.2.3.tgz",
+ "integrity": "sha512-ebimG3OGlzizjxEZ77Cj9CVLcg5vDZ6QVz8ud1rx4WmsCDWHIROEhgMi0GpJ/jKAuwHrH0M+QZUK4hyvxbYhOA==",
"license": "MIT",
"dependencies": {
"normalize-wheel": "^1.0.1"
diff --git a/packages/dashboard-core/src/styles/tokens.css b/packages/dashboard-core/src/styles/tokens.css
index d017b76..ce9f6dc 100644
--- a/packages/dashboard-core/src/styles/tokens.css
+++ b/packages/dashboard-core/src/styles/tokens.css
@@ -48,6 +48,8 @@
--card-hover-lift: -4px;
--card-hover-shadow: 0 16px 48px rgba(var(--primary-rgb) / 0.14);
--card-hover-transition: transform 0.25s ease, box-shadow 0.25s ease;
+ --icon-bg: color-mix(in srgb, var(--primary) 14%, #ffffff);
+ --icon-bg-end: color-mix(in srgb, var(--primary) 8%, #f1f5f9);
}
html {
diff --git a/packages/dashboard-ui/src/components/AddressListEditor.tsx b/packages/dashboard-ui/src/components/AddressListEditor.tsx
index a1eab9c..4b9eb7d 100644
--- a/packages/dashboard-ui/src/components/AddressListEditor.tsx
+++ b/packages/dashboard-ui/src/components/AddressListEditor.tsx
@@ -4,7 +4,7 @@ import styles from './AddressListEditor.module.css'
export type CityOption = {
id: string
parentId: string | null
- level: 'country' | 'province' | 'city'
+ level: 'country' | 'province' | 'city' | 'district'
nameFa: string
nameEn: string
landlineCode: string | null
diff --git a/packages/dashboard-ui/src/components/LanguageSelect.module.css b/packages/dashboard-ui/src/components/LanguageSelect.module.css
index 126b867..749632c 100644
--- a/packages/dashboard-ui/src/components/LanguageSelect.module.css
+++ b/packages/dashboard-ui/src/components/LanguageSelect.module.css
@@ -4,9 +4,16 @@
align-items: center;
height: 38px;
border-radius: 50px;
- background: rgba(255, 255, 255, 0.5);
+ background: linear-gradient(
+ 145deg,
+ color-mix(in srgb, var(--elevated-surface) 78%, transparent) 0%,
+ color-mix(in srgb, var(--glass-bg) 55%, transparent) 55%,
+ color-mix(in srgb, var(--surface) 42%, transparent) 100%
+ );
+ backdrop-filter: blur(18px);
+ -webkit-backdrop-filter: blur(18px);
border: 1px solid var(--glass-border);
- transition: box-shadow 0.2s, border-color 0.2s;
+ transition: box-shadow 0.2s, border-color 0.2s, background 0.2s;
}
.wrap:hover,
@@ -20,6 +27,7 @@
inset-inline-start: 12px;
color: var(--text-secondary);
pointer-events: none;
+ z-index: 1;
}
.chevron {
@@ -27,27 +35,37 @@
inset-inline-end: 10px;
color: var(--text-muted);
pointer-events: none;
+ z-index: 1;
}
-.select {
+/* Beat global `select` + `html[data-theme='dark'] select` so the pill stays one surface */
+.wrap .select {
appearance: none;
-webkit-appearance: none;
+ -moz-appearance: none;
height: 100%;
- min-width: 72px;
+ min-height: 0;
+ min-width: 76px;
padding-block: 0;
padding-inline: 34px 28px;
border: none;
border-radius: 50px;
- background: transparent;
+ background-color: transparent;
+ background-image: none;
+ box-shadow: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
+ line-height: 1;
cursor: pointer;
outline: none;
+ transition: none;
}
-.select:focus {
+.wrap .select:focus {
+ border: none;
+ box-shadow: none;
outline: none;
}
diff --git a/packages/dashboard-ui/src/components/SectionCard.module.css b/packages/dashboard-ui/src/components/SectionCard.module.css
index 179c3a2..84b3fc0 100644
--- a/packages/dashboard-ui/src/components/SectionCard.module.css
+++ b/packages/dashboard-ui/src/components/SectionCard.module.css
@@ -1,6 +1,7 @@
.card {
display: flex;
flex-direction: column;
+ height: 100%;
padding: 28px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
@@ -19,7 +20,8 @@
display: flex;
align-items: center;
justify-content: center;
- background: linear-gradient(135deg, var(--primary-light) 0%, rgba(219, 234, 254, 0.5) 100%);
+ background: linear-gradient(135deg, var(--icon-bg) 0%, var(--icon-bg-end) 100%);
+ border: 1px solid rgba(var(--primary-rgb) / 0.22);
border-radius: var(--radius-sm);
color: var(--primary);
margin-bottom: 20px;
@@ -50,6 +52,9 @@
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 12px;
+ min-height: 36px;
+ margin-top: auto;
}
.link {
@@ -58,28 +63,74 @@
color: var(--primary);
}
+.countMeta {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px 8px;
+ height: 36px;
+ max-width: calc(100% - 48px);
+ padding: 0 14px;
+ box-sizing: border-box;
+ border-radius: 999px;
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+ transition:
+ background 0.35s ease,
+ color 0.35s ease;
+}
+
+.countValue {
+ font-family: var(--font-en), var(--font-ui), sans-serif;
+ font-size: 16px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+ line-height: 1;
+ color: inherit;
+}
+
+.countLabel {
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ color: inherit;
+ opacity: 0.85;
+}
+
+.card:hover .countMeta {
+ background: var(--primary);
+ color: white;
+}
+
+.card:hover .countLabel {
+ opacity: 0.95;
+}
+
.arrowBtn {
width: 36px;
height: 36px;
+ flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
- transition: background 0.2s, transform 0.2s;
+ transition:
+ background 0.35s ease,
+ color 0.35s ease;
+}
+
+.arrowIcon {
+ display: block;
}
.card:hover .arrowBtn {
background: var(--primary);
color: white;
- transform: translateX(2px);
}
:global([dir='rtl']) .arrowBtn {
transform: scaleX(-1);
}
-
-:global([dir='rtl']) .card:hover .arrowBtn {
- transform: scaleX(-1) translateX(2px);
-}
diff --git a/packages/dashboard-ui/src/components/SectionCard.tsx b/packages/dashboard-ui/src/components/SectionCard.tsx
index ee187aa..a54f611 100644
--- a/packages/dashboard-ui/src/components/SectionCard.tsx
+++ b/packages/dashboard-ui/src/components/SectionCard.tsx
@@ -6,8 +6,12 @@ interface SectionCardProps {
icon: LucideIcon
title: string
description: string
- linkText: string
+ /** Legacy footer link text; prefer count + countLabel when available. */
+ linkText?: string
href: string
+ /** Entity total shown beside the arrow; omit for sections without a count. */
+ count?: number | null
+ countLabel?: string
}
export function SectionCard({
@@ -16,7 +20,14 @@ export function SectionCard({
description,
linkText,
href,
+ count,
+ countLabel,
}: SectionCardProps) {
+ const showCount = typeof count === 'number' && Number.isFinite(count)
+ const formattedCount = showCount
+ ? new Intl.NumberFormat('en-US').format(count)
+ : null
+
return (
@@ -27,9 +38,20 @@ export function SectionCard({
{description}
- {linkText}
+ {formattedCount !== null && countLabel ? (
+
+
+ {formattedCount}
+
+ {countLabel}
+
+ ) : linkText ? (
+ {linkText}
+ ) : (
+
+ )}
-
+
diff --git a/packages/dashboard-ui/src/context/Toast.module.css b/packages/dashboard-ui/src/context/Toast.module.css
index 997645a..48138b7 100644
--- a/packages/dashboard-ui/src/context/Toast.module.css
+++ b/packages/dashboard-ui/src/context/Toast.module.css
@@ -19,7 +19,7 @@
font-weight: 500;
line-height: 1.4;
color: var(--text-primary);
- background: rgba(255, 255, 255, 0.55);
+ background: var(--elevated-surface);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--glass-border);
|