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
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user