Add a static images manager and polish dashboard layout and fonts.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e79211e3cd
commit
b9024ad77a
@@ -77,7 +77,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
@media (min-width: 1600px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user