Add a static images manager and polish dashboard layout and fonts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-16 23:56:17 +03:30
co-authored by Cursor
parent e79211e3cd
commit b9024ad77a
57 changed files with 2025 additions and 122 deletions
@@ -77,7 +77,7 @@
}
}
@media (min-width: 1280px) {
@media (min-width: 1600px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
+1 -1
View File
@@ -40,7 +40,7 @@ const sections: {
countLabelKey?: BusinessMessageKey
href: string
countKey?: CountKey
/** Double-width tile (4 of 6 home columns). */
/** Double-width tile (4 of 12 columns vs 2 for a normal tile). */
wide?: boolean
/** When set, card is shown only if this optional module is enabled. */
moduleId?: BusinessModuleId
@@ -69,7 +69,7 @@
}
}
@media (min-width: 1280px) {
@media (min-width: 1600px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
@@ -11,7 +11,7 @@
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: var(--glass-shadow);
overflow: hidden;
overflow-x: auto;
}
.tableHeader {
@@ -220,7 +220,7 @@ button.emptyCta {
}
}
@media (min-width: 1280px) {
@media (min-width: 1600px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
+8
View File
@@ -3,6 +3,7 @@ import {
BellRing,
CircleHelp,
Award,
Image,
Images,
LayoutGrid,
Building2,
@@ -22,6 +23,13 @@ const websiteSections: {
linkKey: BusinessMessageKey
href: string
}[] = [
{
icon: Image,
titleKey: 'nav.website.staticImages',
descKey: 'website.card.staticImages.desc',
linkKey: 'website.card.staticImages.link',
href: '/website/static-images',
},
{
icon: Images,
titleKey: 'nav.website.sliders',
@@ -0,0 +1,74 @@
.slot {
margin-bottom: 28px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.titleBlock {
min-width: 0;
}
.title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.key {
font-family: var(--font-en), var(--font-fa);
direction: ltr;
padding: 2px 8px;
border-radius: 999px;
background: rgba(var(--primary-rgb) / 0.08);
color: var(--primary);
}
.headerActions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.pageSelect {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 200px;
flex-shrink: 0;
}
.pageSelect label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.pageSelect select {
min-width: 200px;
appearance: none;
-webkit-appearance: none;
padding-right: var(--select-padding-end);
background-color: rgba(255, 255, 255, 0.7);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--select-arrow-offset) center;
background-size: var(--select-arrow-size);
cursor: pointer;
}
@@ -0,0 +1,439 @@
import { useEffect, useMemo, useState } from 'react'
import { Pencil, Trash2 } from 'lucide-react'
import {
AddWebsiteSliderSlideModal,
type WebsiteSliderSlideFormData,
} from '../components/AddWebsiteSliderSlideModal'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
import { PageTitle } from '../components/PageTitle'
import { Tooltip } from '../components/Tooltip'
import {
WebsiteStaticImageSlotModal,
type WebsiteStaticImageSlotFormValues,
} from '../components/WebsiteStaticImageSlotModal'
import { WebsiteStaticImageGallery } from '../components/WebsiteStaticImageGallery'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import type { BusinessMessageKey } from '../i18n/messages'
import { ApiError } from '../lib/api'
import { getBusinessDomain } from '../lib/config'
import { resolveDataUrlToMediaId } from '../services/mediaService'
import {
deleteWebsiteStaticImageSlot,
listWebsiteStaticImageSlots,
staticImageItemsToPayload,
updateWebsiteStaticImageSlot,
} from '../services/websiteStaticImageService'
import type {
WebsiteStaticImageItem,
WebsiteStaticImageSlot,
} from '../types/websiteStaticImage'
import { parseAspectRatio } from '../utils/aspectRatio'
import {
staticImagePageLabel,
staticImagePageOptions,
} from '../utils/staticImagePages'
import controlStyles from '../components/CategoryRow.module.css'
import pageStyles from '../components/PageContent.module.css'
import specialStyles from './StoreSpecialsPage.module.css'
import styles from './WebsiteStaticImagesPage.module.css'
function parseRecommendedWidth(raw: string): number | undefined {
const value = Number(raw.trim())
if (!Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function parseItemCount(kind: string, raw: string): number | undefined {
if (kind === 'single') return 1
const value = Number(raw.trim())
if (!Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function occupyingImage(slot: WebsiteStaticImageSlot, index: number) {
return (
slot.images.find((image) => image.sortOrder === index) ??
(slot.itemCount == null ? slot.images[index] : undefined)
)
}
function slotKindLabel(
slot: WebsiteStaticImageSlot,
t: (key: BusinessMessageKey, vars?: Record<string, string | number>) => string,
) {
if (slot.kind === 'single' || slot.itemCount === 1) {
return t('website.staticImages.kindSingle')
}
if (slot.itemCount) {
return t('website.staticImages.kindFixed', { count: slot.itemCount })
}
return t('website.staticImages.kindList')
}
export function WebsiteStaticImagesPage() {
const t = useT()
const { showToast } = useToast()
const domain = getBusinessDomain()
const [slots, setSlots] = useState<WebsiteStaticImageSlot[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [pageKey, setPageKey] = useState('home')
const [editSlot, setEditSlot] = useState<WebsiteStaticImageSlot | null>(null)
const [deleteSlot, setDeleteSlot] = useState<WebsiteStaticImageSlot | null>(null)
const [imageTarget, setImageTarget] = useState<{
slot: WebsiteStaticImageSlot
index: number
} | null>(null)
const [removeTarget, setRemoveTarget] = useState<{
slot: WebsiteStaticImageSlot
image: WebsiteStaticImageItem
} | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadSlots(controller.signal)
return () => controller.abort()
}, [])
async function loadSlots(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await listWebsiteStaticImageSlots(1, 50, signal)
setSlots(data.items)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.staticImages.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function replaceSlot(updated: WebsiteStaticImageSlot) {
setSlots((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)))
}
const pageOptions = useMemo(
() => staticImagePageOptions(slots.map((slot) => slot.pageKey)),
[slots],
)
const visibleSlots = useMemo(
() => slots.filter((slot) => (slot.pageKey || 'home') === pageKey),
[slots, pageKey],
)
async function handleEditSlot(values: WebsiteStaticImageSlotFormValues) {
if (!editSlot) return
setIsSaving(true)
setError('')
try {
const recommendedWidth = parseRecommendedWidth(values.recommendedWidth)
const itemCount = parseItemCount(values.kind, values.itemCount)
const result = await updateWebsiteStaticImageSlot(editSlot.id, {
key: values.key,
label: values.label,
kind: values.kind,
aspectRatio: values.aspectRatio,
...(recommendedWidth !== undefined ? { recommendedWidth } : {}),
itemCount: values.kind === 'single' ? 1 : itemCount ?? null,
pageKey: values.pageKey,
})
replaceSlot(result.slot)
setPageKey(values.pageKey || 'home')
setEditSlot(null)
showToast(t('website.staticImages.toastUpdated'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.staticImages.errorUpdate'))
}
} finally {
setIsSaving(false)
}
}
async function confirmDeleteSlot() {
if (!deleteSlot) return
setIsSaving(true)
setError('')
try {
await deleteWebsiteStaticImageSlot(deleteSlot.id)
setSlots((prev) => prev.filter((entry) => entry.id !== deleteSlot.id))
setDeleteSlot(null)
showToast(t('website.staticImages.toastDeleted'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.staticImages.errorDelete'))
}
} finally {
setIsSaving(false)
}
}
async function handleAddImage(data: WebsiteSliderSlideFormData) {
if (!imageTarget) return
const { slot, index } = imageTarget
setIsSaving(true)
setError('')
try {
const occupying = occupyingImage(slot, index)
const imageMediaId = await resolveDataUrlToMediaId(
data.image,
'static-image.jpg',
occupying?.imageMediaId,
)
if (!imageMediaId) {
throw new Error(t('website.staticImages.errorUpload'))
}
const nextItem = {
imageMediaId,
sortOrder: index,
...(data.titleFa ? { titleFa: data.titleFa } : {}),
...(data.titleEn ? { titleEn: data.titleEn } : {}),
...(data.subtext ? { subtext: data.subtext } : {}),
...(data.linkUrl ? { linkUrl: data.linkUrl } : {}),
}
const remaining = staticImageItemsToPayload(
slot.images.filter((image) => (occupying ? image.id !== occupying.id : true)),
)
const result = await updateWebsiteStaticImageSlot(slot.id, {
items: [...remaining, nextItem],
})
replaceSlot(result.slot)
setImageTarget(null)
showToast(t('website.staticImages.toastImageSaved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else if (err instanceof Error) {
setError(err.message)
} else {
setError(t('website.staticImages.errorAddImage'))
}
} finally {
setIsSaving(false)
}
}
async function confirmRemoveImage() {
if (!removeTarget) return
setIsSaving(true)
setError('')
try {
const result = await updateWebsiteStaticImageSlot(removeTarget.slot.id, {
items: staticImageItemsToPayload(
removeTarget.slot.images.filter((image) => image.id !== removeTarget.image.id),
),
})
replaceSlot(result.slot)
setRemoveTarget(null)
showToast(t('website.staticImages.toastImageRemoved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.staticImages.errorRemoveImage'))
}
} finally {
setIsSaving(false)
}
}
const occupyingTarget = imageTarget
? occupyingImage(imageTarget.slot, imageTarget.index)
: undefined
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Static Images' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="STATIC IMAGES">{t('title.staticImages')}</PageTitle>
<p className={pageStyles.pageSubtitle}>
{t('website.staticImages.subtitle', { domain })}
</p>
</div>
<div className={styles.pageSelect}>
<label htmlFor="static-images-page">{t('website.staticImages.page')}</label>
<select
id="static-images-page"
value={pageKey}
onChange={(e) => setPageKey(e.target.value)}
disabled={isLoading}
>
{pageOptions.map((option) => (
<option key={option} value={option}>
{staticImagePageLabel(option, t)}
</option>
))}
</select>
</div>
</div>
{error && <p className={specialStyles.error}>{error}</p>}
{isLoading ? (
<p className={specialStyles.empty}>{t('website.staticImages.loading')}</p>
) : visibleSlots.length === 0 ? (
<p className={specialStyles.empty}>
{slots.length === 0
? t('website.staticImages.empty')
: t('website.staticImages.emptyPage')}
</p>
) : (
visibleSlots.map((slot) => (
<section key={slot.id} className={styles.slot}>
<div className={styles.header}>
<div className={styles.titleBlock}>
<h3 className={styles.title}>{slot.label}</h3>
<div className={styles.meta}>
<span className={styles.key}>{slot.key}</span>
<span>{slotKindLabel(slot, t)}</span>
{slot.aspectRatio ? <span>{slot.aspectRatio}</span> : null}
{slot.recommendedWidth ? <span>{slot.recommendedWidth}px</span> : null}
</div>
</div>
<div className={styles.headerActions}>
<Tooltip label={t('website.editGroup')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => setEditSlot(slot)}
aria-label={t('website.editGroup')}
>
<Pencil size={16} />
</button>
</Tooltip>
<Tooltip label={t('website.deleteGroup')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => setDeleteSlot(slot)}
aria-label={t('website.deleteGroup')}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</div>
<WebsiteStaticImageGallery
images={slot.images}
aspectRatio={slot.aspectRatio}
itemCount={slot.itemCount ?? (slot.kind === 'single' ? 1 : null)}
addLabel={t('website.staticImages.addImage')}
replaceLabel={t('website.staticImages.replaceImage')}
removeLabel={t('website.staticImages.removeImage')}
onAddAt={(index) => setImageTarget({ slot, index })}
onRemove={(image) => setRemoveTarget({ slot, image })}
/>
</section>
))
)}
<WebsiteStaticImageSlotModal
open={!!editSlot}
onClose={() => !isSaving && setEditSlot(null)}
onSubmit={handleEditSlot}
initial={
editSlot
? {
key: editSlot.key,
label: editSlot.label,
kind: editSlot.kind,
aspectRatio: editSlot.aspectRatio ?? '16:9',
recommendedWidth: editSlot.recommendedWidth
? String(editSlot.recommendedWidth)
: '',
itemCount:
editSlot.kind === 'list' && editSlot.itemCount
? String(editSlot.itemCount)
: '',
pageKey: editSlot.pageKey || 'home',
}
: undefined
}
title={t('website.staticImages.editTitle')}
submitLabel={t('website.save')}
isSubmitting={isSaving}
lockKey
/>
<AddWebsiteSliderSlideModal
open={!!imageTarget}
onClose={() => !isSaving && setImageTarget(null)}
onSubmit={(data) => void handleAddImage(data)}
isSubmitting={isSaving}
aspect={parseAspectRatio(imageTarget?.slot.aspectRatio)}
captionMode="bilingual"
initial={
occupyingTarget
? {
image: occupyingTarget.url,
titleFa: occupyingTarget.titleFa || occupyingTarget.title || '',
titleEn: occupyingTarget.titleEn || '',
subtext: occupyingTarget.subtext || '',
linkUrl: occupyingTarget.linkUrl || '',
}
: undefined
}
heading={
occupyingTarget
? t('website.staticImages.replaceImage')
: t('website.staticImages.addImage')
}
hint={
imageTarget?.slot.aspectRatio
? t('website.staticImages.cropHint', { ratio: imageTarget.slot.aspectRatio })
: t('website.staticImages.cropHint', { ratio: '16:9' })
}
submitLabel={t('website.save')}
/>
<ConfirmDeleteModal
open={!!deleteSlot}
title={t('website.staticImages.deleteTitle')}
message={
deleteSlot
? t('website.staticImages.deleteMessage', { label: deleteSlot.label })
: ''
}
onConfirm={() => void confirmDeleteSlot()}
onCancel={() => !isSaving && setDeleteSlot(null)}
/>
<ConfirmDeleteModal
open={!!removeTarget}
title={t('website.staticImages.removeImageTitle')}
message={t('website.staticImages.removeImageMessage')}
onConfirm={() => void confirmRemoveImage()}
onCancel={() => !isSaving && setRemoveTarget(null)}
/>
</main>
)
}