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