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
-4
View File
@@ -10,10 +10,6 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
rel="stylesheet"
/>
<title>Business Dashboard</title>
<script>
try {
+2
View File
@@ -68,6 +68,7 @@ import { WebsiteFaqPage } from './pages/WebsiteFaqPage'
import { WebsiteBadgesPage } from './pages/WebsiteBadgesPage'
import { SettingsPage } from './pages/SettingsPage'
import { WebsiteSlidersPage } from './pages/WebsiteSlidersPage'
import { WebsiteStaticImagesPage } from './pages/WebsiteStaticImagesPage'
import { WebsiteSpecialCategoriesPage } from './pages/WebsiteSpecialCategoriesPage'
import { WebsiteSpecialBrandsPage } from './pages/WebsiteSpecialBrandsPage'
@@ -148,6 +149,7 @@ function App() {
<Route path="instructions/categories" element={<InstructionCategoriesPage />} />
<Route path="instructions/settings" element={<InstructionSettingsPage />} />
<Route path="website" element={<WebsitePage />} />
<Route path="website/static-images" element={<WebsiteStaticImagesPage />} />
<Route path="website/sliders" element={<WebsiteSlidersPage />} />
<Route path="website/special-categories" element={<WebsiteSpecialCategoriesPage />} />
<Route path="website/special-brands" element={<WebsiteSpecialBrandsPage />} />
@@ -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,
)
}
+25 -1
View File
@@ -1,4 +1,4 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install. */
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
@@ -14,3 +14,27 @@
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+128
View File
@@ -69,6 +69,7 @@ const en = {
'nav.instructions.settings': 'Settings',
'nav.website': 'Website',
'nav.website.overview': 'Overview',
'nav.website.staticImages': 'Static Images',
'nav.website.sliders': 'Sliders',
'nav.website.specialCategories': 'Special Categories',
'nav.website.specialBrands': 'Special Brands',
@@ -82,6 +83,7 @@ const en = {
'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu',
'header.closeMenu': 'Close menu',
'header.title': 'Admin Dashboard',
'header.messages': 'Messages',
'header.notifications': 'Notifications',
@@ -1252,6 +1254,9 @@ const en = {
'portfolio.details.galleryImage': 'View gallery image {index}',
'website.page.subtitle': 'Manage public website content, forms, and customer-facing settings.',
'website.card.staticImages.desc':
'Replace named website images (heroes, banners, galleries) without a deploy.',
'website.card.staticImages.link': 'Manage images',
'website.card.sliders.desc': 'Manage homepage banner sliders and promotional image carousels.',
'website.card.sliders.link': 'Manage sliders',
'website.card.specialCategories.desc':
@@ -1325,6 +1330,63 @@ const en = {
'website.sliders.modal.linkOptional': 'Link URL (optional)',
'website.sliders.modal.addSlide': 'Add slide',
'website.staticImages.subtitle':
'Websites load these by key from GET /tenants/{domain}/website/static-images',
'website.staticImages.loading': 'Loading static images...',
'website.staticImages.empty': 'No image slots yet for this website.',
'website.staticImages.emptyPage': 'No static images on this page yet.',
'website.staticImages.errorLoad': 'Unable to load static images.',
'website.staticImages.errorCreate': 'Unable to create image slot.',
'website.staticImages.errorUpdate': 'Unable to update image slot.',
'website.staticImages.errorDelete': 'Unable to delete image slot.',
'website.staticImages.errorUpload': 'Unable to upload image.',
'website.staticImages.errorAddImage': 'Unable to save image.',
'website.staticImages.errorRemoveImage': 'Unable to remove image.',
'website.staticImages.toastCreated': 'Image slot created.',
'website.staticImages.toastUpdated': 'Image slot updated.',
'website.staticImages.toastDeleted': 'Image slot deleted.',
'website.staticImages.toastImageSaved': 'Image saved.',
'website.staticImages.toastImageRemoved': 'Image removed.',
'website.staticImages.addSlot': 'Add image slot',
'website.staticImages.createTitle': 'Add image slot',
'website.staticImages.editTitle': 'Edit image slot',
'website.staticImages.deleteTitle': 'Delete image slot',
'website.staticImages.deleteMessage':
'Delete slot {label}? The website placeholder will have no image until you add one again.',
'website.staticImages.addImage': 'Add image',
'website.staticImages.replaceImage': 'Replace image',
'website.staticImages.removeImage': 'Remove image',
'website.staticImages.removeImageTitle': 'Remove image',
'website.staticImages.removeImageMessage': 'Remove this image from the slot?',
'website.staticImages.slotLabel': 'Label',
'website.staticImages.slotLabelPlaceholder': 'e.g. Home hero',
'website.staticImages.slotKey': 'Key (matches website placeholder)',
'website.staticImages.kind': 'Type',
'website.staticImages.page': 'Page',
'website.staticImages.pageHome': 'Home',
'website.staticImages.pageProducts': 'Products',
'website.staticImages.pageProduct': 'Product',
'website.staticImages.pageCategories': 'Categories',
'website.staticImages.pageBrands': 'Brands',
'website.staticImages.pageBlog': 'Blog',
'website.staticImages.pageAbout': 'About',
'website.staticImages.pageContact': 'Contact',
'website.staticImages.kindSingle': 'Single image',
'website.staticImages.kindList': 'List (add more)',
'website.staticImages.kindFixed': '{count} images',
'website.staticImages.itemCount': 'Number of images',
'website.staticImages.itemCountPlaceholder': 'Empty = add more freely',
'website.staticImages.itemCountHint': 'e.g. 2 for a two-banner row. Leave empty for an unlimited list.',
'website.staticImages.aspectRatio': 'Aspect ratio',
'website.staticImages.recommendedWidth': 'Recommended width (px)',
'website.staticImages.cropHint': '{ratio} recommended',
'website.staticImages.titleFa': 'Title (FA)',
'website.staticImages.titleEn': 'Title (EN)',
'website.staticImages.titleFaPlaceholder': 'Persian title',
'website.staticImages.titleEnPlaceholder': 'English title',
'website.staticImages.subtext': 'Subtext',
'website.staticImages.subtextPlaceholder': 'Optional caption under the title',
'website.specialCategories.subtitle':
'Curate featured product categories into groups for your website homepage.',
'website.specialCategories.loading': 'Loading special category groups...',
@@ -1542,6 +1604,7 @@ const en = {
'title.editPortfolio': 'Edit Portfolio',
'title.portfolioDetails': 'Portfolio Details',
'title.website': 'Website',
'title.staticImages': 'Static Images',
'title.sliders': 'Sliders',
'title.specialCategories': 'Special Categories',
'title.specialBrands': 'Special Brands',
@@ -1922,6 +1985,7 @@ const fa: Record<MessageKey, string> = {
'nav.instructions.settings': 'تنظیمات',
'nav.website': 'وب‌سایت',
'nav.website.overview': 'نمای کلی',
'nav.website.staticImages': 'تصاویر ثابت',
'nav.website.sliders': 'اسلایدرها',
'nav.website.specialCategories': 'دسته‌های ویژه',
'nav.website.specialBrands': 'برندهای ویژه',
@@ -1935,6 +1999,7 @@ const fa: Record<MessageKey, string> = {
'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو',
'header.closeMenu': 'بستن منو',
'header.title': 'پنل مدیریت',
'header.messages': 'پیام‌ها',
'header.notifications': 'اعلان‌ها',
@@ -3105,6 +3170,9 @@ const fa: Record<MessageKey, string> = {
'website.page.subtitle':
'محتوای عمومی وب‌سایت، فرم‌ها و تنظیمات مشتری‌محور را مدیریت کنید.',
'website.card.staticImages.desc':
'تصاویر نام‌گذاری‌شده وب‌سایت (هیرو، بنر، گالری) را بدون دیپلوی عوض کنید.',
'website.card.staticImages.link': 'مدیریت تصاویر',
'website.card.sliders.desc': 'اسلایدرهای بنر صفحه اصلی و کاروسل‌های تبلیغاتی را مدیریت کنید.',
'website.card.sliders.link': 'مدیریت اسلایدرها',
'website.card.specialCategories.desc':
@@ -3177,6 +3245,63 @@ const fa: Record<MessageKey, string> = {
'website.sliders.modal.linkOptional': 'آدرس لینک (اختیاری)',
'website.sliders.modal.addSlide': 'افزودن اسلاید',
'website.staticImages.subtitle':
'وب‌سایت‌ها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images می‌خوانند',
'website.staticImages.loading': 'در حال بارگذاری تصاویر ثابت...',
'website.staticImages.empty': 'هنوز اسلاتی برای این وب‌سایت نیست.',
'website.staticImages.emptyPage': 'در این صفحه هنوز تصویر ثابتی نیست.',
'website.staticImages.errorLoad': 'بارگذاری تصاویر ثابت ممکن نشد.',
'website.staticImages.errorCreate': 'ساخت اسلات تصویر ممکن نشد.',
'website.staticImages.errorUpdate': 'به‌روزرسانی اسلات ممکن نشد.',
'website.staticImages.errorDelete': 'حذف اسلات ممکن نشد.',
'website.staticImages.errorUpload': 'آپلود تصویر ممکن نشد.',
'website.staticImages.errorAddImage': 'ذخیره تصویر ممکن نشد.',
'website.staticImages.errorRemoveImage': 'حذف تصویر ممکن نشد.',
'website.staticImages.toastCreated': 'اسلات تصویر ساخته شد.',
'website.staticImages.toastUpdated': 'اسلات تصویر به‌روز شد.',
'website.staticImages.toastDeleted': 'اسلات تصویر حذف شد.',
'website.staticImages.toastImageSaved': 'تصویر ذخیره شد.',
'website.staticImages.toastImageRemoved': 'تصویر حذف شد.',
'website.staticImages.addSlot': 'افزودن اسلات تصویر',
'website.staticImages.createTitle': 'افزودن اسلات تصویر',
'website.staticImages.editTitle': 'ویرایش اسلات تصویر',
'website.staticImages.deleteTitle': 'حذف اسلات تصویر',
'website.staticImages.deleteMessage':
'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جای‌نگهدار وب‌سایت خالی می‌ماند.',
'website.staticImages.addImage': 'افزودن تصویر',
'website.staticImages.replaceImage': 'جایگزینی تصویر',
'website.staticImages.removeImage': 'حذف تصویر',
'website.staticImages.removeImageTitle': 'حذف تصویر',
'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟',
'website.staticImages.slotLabel': 'برچسب',
'website.staticImages.slotLabelPlaceholder': 'مثلاً: هیرو صفحه اصلی',
'website.staticImages.slotKey': 'کلید (مطابق جای‌نگهدار وب‌سایت)',
'website.staticImages.kind': 'نوع',
'website.staticImages.page': 'صفحه',
'website.staticImages.pageHome': 'خانه',
'website.staticImages.pageProducts': 'محصولات',
'website.staticImages.pageProduct': 'محصول',
'website.staticImages.pageCategories': 'دسته‌بندی‌ها',
'website.staticImages.pageBrands': 'برندها',
'website.staticImages.pageBlog': 'بلاگ',
'website.staticImages.pageAbout': 'درباره ما',
'website.staticImages.pageContact': 'تماس',
'website.staticImages.kindSingle': 'یک تصویر',
'website.staticImages.kindList': 'فهرست (قابل افزودن)',
'website.staticImages.kindFixed': '{count} تصویر',
'website.staticImages.itemCount': 'تعداد تصویر',
'website.staticImages.itemCountPlaceholder': 'خالی = افزودن آزاد',
'website.staticImages.itemCountHint': 'مثلاً ۲ برای ردیف دو بنر. خالی بگذارید تا فهرست نامحدود باشد.',
'website.staticImages.aspectRatio': 'نسبت تصویر',
'website.staticImages.recommendedWidth': 'عرض پیشنهادی (پیکسل)',
'website.staticImages.cropHint': 'نسبت {ratio} توصیه می‌شود',
'website.staticImages.titleFa': 'عنوان (فارسی)',
'website.staticImages.titleEn': 'عنوان (انگلیسی)',
'website.staticImages.titleFaPlaceholder': 'عنوان فارسی',
'website.staticImages.titleEnPlaceholder': 'English title',
'website.staticImages.subtext': 'متن فرعی',
'website.staticImages.subtextPlaceholder': 'متن کوتاه زیر عنوان (اختیاری)',
'website.specialCategories.subtitle':
'دسته‌بندی‌های ویژه محصول را برای صفحه اصلی وب‌سایت در گروه‌ها گردآوری کنید.',
'website.specialCategories.loading': 'در حال بارگذاری گروه‌های دسته ویژه...',
@@ -3394,6 +3519,7 @@ const fa: Record<MessageKey, string> = {
'title.editPortfolio': 'ویرایش نمونه کار',
'title.portfolioDetails': 'جزئیات نمونه کار',
'title.website': 'وب‌سایت',
'title.staticImages': 'تصاویر ثابت',
'title.sliders': 'اسلایدرها',
'title.specialCategories': 'دسته‌های ویژه',
'title.specialBrands': 'برندهای ویژه',
@@ -3754,6 +3880,7 @@ const BREADCRUMB_LABEL_KEYS: Record<string, MessageKey> = {
'Edit Video': 'bc.editVideo',
'Video Details': 'bc.videoDetails',
Website: 'nav.website',
'Static Images': 'nav.website.staticImages',
Sliders: 'nav.website.sliders',
'Special Categories': 'nav.website.specialCategories',
'Special Brands': 'nav.website.specialBrands',
@@ -3867,6 +3994,7 @@ export function getBusinessRouteTitleRules(locale: DashboardLocale): RouteTitleR
{ match: '/portfolios/categories', labels: [t('title.portfolios'), t('title.categories')] },
{ match: '/portfolios/settings', labels: [t('title.portfolios'), t('title.settings')] },
{ match: '/portfolios', labels: [t('title.portfolios')] },
{ match: '/website/static-images', labels: [t('title.website'), t('title.staticImages')] },
{ match: '/website/sliders', labels: [t('title.website'), t('title.sliders')] },
{
match: '/website/special-categories',
+5 -3
View File
@@ -45,9 +45,11 @@
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
/* Named faces only — generic/system families in --font-en intercept Persian glyphs. */
--font-en: 'Montserrat';
--font-fa: 'IRANYekan', 'IranYekan';
--font-ui: var(--font-en), var(--font-fa), -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma,
sans-serif;
}
html {
@@ -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>
)
}
@@ -0,0 +1,70 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CreateWebsiteStaticImageSlotPayload,
UpdateWebsiteStaticImageSlotPayload,
WebsiteStaticImageItem,
WebsiteStaticImageSlotsListResponse,
WebsiteStaticImageSlot,
} from '../types/websiteStaticImage'
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/website/static-images${suffix}`
}
export async function listWebsiteStaticImageSlots(
page = 1,
pageSize = 50,
signal?: AbortSignal,
) {
return apiRequest<WebsiteStaticImageSlotsListResponse>(
`${businessPath()}?page=${page}&pageSize=${pageSize}`,
{ auth: true, signal },
)
}
export async function createWebsiteStaticImageSlot(
payload: CreateWebsiteStaticImageSlotPayload,
) {
return apiRequest<{ message: string; slot: WebsiteStaticImageSlot }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateWebsiteStaticImageSlot(
slotId: string,
payload: UpdateWebsiteStaticImageSlotPayload,
) {
return apiRequest<{ message: string; slot: WebsiteStaticImageSlot }>(
businessPath(`/${slotId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteWebsiteStaticImageSlot(slotId: string) {
return apiRequest<{ message: string }>(businessPath(`/${slotId}`), {
method: 'DELETE',
auth: true,
})
}
export function staticImageItemsToPayload(images: WebsiteStaticImageItem[]) {
return images.map((image) => ({
imageMediaId: image.imageMediaId,
sortOrder: image.sortOrder,
...(image.titleFa || image.title ? { titleFa: image.titleFa || image.title || undefined } : {}),
...(image.titleEn ? { titleEn: image.titleEn } : {}),
...(image.subtext ? { subtext: image.subtext } : {}),
...(image.linkUrl ? { linkUrl: image.linkUrl } : {}),
}))
}
@@ -0,0 +1,71 @@
export type WebsiteStaticImageSlotKind = 'single' | 'list'
export interface WebsiteStaticImageItem {
id: string
imageMediaId: string
url: string
title: string | null
titleFa: string | null
titleEn: string | null
subtext: string | null
linkUrl: string | null
width: number | null
height: number | null
sortOrder: number
}
export interface WebsiteStaticImageSlot {
id: string
key: string
kind: WebsiteStaticImageSlotKind
label: string
aspectRatio: string | null
recommendedWidth: number | null
itemCount: number | null
pageKey: string
sortOrder: number
createdAt: string
updatedAt: string
images: WebsiteStaticImageItem[]
}
export interface WebsiteStaticImageSlotsListResponse {
items: WebsiteStaticImageSlot[]
total: number
page: number
pageSize: number
}
export interface WebsiteStaticImageItemInput {
imageMediaId: string
title?: string
titleFa?: string
titleEn?: string
subtext?: string
linkUrl?: string
sortOrder?: number
}
export interface CreateWebsiteStaticImageSlotPayload {
key: string
label: string
kind: WebsiteStaticImageSlotKind
aspectRatio?: string
recommendedWidth?: number
itemCount?: number
pageKey?: string
items?: WebsiteStaticImageItemInput[]
sortOrder?: number
}
export interface UpdateWebsiteStaticImageSlotPayload {
key?: string
label?: string
kind?: WebsiteStaticImageSlotKind
aspectRatio?: string
recommendedWidth?: number | null
itemCount?: number | null
pageKey?: string
items?: WebsiteStaticImageItemInput[]
sortOrder?: number
}
+27
View File
@@ -0,0 +1,27 @@
export function parseAspectRatio(value?: string | null): number {
if (!value) return 16 / 9
const [widthRaw, heightRaw] = value.split(':')
const width = Number(widthRaw)
const height = Number(heightRaw)
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 16 / 9
}
return width / height
}
export function cssAspectRatio(value?: string | null): string {
if (!value?.includes(':')) return '16 / 9'
return value.replace(':', ' / ')
}
/** Preview box that keeps the slot ratio: landscape is wide, portrait is tall. */
export function slotPreviewSize(value?: string | null): { width: number; height: number } {
const aspect = parseAspectRatio(value)
const long = 220
if (aspect >= 1) {
const width = Math.min(360, Math.round(long * aspect))
return { width, height: Math.round(width / aspect) }
}
const height = long
return { width: Math.round(height * aspect), height }
}
@@ -1,5 +1,6 @@
export const BUSINESS_PRIMARY_COLOR_IDS = [
'red',
'dark-red',
'yellow',
'black',
'cyan',
@@ -59,6 +60,16 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
'dark-red': {
label: 'Dark Red',
primary: '#991b1b',
primaryDark: '#7f1d1d',
primaryLight: '#fee2e2',
primaryGlow: '#b91c1c',
primaryRgb: '153 27 27',
primaryDarkRgb: '127 29 29',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
primary: '#eab308',
@@ -0,0 +1,38 @@
import type { BusinessMessageKey } from '../i18n/messages'
export const STATIC_IMAGE_PAGE_KEYS = [
'home',
'products',
'product',
'categories',
'brands',
'blog',
'about',
'contact',
] as const
const PAGE_LABELS: Record<string, BusinessMessageKey> = {
home: 'website.staticImages.pageHome',
products: 'website.staticImages.pageProducts',
product: 'website.staticImages.pageProduct',
categories: 'website.staticImages.pageCategories',
brands: 'website.staticImages.pageBrands',
blog: 'website.staticImages.pageBlog',
about: 'website.staticImages.pageAbout',
contact: 'website.staticImages.pageContact',
}
export function staticImagePageLabel(
pageKey: string,
t: (key: BusinessMessageKey, vars?: Record<string, string | number>) => string,
) {
const mapped = PAGE_LABELS[pageKey]
return mapped ? t(mapped) : pageKey
}
export function staticImagePageOptions(existing: string[]) {
const extras = [...new Set(existing.filter(Boolean))].filter(
(key) => !(STATIC_IMAGE_PAGE_KEYS as readonly string[]).includes(key),
)
return [...STATIC_IMAGE_PAGE_KEYS, ...extras]
}
-4
View File
@@ -10,10 +10,6 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
rel="stylesheet"
/>
<title>Customer Dashboard</title>
<script>
try {
+18 -1
View File
@@ -69,6 +69,7 @@
border-radius: 8px;
color: var(--text-secondary);
transition: background 0.2s;
flex-shrink: 0;
}
.menuBtn:hover {
@@ -256,11 +257,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;
}
@@ -270,6 +281,12 @@
}
}
@media (max-width: 640px) {
.iconBtn {
display: none;
}
}
@media (max-width: 480px) {
.header {
padding: 12px 16px;
+16 -2
View File
@@ -39,9 +39,16 @@ interface HeaderProps {
hideMenu?: boolean
/** Show website logo + name on the start side (visual right in RTL). */
showBrand?: boolean
navOpen?: boolean
onMenuClick?: () => void
}
export function Header({ hideMenu = false, showBrand = false }: HeaderProps) {
export function Header({
hideMenu = false,
showBrand = false,
navOpen = false,
onMenuClick,
}: HeaderProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
@@ -129,7 +136,14 @@ export function Header({ hideMenu = false, showBrand = false }: HeaderProps) {
</div>
</div>
) : !hideMenu ? (
<button className={styles.menuBtn} aria-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>
) : null}
@@ -1,5 +1,6 @@
.content {
width: 100%;
min-width: 0;
padding: 32px;
}
@@ -115,10 +116,19 @@
min-height: 0;
}
@media (max-width: 1400px) {
@media (max-width: 1600px) {
.gridHome {
grid-template-columns: repeat(3, 1fr);
}
.col6,
.col4 {
grid-column: span 12;
}
.col3 {
grid-column: span 6;
}
}
@media (max-width: 1100px) {
@@ -150,6 +160,8 @@
.dateBadge {
align-self: flex-start;
max-width: 100%;
white-space: normal;
}
.grid,
@@ -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,15 +1,60 @@
import { Outlet } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
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 (
<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>
@@ -66,6 +66,9 @@
color: var(--text-muted);
margin-top: 2px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brandName {
@@ -73,6 +76,9 @@
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav {
@@ -170,8 +176,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);
}
}
+9 -2
View File
@@ -12,7 +12,11 @@ import { hasBusinessModule } from '../utils/businessModules'
import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css'
export function Sidebar() {
interface SidebarProps {
open?: boolean
}
export function Sidebar({ open = false }: SidebarProps) {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { locale } = useLocale()
@@ -65,7 +69,10 @@ export function Sidebar() {
}, [businessDomain, fallbackBusinessName, locale])
return (
<aside className={styles.sidebar}>
<aside
id="dashboard-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`}
>
<div className={styles.brand}>
<img
src={logoUrl || meshkeeLogo}
+16
View File
@@ -14,6 +14,22 @@
font-display: swap;
}
@font-face {
font-family: 'YekanBakh';
src: url('/fonts/yekanbakh/YekanBakh-Regular.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'YekanBakh';
src: url('/fonts/yekanbakh/YekanBakh-Bold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'YekanBakh';
src: url('/fonts/yekanbakh/YekanBakh-Bold.woff2') format('woff2');
+2
View File
@@ -19,6 +19,7 @@ const en = {
'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu',
'header.closeMenu': 'Close menu',
'header.messages': 'Messages',
'header.notifications': 'Notifications',
'header.changePassword': 'Change password',
@@ -407,6 +408,7 @@ const fa: Record<MessageKey, string> = {
'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو',
'header.closeMenu': 'بستن منو',
'header.messages': 'پیام‌ها',
'header.notifications': 'اعلان‌ها',
'header.changePassword': 'تغییر رمز عبور',
+6 -4
View File
@@ -1,10 +1,12 @@
@import '@meshkee/dashboard-core/styles/tokens.css';
/* Customer: Montserrat (EN) + Yekan Bakh (FA) via --font-ui stack */
/* Customer: Montserrat (EN) + Yekan Bakh (FA) via --font-ui stack.
Named faces only in --font-en/--font-fa so Persian reaches YekanBakh. */
:root {
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'YekanBakh', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
--font-en: 'Montserrat';
--font-fa: 'YekanBakh';
--font-ui: var(--font-en), var(--font-fa), -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma,
sans-serif;
/* Neutral light surfaces (theme color stays on accents only) */
--bg-gradient-start: #f4f5f7;
-1
View File
@@ -1,6 +1,5 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './fonts/iranyekan.css'
import './fonts/yekanbakh.css'
import './index.css'
import App from './App.tsx'
@@ -67,7 +67,7 @@
}
}
@media (min-width: 1280px) {
@media (min-width: 1600px) {
.grid {
grid-template-columns: repeat(6, 1fr);
}
@@ -84,7 +84,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 {
@@ -1,5 +1,6 @@
export const BUSINESS_PRIMARY_COLOR_IDS = [
'red',
'dark-red',
'yellow',
'black',
'cyan',
@@ -59,6 +60,16 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
'dark-red': {
label: 'Dark Red',
primary: '#991b1b',
primaryDark: '#7f1d1d',
primaryLight: '#fee2e2',
primaryGlow: '#b91c1c',
primaryRgb: '153 27 27',
primaryDarkRgb: '127 29 29',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
primary: '#eab308',
-4
View File
@@ -10,10 +10,6 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.fontcdn.ir/Font/Persian/IranYekan/IranYekan.css"
rel="stylesheet"
/>
<title>Meshkee Super Admin</title>
</head>
<body>
+25 -1
View File
@@ -1,4 +1,4 @@
/* Self-host licensed Iran Yekan files in public/fonts/iranyekan/ */
/* Self-hosted Iran Yekan — served from public/fonts/iranyekan/ so it loads without a local install. */
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebLight.ttf') format('truetype');
@@ -14,3 +14,27 @@
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IRANYekan';
src: url('/fonts/iranyekan/IRANYekanWebRegular.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
+5 -3
View File
@@ -43,9 +43,11 @@
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
--font-ui: var(--font-en), var(--font-fa);
/* Named faces only — generic/system families in --font-en intercept Persian glyphs. */
--font-en: 'Montserrat';
--font-fa: 'IRANYekan', 'IranYekan';
--font-ui: var(--font-en), var(--font-fa), -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma,
sans-serif;
--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;
@@ -1,5 +1,6 @@
export const BUSINESS_PRIMARY_COLOR_IDS = [
'red',
'dark-red',
'yellow',
'black',
'cyan',
@@ -59,6 +60,16 @@ export const BUSINESS_PRIMARY_COLOR_PALETTE: Record<
primaryDarkRgb: '220 38 38',
...PURPLE_ACCENT,
},
'dark-red': {
label: 'Dark Red',
primary: '#991b1b',
primaryDark: '#7f1d1d',
primaryLight: '#fee2e2',
primaryGlow: '#b91c1c',
primaryRgb: '153 27 27',
primaryDarkRgb: '127 29 29',
...PURPLE_ACCENT,
},
yellow: {
label: 'Yellow',
primary: '#eab308',
+5 -1
View File
@@ -214,6 +214,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/portfolios/categories` | Portfolio category tree | Yes |
| `/portfolios/settings` | Comment moderation | Yes |
| `/website` | Website hub | Placeholder |
| `/website/static-images` | Named website image slots, grouped by page | Yes |
| `/website/sliders` | Homepage sliders | Yes |
| `/website/special-categories` | Featured category groups | Yes |
| `/website/special-brands` | Featured brand groups | Yes |
@@ -296,6 +297,9 @@ Products accept optional `brandId` on create/update.
- `GET/POST/PATCH/DELETE /businesses/:businessId/website/category-groups` — groups with `categoryIds[]`
- `GET/POST/PATCH/DELETE /businesses/:businessId/website/brand-groups` — groups with `brandIds[]`
- `GET /tenants/:host/website/category-groups` — public category groups
- `GET/POST/PATCH/DELETE /businesses/:businessId/website/static-images` — named image slots (`single` or `list`) with `pageKey` + per-image `titleFa` / `titleEn` / `subtext` / `linkUrl`
- `GET /tenants/:host/website/static-images` — public slots (`?pageKey=home` to filter by page)
- `GET /tenants/:host/website/static-images/:key` — one public slot
- `GET/POST/PATCH/DELETE /businesses/:businessId/website/sliders` — sliders with `slides[]` (imageMediaId, title, linkUrl)
- `GET /tenants/:host/website/sliders` — public sliders
@@ -462,7 +466,7 @@ Match **Super Admin** compact field sizing across all dashboards — see `.curso
Global input sizing lives in `packages/dashboard-core/src/styles/tokens.css` (customer) and each app’s `index.css` (business / super-admin). Change password → shared `PasswordResetModal` from `@meshkee/dashboard-ui`.
- **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`
- **Farsi text:** Customer app → Yekan Bakh (`src/fonts/yekanbakh.css`); business/super-admin → IranYekan (`iranyekan.css`). `--font-en` / `--font-fa` are named faces only; `--font-ui` appends system fallbacks. Self-host files in `public/fonts/` — 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 + 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`
@@ -47,10 +47,13 @@
--field-padding-y: 9px;
--field-padding-x: 12px;
--field-height: 38px;
--font-en: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-fa: 'IRANYekan', 'IranYekan', 'Yekan', Tahoma, sans-serif;
/* Latin → Montserrat first; Arabic/Persian glyphs fall through to --font-fa */
--font-ui: var(--font-en), var(--font-fa);
/* Named faces only — do not put generic/system families in --font-en or --font-fa.
Those always have Persian glyphs and would intercept before the self-hosted FA font. */
--font-en: 'Montserrat';
--font-fa: 'IRANYekan', 'IranYekan';
/* Latin → Montserrat; Arabic/Persian → IRANYekan; then system fallbacks */
--font-ui: var(--font-en), var(--font-fa), -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma,
sans-serif;
--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;