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
@@ -1,3 +1,30 @@
.modalWide {
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
flex-shrink: 0;
}
.formFit {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.actionsBar {
position: sticky;
bottom: 0;
z-index: 1;
margin-top: auto;
padding-top: 12px;
background: linear-gradient(
to top,
rgba(255, 255, 255, 0.96) 72%,
rgba(255, 255, 255, 0.78)
);
}
@@ -9,6 +9,9 @@ import styles from './AddWebsiteSliderSlideModal.module.css'
export interface WebsiteSliderSlideFormData {
image: string | null
title: string
titleFa: string
titleEn: string
subtext: string
linkUrl: string
}
@@ -17,6 +20,12 @@ interface AddWebsiteSliderSlideModalProps {
onClose: () => void
onSubmit: (data: WebsiteSliderSlideFormData) => void
isSubmitting?: boolean
aspect?: number
heading?: string
hint?: string
submitLabel?: string
captionMode?: 'simple' | 'bilingual'
initial?: Partial<WebsiteSliderSlideFormData>
}
const ANIMATION_MS = 220
@@ -26,6 +35,12 @@ export function AddWebsiteSliderSlideModal({
onClose,
onSubmit,
isSubmitting = false,
aspect = 9 / 4,
heading,
hint,
submitLabel,
captionMode = 'simple',
initial,
}: AddWebsiteSliderSlideModalProps) {
const t = useT()
const formRef = useRef<HTMLFormElement>(null)
@@ -33,15 +48,21 @@ export function AddWebsiteSliderSlideModal({
const [closing, setClosing] = useState(false)
const [image, setImage] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [subtext, setSubtext] = useState('')
const [linkUrl, setLinkUrl] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setImage(null)
setTitle('')
setLinkUrl('')
setImage(initial?.image ?? null)
setTitle(initial?.title ?? '')
setTitleFa(initial?.titleFa ?? '')
setTitleEn(initial?.titleEn ?? '')
setSubtext(initial?.subtext ?? '')
setLinkUrl(initial?.linkUrl ?? '')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -69,6 +90,9 @@ export function AddWebsiteSliderSlideModal({
onSubmit({
image,
title: title.trim(),
titleFa: titleFa.trim(),
titleEn: titleEn.trim(),
subtext: subtext.trim(),
linkUrl: linkUrl.trim(),
})
}
@@ -85,9 +109,9 @@ export function AddWebsiteSliderSlideModal({
aria-modal="true"
aria-labelledby="add-slide-title"
>
<div className={modalStyles.header}>
<div className={`${modalStyles.header} ${styles.header}`}>
<h3 id="add-slide-title" className={modalStyles.title}>
{t('website.sliders.modal.title')}
{heading ?? t('website.sliders.modal.title')}
</h3>
<button
type="button"
@@ -99,30 +123,77 @@ export function AddWebsiteSliderSlideModal({
</button>
</div>
<form ref={formRef} className={modalStyles.form} onSubmit={handleSubmit}>
<form
ref={formRef}
className={`${modalStyles.form} ${styles.formFit}`}
onSubmit={handleSubmit}
>
<div className={modalStyles.field}>
<label>{t('website.sliders.modal.image')}</label>
<ImageCropper
value={image}
onChange={setImage}
aspect={9 / 4}
aspect={aspect}
uploadLabel={t('website.sliders.modal.upload')}
hint={t('website.sliders.modal.hint')}
hint={hint ?? t('website.sliders.modal.hint')}
changeLabel={t('website.sliders.modal.changeImage')}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="slide-title">{t('website.sliders.modal.titleOptional')}</label>
<input
id="slide-title"
type="text"
placeholder={t('website.sliders.modal.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
/>
</div>
{captionMode === 'bilingual' ? (
<>
<div className={modalStyles.field}>
<label htmlFor="slide-title-fa">{t('website.staticImages.titleFa')}</label>
<input
id="slide-title-fa"
type="text"
dir="rtl"
lang="fa"
placeholder={t('website.staticImages.titleFaPlaceholder')}
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="slide-title-en">{t('website.staticImages.titleEn')}</label>
<input
id="slide-title-en"
type="text"
dir="ltr"
placeholder={t('website.staticImages.titleEnPlaceholder')}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className={modalStyles.field}>
<label htmlFor="slide-subtext">{t('website.staticImages.subtext')}</label>
<textarea
id="slide-subtext"
rows={3}
placeholder={t('website.staticImages.subtextPlaceholder')}
value={subtext}
onChange={(e) => setSubtext(e.target.value)}
disabled={isSubmitting}
/>
</div>
</>
) : (
<div className={modalStyles.field}>
<label htmlFor="slide-title">{t('website.sliders.modal.titleOptional')}</label>
<input
id="slide-title"
type="text"
placeholder={t('website.sliders.modal.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
/>
</div>
)}
<div className={modalStyles.field}>
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
@@ -137,7 +208,7 @@ export function AddWebsiteSliderSlideModal({
/>
</div>
<div className={modalStyles.actions}>
<div className={`${modalStyles.actions} ${styles.actionsBar}`}>
<button
type="button"
className={modalStyles.cancelBtn}
@@ -151,7 +222,7 @@ export function AddWebsiteSliderSlideModal({
className={modalStyles.submitBtn}
disabled={isSubmitting || !image}
>
{isSubmitting ? t('website.adding') : t('website.sliders.modal.addSlide')}
{isSubmitting ? t('website.adding') : submitLabel ?? t('website.sliders.modal.addSlide')}
</button>
</div>
</form>
@@ -101,7 +101,15 @@
}
.field select {
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;
}
.field input:focus,
+18 -1
View File
@@ -26,6 +26,7 @@
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s;
flex-shrink: 0;
}
.menuBtn:hover {
@@ -185,11 +186,21 @@
}
}
@media (max-width: 768px) {
@media (max-width: 1280px) {
.menuBtn {
display: flex;
}
.header {
padding: 12px 20px;
}
.right {
gap: 10px;
}
}
@media (max-width: 768px) {
.profileInfo {
display: none;
}
@@ -199,6 +210,12 @@
}
}
@media (max-width: 640px) {
.iconBtn {
display: none;
}
}
@media (max-width: 480px) {
.header {
padding: 12px 16px;
+19 -4
View File
@@ -6,6 +6,7 @@ import { useAuth } from '../context/AuthContext'
import { useT } from '../i18n/useT'
import { getActiveBusinessId } from '../lib/businessContext'
import { changePassword } from '../services/authService'
import { Tooltip } from './Tooltip'
import styles from './Header.module.css'
function displayUserName(
@@ -31,7 +32,12 @@ function displayUserName(
return localized || other || user.cellNumber || fallback
}
export function Header() {
interface HeaderProps {
navOpen?: boolean
onMenuClick?: () => void
}
export function Header({ navOpen = false, onMenuClick }: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
@@ -92,9 +98,18 @@ export function Header() {
<>
<header className={styles.header}>
<div className={styles.left}>
<button className={styles.menuBtn} aria-label={t('header.toggleMenu')}>
<Menu size={22} />
</button>
<Tooltip label={t('header.toggleMenu')}>
<button
type="button"
className={styles.menuBtn}
aria-label={t('header.toggleMenu')}
aria-expanded={navOpen}
aria-controls="dashboard-sidebar"
onClick={onMenuClick}
>
<Menu size={22} />
</button>
</Tooltip>
</div>
<div className={styles.right}>
@@ -39,6 +39,7 @@
min-height: 120px;
border-radius: var(--radius-sm);
overflow: hidden;
flex-shrink: 0;
border: 1px solid var(--glass-border);
background:
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
@@ -53,6 +54,7 @@
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.previewImgNatural {
@@ -96,7 +98,7 @@
}
.cropAreaPortrait {
max-height: none;
max-height: min(360px, 42vh);
min-height: 0;
}
@@ -83,7 +83,7 @@ export function ImageCropper({
const frameStyle: React.CSSProperties = isPortrait
? {
aspectRatio: `${aspect}`,
height: 'min(480px, 65vh)',
height: 'min(360px, 42vh)',
width: 'auto',
maxWidth: '100%',
marginInline: 'auto',
@@ -91,14 +91,14 @@ export function ImageCropper({
: {
aspectRatio: `${aspect}`,
width: '100%',
maxHeight: 'min(420px, 55vh)',
maxHeight: 'min(280px, 36vh)',
}
return (
<div className={styles.wrapper}>
{value && !imageSrc && (
<div className={styles.preview}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImgNatural} />
<div className={styles.preview} style={frameStyle}>
<img src={value} alt={resolvedUploadLabel} className={styles.previewImg} />
<button
type="button"
className={styles.removeBtn}
@@ -1,5 +1,6 @@
.content {
width: 100%;
min-width: 0;
padding: 32px;
}
@@ -79,10 +80,15 @@
.gridHome {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(12, 1fr);
gap: 20px;
}
/* 6 tiles per row: each tile 2/12, Store 4/12. */
.gridHome > * {
grid-column: span 2;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -112,6 +118,10 @@
grid-column: span 4;
}
.gridHome > .span4 {
grid-column: span 4;
}
.col6 {
grid-column: span 6;
}
@@ -120,14 +130,14 @@
grid-column: span 12;
}
@media (max-width: 1536px) {
.gridHome {
grid-template-columns: repeat(4, 1fr);
gap: 24px;
@media (max-width: 1600px) {
/* 3 tiles per row on laptops */
.gridHome > * {
grid-column: span 4;
}
.span4 {
grid-column: span 2;
.gridHome > .span4 {
grid-column: span 8;
}
}
@@ -137,25 +147,19 @@
}
}
@media (max-width: 1200px) {
.gridHome {
grid-template-columns: repeat(3, 1fr);
}
.span4 {
grid-column: span 2;
}
}
@media (max-width: 900px) {
.gridHome,
@media (max-width: 1100px) {
.grid,
.gridCols4 {
grid-template-columns: repeat(2, 1fr);
}
.span4 {
grid-column: span 2;
/* 2 tiles per row */
.gridHome > * {
grid-column: span 6;
}
.gridHome > .span4 {
grid-column: span 12;
}
}
@@ -179,6 +183,8 @@
.dateBadge {
align-self: flex-start;
max-width: 100%;
white-space: normal;
}
.grid,
@@ -189,7 +195,8 @@
gap: 16px;
}
.span4,
.gridHome > *,
.gridHome > .span4,
.col6 {
grid-column: span 1;
}
@@ -6,12 +6,30 @@
.main {
margin-inline-start: var(--sidebar-width);
min-height: 100vh;
min-width: 0;
position: relative;
z-index: 1;
}
@media (max-width: 768px) {
.navScrim {
display: none;
}
@media (max-width: 1280px) {
.main {
margin-inline-start: 0;
}
.navScrim {
display: block;
position: fixed;
inset: 0;
z-index: 180;
padding: 0;
border: 0;
cursor: pointer;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
}
}
+49 -4
View File
@@ -1,17 +1,62 @@
import { Outlet } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { DraftCartProvider } from '../context/DraftCartContext'
import { useT } from '../i18n/useT'
import { Sidebar } from './Sidebar'
import { Header } from './Header'
import styles from './PageLayout.module.css'
const NAV_EXPAND_QUERY = '(min-width: 1281px)'
export function PageLayout() {
const { pathname } = useLocation()
const t = useT()
const [navOpen, setNavOpen] = useState(false)
useEffect(() => {
setNavOpen(false)
}, [pathname])
useEffect(() => {
const media = window.matchMedia(NAV_EXPAND_QUERY)
function collapseWhenExpanded() {
if (media.matches) setNavOpen(false)
}
media.addEventListener('change', collapseWhenExpanded)
return () => media.removeEventListener('change', collapseWhenExpanded)
}, [])
useEffect(() => {
if (!navOpen) return
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') setNavOpen(false)
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleEscape)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleEscape)
}
}, [navOpen])
return (
<DraftCartProvider>
<div className={styles.layout}>
<Sidebar />
<div className={styles.layout} data-nav-open={navOpen ? 'true' : undefined}>
{navOpen ? (
<button
type="button"
className={styles.navScrim}
aria-label={t('header.closeMenu')}
onClick={() => setNavOpen(false)}
/>
) : null}
<Sidebar open={navOpen} />
<div className={styles.main}>
<Header />
<Header navOpen={navOpen} onMenuClick={() => setNavOpen((open) => !open)} />
<Outlet />
</div>
</div>
@@ -69,6 +69,9 @@
font-weight: 500;
color: var(--text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brandName {
@@ -76,6 +79,9 @@
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav {
@@ -173,8 +179,19 @@
flex-shrink: 0;
}
@media (max-width: 768px) {
@media (max-width: 1280px) {
.sidebar {
display: none;
z-index: 200;
inset-inline-start: calc(-1 * var(--sidebar-width));
visibility: hidden;
pointer-events: none;
transition: inset-inline-start 0.25s ease, box-shadow 0.25s ease, visibility 0.25s;
}
.open {
inset-inline-start: 0;
visibility: visible;
pointer-events: auto;
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18);
}
}
+10 -2
View File
@@ -77,7 +77,11 @@ function isGroupActive(group: NavGroup, pathname: string) {
)
}
export function Sidebar() {
interface SidebarProps {
open?: boolean
}
export function Sidebar({ open = false }: SidebarProps) {
const { pathname } = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
@@ -204,6 +208,7 @@ export function Sidebar() {
basePath: '/website',
children: [
{ labelKey: 'nav.website.overview', to: '/website' },
{ labelKey: 'nav.website.staticImages', to: '/website/static-images' },
{ labelKey: 'nav.website.sliders', to: '/website/sliders' },
{ labelKey: 'nav.website.specialCategories', to: '/website/special-categories' },
{ labelKey: 'nav.website.specialBrands', to: '/website/special-brands' },
@@ -271,7 +276,10 @@ export function Sidebar() {
}
return (
<aside className={styles.sidebar}>
<aside
id="dashboard-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`}
>
<div className={styles.brand}>
{brandLogoUrl ? (
<img
@@ -11,18 +11,27 @@ interface WebsiteSliderGalleryProps {
slides: WebsiteSliderSlide[]
onAddSlide: () => void
onRemoveSlide: (slide: WebsiteSliderSlide) => void
showAdd?: boolean
addLabel?: string
aspectRatio?: string | null
removeLabel?: string
}
export function WebsiteSliderGallery({
slides,
onAddSlide,
onRemoveSlide,
showAdd = true,
addLabel,
aspectRatio,
removeLabel,
}: WebsiteSliderGalleryProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const trackRef = useRef<HTMLDivElement>(null)
const showNav = slides.length > 0
const addText = addLabel ?? t('website.sliders.addSlide')
function scrollBy(direction: -1 | 1) {
const track = trackRef.current
@@ -31,14 +40,14 @@ export function WebsiteSliderGallery({
track.scrollBy({ left: direction * amount, behavior: 'smooth' })
}
const addCard = (
const addCard = showAdd ? (
<div className={`${styles.slideSlot} ${styles.slideSlotAdd}`}>
<Tooltip label={t('website.sliders.addSlide')}>
<Tooltip label={addText}>
<button
type="button"
className={styles.addCard}
onClick={onAddSlide}
aria-label={t('website.sliders.addSlide')}
aria-label={addText}
>
<span className={styles.addIcon}>
<Plus size={28} />
@@ -46,11 +55,16 @@ export function WebsiteSliderGallery({
</button>
</Tooltip>
</div>
)
) : null
const slideCards = slides.map((slide) => (
<div key={slide.id} className={styles.slideSlot}>
<WebsiteSliderSlideCard slide={slide} onRemove={() => onRemoveSlide(slide)} />
<WebsiteSliderSlideCard
slide={slide}
onRemove={() => onRemoveSlide(slide)}
aspectRatio={aspectRatio}
removeLabel={removeLabel}
/>
</div>
))
@@ -7,14 +7,25 @@ import styles from './WebsiteSliderSlideCard.module.css'
interface WebsiteSliderSlideCardProps {
slide: WebsiteSliderSlide
onRemove: () => void
aspectRatio?: string | null
removeLabel?: string
}
export function WebsiteSliderSlideCard({ slide, onRemove }: WebsiteSliderSlideCardProps) {
export function WebsiteSliderSlideCard({
slide,
onRemove,
aspectRatio,
removeLabel,
}: WebsiteSliderSlideCardProps) {
const t = useT()
const cssAspect = aspectRatio?.includes(':')
? aspectRatio.replace(':', ' / ')
: undefined
const removeText = removeLabel ?? t('website.sliders.removeSlide')
return (
<article className={styles.card}>
<div className={styles.imageWrap}>
<div className={styles.imageWrap} style={cssAspect ? { aspectRatio: cssAspect } : undefined}>
<img
src={slide.imageUrl}
alt={slide.title ?? t('website.sliders.slideAlt')}
@@ -25,12 +36,12 @@ export function WebsiteSliderSlideCard({ slide, onRemove }: WebsiteSliderSlideCa
<div className={styles.footer}>
<p className={styles.title}>{slide.title?.trim() || t('website.sliders.untitled')}</p>
<Tooltip label={t('website.sliders.removeSlide')}>
<Tooltip label={removeText}>
<button
type="button"
className={styles.removeBtn}
onClick={onRemove}
aria-label={t('website.sliders.removeSlide')}
aria-label={removeText}
>
<Trash2 size={16} />
</button>
@@ -0,0 +1,131 @@
.track {
display: flex;
align-items: flex-start;
gap: 10px;
overflow-x: auto;
scroll-snap-type: x proximity;
padding: 4px 2px 12px;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.track::-webkit-scrollbar {
height: 6px;
}
.track::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45);
border-radius: 50px;
}
.cell {
flex: 0 0 auto;
scroll-snap-align: start;
min-width: 0;
}
.cell > span {
display: block;
width: 100%;
}
.cell > span > button {
width: 100%;
}
.card {
display: flex;
flex-direction: column;
width: 100%;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--glass-border);
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--glass-shadow);
}
.imageButton {
display: block;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
cursor: pointer;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
min-height: 40px;
}
.title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.removeBtn {
flex-shrink: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.removeBtn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.addCard {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px dashed rgba(var(--primary-rgb) / 0.35);
border-radius: var(--radius);
color: var(--primary);
transition: background 0.2s, border-color 0.2s, transform 0.2s;
}
.addCard:hover {
background: rgba(var(--primary-rgb) / 0.08);
border-color: rgba(var(--primary-rgb) / 0.55);
transform: translateY(-2px);
}
.addIcon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--primary-rgb) / 0.1);
}
@@ -0,0 +1,147 @@
import { Plus, Trash2 } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
import { Tooltip } from './Tooltip'
import styles from './WebsiteStaticImageGallery.module.css'
interface WebsiteStaticImageGalleryProps {
images: WebsiteStaticImageItem[]
aspectRatio?: string | null
itemCount: number | null
addLabel: string
replaceLabel: string
removeLabel: string
onAddAt: (index: number) => void
onRemove: (image: WebsiteStaticImageItem) => void
}
export function WebsiteStaticImageGallery({
images,
aspectRatio,
itemCount,
addLabel,
replaceLabel,
removeLabel,
onAddAt,
onRemove,
}: WebsiteStaticImageGalleryProps) {
const t = useT()
const { locale } = useLocale()
const size = slotPreviewSize(aspectRatio)
const cssAspect = cssAspectRatio(aspectRatio)
const sorted = [...images].sort(
(a, b) => a.sortOrder - b.sortOrder || Number(a.id) - Number(b.id),
)
const fixedCount = itemCount != null && itemCount > 0 ? itemCount : null
const cellCount = fixedCount ?? sorted.length + 1
const assigned = new Map<number, WebsiteStaticImageItem>()
for (const image of sorted) {
if (
fixedCount != null &&
image.sortOrder >= 0 &&
image.sortOrder < fixedCount &&
!assigned.has(image.sortOrder)
) {
assigned.set(image.sortOrder, image)
}
}
if (fixedCount != null) {
const leftovers = sorted.filter((image) => ![...assigned.values()].includes(image))
for (let index = 0; index < fixedCount; index++) {
if (!assigned.has(index) && leftovers.length > 0) {
assigned.set(index, leftovers.shift()!)
}
}
}
const cells = Array.from({ length: cellCount }, (_, index) => {
const isExtraAdd = !fixedCount && index === cellCount - 1
const image = isExtraAdd
? undefined
: (assigned.get(index) ?? (fixedCount ? undefined : sorted[index]))
return { index, image, isExtraAdd }
})
return (
<div className={styles.track}>
{cells.map(({ index, image, isExtraAdd }) => {
const empty = !image
const label = empty
? addLabel
: isExtraAdd
? addLabel
: replaceLabel
return (
<div
key={`${index}-${image?.id ?? 'empty'}`}
className={styles.cell}
style={{ width: size.width }}
>
{image ? (
<article className={styles.card}>
<button
type="button"
className={styles.imageButton}
onClick={() => onAddAt(index)}
aria-label={replaceLabel}
style={{ aspectRatio: cssAspect }}
>
<img
src={image.url}
alt={
(locale === 'fa'
? image.titleFa || image.title || image.titleEn
: image.titleEn || image.titleFa || image.title) ??
t('website.staticImages.slotLabel')
}
className={styles.image}
/>
</button>
<div className={styles.footer}>
<p className={styles.title}>
{(locale === 'fa'
? image.titleFa || image.title || image.titleEn
: image.titleEn || image.titleFa || image.title
)?.trim() ||
image.subtext?.trim() ||
image.linkUrl ||
`${index + 1}`}
</p>
<Tooltip label={removeLabel}>
<button
type="button"
className={styles.removeBtn}
onClick={() => onRemove(image)}
aria-label={removeLabel}
>
<Trash2 size={16} />
</button>
</Tooltip>
</div>
</article>
) : (
<Tooltip label={label}>
<button
type="button"
className={styles.addCard}
onClick={() => onAddAt(index)}
aria-label={label}
style={{ aspectRatio: cssAspect, minHeight: size.height }}
>
<span className={styles.addIcon}>
<Plus size={28} />
</span>
</button>
</Tooltip>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,279 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useT } from '../i18n/useT'
import type { WebsiteStaticImageSlotKind } from '../types/websiteStaticImage'
import {
staticImagePageLabel,
staticImagePageOptions,
} from '../utils/staticImagePages'
import styles from './CategoryModal.module.css'
export interface WebsiteStaticImageSlotFormValues {
key: string
label: string
kind: WebsiteStaticImageSlotKind
aspectRatio: string
recommendedWidth: string
itemCount: string
pageKey: string
}
interface WebsiteStaticImageSlotModalProps {
open: boolean
onClose: () => void
onSubmit: (values: WebsiteStaticImageSlotFormValues) => void
initial?: Partial<WebsiteStaticImageSlotFormValues>
title: string
submitLabel: string
isSubmitting?: boolean
lockKey?: boolean
}
const ANIMATION_MS = 220
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/
const ASPECT_OPTIONS = ['16:9', '16:5', '12:19', '9:4', '4:3', '3:2', '1:1', '21:9']
function slugifyKey(raw: string): string {
const slug = raw
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!slug || !/^[a-z]/.test(slug)) return ''
return KEY_PATTERN.test(slug) ? slug : ''
}
export function WebsiteStaticImageSlotModal({
open,
onClose,
onSubmit,
initial,
title,
submitLabel,
isSubmitting = false,
lockKey = false,
}: WebsiteStaticImageSlotModalProps) {
const t = useT()
const formRef = useRef<HTMLFormElement>(null)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [key, setKey] = useState(initial?.key ?? '')
const [label, setLabel] = useState(initial?.label ?? '')
const [kind, setKind] = useState<WebsiteStaticImageSlotKind>(initial?.kind ?? 'single')
const [aspectRatio, setAspectRatio] = useState(initial?.aspectRatio ?? '16:9')
const [recommendedWidth, setRecommendedWidth] = useState(initial?.recommendedWidth ?? '1440')
const [itemCount, setItemCount] = useState(initial?.itemCount ?? '')
const [pageKey, setPageKey] = useState(initial?.pageKey ?? 'home')
const [keyTouched, setKeyTouched] = useState(Boolean(initial?.key))
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setKey(initial?.key ?? '')
setLabel(initial?.label ?? '')
setKind(initial?.kind ?? 'single')
setAspectRatio(initial?.aspectRatio ?? '16:9')
setRecommendedWidth(initial?.recommendedWidth ?? '1440')
setItemCount(initial?.itemCount ?? '')
setPageKey(initial?.pageKey ?? 'home')
setKeyTouched(Boolean(initial?.key) || lockKey)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
}, [open, mounted, initial, lockKey])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const nextLabel = label.trim()
const nextKey = slugifyKey(key) || slugifyKey(nextLabel)
if (!nextKey || !nextLabel) return
onSubmit({
key: nextKey,
label: nextLabel,
kind,
aspectRatio,
recommendedWidth: recommendedWidth.trim(),
itemCount: kind === 'single' ? '1' : itemCount.trim(),
pageKey,
})
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={(e) => e.target === e.currentTarget && onClose()}
role="presentation"
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
role="dialog"
aria-modal="true"
aria-labelledby="static-image-slot-title"
>
<div className={styles.header}>
<h3 id="static-image-slot-title" className={styles.title}>
{title}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form ref={formRef} className={styles.form} onSubmit={handleSubmit}>
<div className={styles.field}>
<label htmlFor="static-image-slot-page">{t('website.staticImages.page')}</label>
<select
id="static-image-slot-page"
value={pageKey}
onChange={(e) => setPageKey(e.target.value)}
disabled={isSubmitting}
>
{staticImagePageOptions([pageKey]).map((option) => (
<option key={option} value={option}>
{staticImagePageLabel(option, t)}
</option>
))}
</select>
</div>
<div className={styles.field}>
<label htmlFor="static-image-slot-label">{t('website.staticImages.slotLabel')}</label>
<input
id="static-image-slot-label"
type="text"
value={label}
onChange={(e) => {
const next = e.target.value
setLabel(next)
if (!keyTouched && !lockKey) setKey(slugifyKey(next))
}}
placeholder={t('website.staticImages.slotLabelPlaceholder')}
disabled={isSubmitting}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="static-image-slot-key">{t('website.staticImages.slotKey')}</label>
<input
id="static-image-slot-key"
type="text"
dir="ltr"
value={key}
onChange={(e) => {
setKeyTouched(true)
setKey(e.target.value)
}}
placeholder="home-hero"
disabled={isSubmitting || lockKey}
required
/>
</div>
<div className={styles.field}>
<label htmlFor="static-image-slot-kind">{t('website.staticImages.kind')}</label>
<select
id="static-image-slot-kind"
value={kind}
onChange={(e) => setKind(e.target.value as WebsiteStaticImageSlotKind)}
disabled={isSubmitting}
>
<option value="single">{t('website.staticImages.kindSingle')}</option>
<option value="list">{t('website.staticImages.kindList')}</option>
</select>
</div>
{kind === 'list' ? (
<div className={styles.field}>
<label htmlFor="static-image-slot-count">{t('website.staticImages.itemCount')}</label>
<input
id="static-image-slot-count"
type="text"
inputMode="numeric"
dir="ltr"
value={itemCount}
onChange={(e) => setItemCount(e.target.value.replace(/[^\d]/g, ''))}
placeholder={t('website.staticImages.itemCountPlaceholder')}
disabled={isSubmitting}
/>
<p className={styles.hint}>{t('website.staticImages.itemCountHint')}</p>
</div>
) : null}
<div className={styles.field}>
<label htmlFor="static-image-slot-aspect">{t('website.staticImages.aspectRatio')}</label>
<select
id="static-image-slot-aspect"
value={aspectRatio}
onChange={(e) => setAspectRatio(e.target.value)}
disabled={isSubmitting}
>
{ASPECT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
{aspectRatio && !ASPECT_OPTIONS.includes(aspectRatio) ? (
<option value={aspectRatio}>{aspectRatio}</option>
) : null}
</select>
</div>
<div className={styles.field}>
<label htmlFor="static-image-slot-width">
{t('website.staticImages.recommendedWidth')}
</label>
<input
id="static-image-slot-width"
type="text"
inputMode="numeric"
dir="ltr"
value={recommendedWidth}
onChange={(e) => setRecommendedWidth(e.target.value.replace(/[^\d]/g, ''))}
placeholder="1440"
disabled={isSubmitting}
/>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('products.form.cancel')}
</button>
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
{isSubmitting ? t('website.saving') : submitLabel}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}