Add a reusable website link generator and polish static image cards.
Editors can pick product, store, blog, portfolio, or video category links, and image cards expose compact info/link actions without dumping URLs in the footer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b9024ad77a
commit
e0e68de30f
@@ -11,20 +11,35 @@
|
||||
}
|
||||
|
||||
.formFit {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.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)
|
||||
);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.linkRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.linkRow input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { ImageCropper } from './ImageCropper'
|
||||
import { GenerateWebsiteLinkButton } from './GenerateWebsiteLinkModal'
|
||||
import { useT } from '../i18n/useT'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import styles from './AddWebsiteSliderSlideModal.module.css'
|
||||
@@ -128,17 +129,18 @@ export function AddWebsiteSliderSlideModal({
|
||||
className={`${modalStyles.form} ${styles.formFit}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className={modalStyles.field}>
|
||||
<label>{t('website.sliders.modal.image')}</label>
|
||||
<ImageCropper
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
aspect={aspect}
|
||||
uploadLabel={t('website.sliders.modal.upload')}
|
||||
hint={hint ?? t('website.sliders.modal.hint')}
|
||||
changeLabel={t('website.sliders.modal.changeImage')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.fields}>
|
||||
<div className={modalStyles.field}>
|
||||
<label>{t('website.sliders.modal.image')}</label>
|
||||
<ImageCropper
|
||||
value={image}
|
||||
onChange={setImage}
|
||||
aspect={aspect}
|
||||
uploadLabel={t('website.sliders.modal.upload')}
|
||||
hint={hint ?? t('website.sliders.modal.hint')}
|
||||
changeLabel={t('website.sliders.modal.changeImage')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{captionMode === 'bilingual' ? (
|
||||
<>
|
||||
@@ -197,15 +199,20 @@ export function AddWebsiteSliderSlideModal({
|
||||
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
|
||||
<input
|
||||
id="slide-link"
|
||||
type="url"
|
||||
dir="ltr"
|
||||
placeholder="https://example.com/promo"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className={styles.linkRow}>
|
||||
<input
|
||||
id="slide-link"
|
||||
type="text"
|
||||
inputMode="url"
|
||||
dir="ltr"
|
||||
placeholder="https://example.com/promo"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<GenerateWebsiteLinkButton onPick={setLinkUrl} disabled={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.actions} ${styles.actionsBar}`}>
|
||||
@@ -227,8 +234,7 @@ export function AddWebsiteSliderSlideModal({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
,
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,32 @@
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.overlayIn {
|
||||
animation: overlayFadeIn 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.overlayOut {
|
||||
animation: overlayFadeOut 0.18s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
@@ -103,12 +129,19 @@
|
||||
.field select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding-right: var(--select-padding-end);
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x);
|
||||
font-size: var(--field-font-size);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
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);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 var(--field-padding-x);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputWrapOpen,
|
||||
.inputWrap:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: var(--field-padding-y) 0;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.clearBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.clearBtn:hover {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fieldChevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fieldChevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-height: min(42vh, 320px);
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
text-align: start;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
}
|
||||
|
||||
.rowSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.rowSelected:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.16);
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background 0.15s, color 0.15s, transform 0.2s;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toggleSpacer {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .chevron {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .chevronOpen {
|
||||
transform: scaleX(-1) rotate(90deg);
|
||||
}
|
||||
|
||||
.selectBtn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
text-align: start;
|
||||
color: inherit;
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nameSecondary {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rowSelected .nameSecondary {
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: 16px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { Category } from '../types/category'
|
||||
import { getAncestorIds, getChildren } from '../utils/categories'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import treeStyles from './CategoryTree.module.css'
|
||||
import styles from './CategoryTreeSelect.module.css'
|
||||
|
||||
interface CategoryTreeSelectProps {
|
||||
categories: Category[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
function categoryLabel(category: Category, isFa: boolean) {
|
||||
if (isFa) {
|
||||
return category.nameFa
|
||||
? `${category.nameFa}${category.nameEn ? ` / ${category.nameEn}` : ''}`
|
||||
: category.nameEn
|
||||
}
|
||||
return category.nameEn
|
||||
? `${category.nameEn}${category.nameFa ? ` / ${category.nameFa}` : ''}`
|
||||
: category.nameFa
|
||||
}
|
||||
|
||||
function categoryMatches(category: Category, query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return (
|
||||
category.nameEn.toLowerCase().includes(q) ||
|
||||
category.nameFa.toLowerCase().includes(q) ||
|
||||
category.nameFa.includes(query.trim())
|
||||
)
|
||||
}
|
||||
|
||||
function visibleIdsForQuery(categories: Category[], query: string): Set<string> | null {
|
||||
const q = query.trim()
|
||||
if (!q) return null
|
||||
|
||||
const matched = categories.filter((category) => categoryMatches(category, q))
|
||||
const visible = new Set<string>()
|
||||
|
||||
for (const category of matched) {
|
||||
visible.add(category.id)
|
||||
for (const ancestorId of getAncestorIds(categories, category.id)) {
|
||||
visible.add(ancestorId)
|
||||
}
|
||||
}
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
function TreeNodes({
|
||||
categories,
|
||||
parentId,
|
||||
value,
|
||||
expandedIds,
|
||||
visibleIds,
|
||||
forceExpanded,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
categories: Category[]
|
||||
parentId: string | null
|
||||
value: string
|
||||
expandedIds: Set<string>
|
||||
visibleIds: Set<string> | null
|
||||
forceExpanded: boolean
|
||||
onToggle: (id: string) => void
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const nodes = getChildren(categories, parentId).filter(
|
||||
(category) => !visibleIds || visibleIds.has(category.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{nodes.map((category) => {
|
||||
const childCount = getChildren(categories, category.id).some(
|
||||
(child) => !visibleIds || visibleIds.has(child.id),
|
||||
)
|
||||
const expanded = forceExpanded || expandedIds.has(category.id)
|
||||
const selected = value === category.id
|
||||
const primary = isFa ? category.nameFa || category.nameEn : category.nameEn
|
||||
const secondary = isFa
|
||||
? category.nameFa && category.nameEn && category.nameFa !== category.nameEn
|
||||
? category.nameEn
|
||||
: ''
|
||||
: category.nameFa
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div className={`${styles.row} ${selected ? styles.rowSelected : ''}`}>
|
||||
{childCount ? (
|
||||
<Tooltip
|
||||
label={expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleBtn}
|
||||
aria-label={
|
||||
expanded ? t('categories.tooltip.collapse') : t('categories.tooltip.expand')
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggle(category.id)}
|
||||
>
|
||||
<span className={`${styles.chevron} ${expanded ? styles.chevronOpen : ''}`}>
|
||||
<ChevronRight size={16} />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={styles.toggleSpacer} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.selectBtn}
|
||||
onClick={() => onSelect(category.id)}
|
||||
>
|
||||
<span className={styles.name}>{primary}</span>
|
||||
{secondary ? (
|
||||
<span className={styles.nameSecondary} dir={isFa ? 'ltr' : 'rtl'}>
|
||||
· {secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{childCount ? (
|
||||
<div className={`${treeStyles.childrenWrap} ${expanded ? treeStyles.expanded : ''}`}>
|
||||
<div className={treeStyles.childrenInner}>
|
||||
<TreeNodes
|
||||
categories={categories}
|
||||
parentId={category.id}
|
||||
value={value}
|
||||
expandedIds={expandedIds}
|
||||
visibleIds={visibleIds}
|
||||
forceExpanded={forceExpanded}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryTreeSelect({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
id,
|
||||
}: CategoryTreeSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selected = categories.find((category) => category.id === value)
|
||||
const selectedLabel = selected ? categoryLabel(selected, isFa) : ''
|
||||
const searchPlaceholder = placeholder ?? t('categories.select.search')
|
||||
|
||||
const visibleIds = useMemo(
|
||||
() => visibleIdsForQuery(categories, query),
|
||||
[categories, query],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setExpandedIds(new Set())
|
||||
}, [categories])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const ancestorId of getAncestorIds(categories, value)) {
|
||||
next.add(ancestorId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [categories, value])
|
||||
|
||||
function handleToggle(id: string) {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectOption(id: string) {
|
||||
onChange(id)
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const searching = query.trim().length > 0
|
||||
const hasVisible = searching ? (visibleIds?.size ?? 0) > 0 : categories.length > 0
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} ref={containerRef} dir={isFa ? 'rtl' : 'ltr'}>
|
||||
<div className={`${styles.inputWrap} ${open ? styles.inputWrapOpen : ''}`}>
|
||||
<Search size={16} className={styles.searchIcon} aria-hidden />
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={styles.input}
|
||||
disabled={disabled}
|
||||
placeholder={selected ? selectedLabel : searchPlaceholder}
|
||||
value={open ? query : selectedLabel}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
if (!open) setOpen(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) setOpen(true)
|
||||
}}
|
||||
autoComplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{value && !open && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearBtn}
|
||||
onClick={() => onChange('')}
|
||||
aria-label={t('categories.select.clear')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${styles.fieldChevron} ${open ? styles.fieldChevronOpen : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && !disabled ? (
|
||||
<div className={styles.panel}>
|
||||
{!hasVisible ? (
|
||||
<p className={styles.noResults}>{t('categories.select.empty')}</p>
|
||||
) : (
|
||||
<TreeNodes
|
||||
categories={categories}
|
||||
parentId={null}
|
||||
value={value}
|
||||
expandedIds={expandedIds}
|
||||
visibleIds={visibleIds}
|
||||
forceExpanded={searching}
|
||||
onToggle={handleToggle}
|
||||
onSelect={selectOption}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.modal {
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.headerBlock {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.form {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.treeField {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: var(--field-height);
|
||||
padding: 0 12px;
|
||||
font-size: var(--field-font-size);
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.generateBtn:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.14);
|
||||
}
|
||||
|
||||
.generateBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link2, X } from 'lucide-react'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import type { BusinessMessageKey } from '../i18n/messages'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { listBlogCategories } from '../services/blogCategoryService'
|
||||
import { listPortfolioCategories } from '../services/portfolioCategoryService'
|
||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||
import { listVideoCategories } from '../services/videoCategoryService'
|
||||
import type { ProductCategory } from '../types/category'
|
||||
import {
|
||||
WEBSITE_LINK_KINDS,
|
||||
buildWebsiteCategoryUrl,
|
||||
type WebsiteLinkKind,
|
||||
} from '../utils/websiteLinks'
|
||||
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import styles from './GenerateWebsiteLinkModal.module.css'
|
||||
|
||||
export type { WebsiteLinkKind }
|
||||
export { buildWebsiteCategoryUrl }
|
||||
|
||||
const KIND_LABEL: Record<WebsiteLinkKind, BusinessMessageKey> = {
|
||||
product: 'website.linkGenerator.types.product',
|
||||
storeItem: 'website.linkGenerator.types.storeItem',
|
||||
blog: 'website.linkGenerator.types.blog',
|
||||
portfolio: 'website.linkGenerator.types.portfolio',
|
||||
video: 'website.linkGenerator.types.video',
|
||||
}
|
||||
|
||||
const ANIMATION_MS = 220
|
||||
|
||||
interface GenerateWebsiteLinkModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onPick: (url: string) => void
|
||||
}
|
||||
|
||||
interface GenerateWebsiteLinkButtonProps {
|
||||
onPick: (url: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) {
|
||||
switch (kind) {
|
||||
case 'blog':
|
||||
return listBlogCategories(signal)
|
||||
case 'portfolio':
|
||||
return listPortfolioCategories(signal)
|
||||
case 'video':
|
||||
return listVideoCategories(signal)
|
||||
case 'product':
|
||||
case 'storeItem':
|
||||
return listProductCategories(signal)
|
||||
}
|
||||
}
|
||||
|
||||
export function GenerateWebsiteLinkButton({ onPick, disabled = false }: GenerateWebsiteLinkButtonProps) {
|
||||
const t = useT()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.generateBtn}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Link2 size={15} />
|
||||
{t('website.linkGenerator.generate')}
|
||||
</button>
|
||||
<GenerateWebsiteLinkModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onPick={(url) => {
|
||||
onPick(url)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebsiteLinkModalProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const isFa = locale === 'fa'
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [kind, setKind] = useState<WebsiteLinkKind>('product')
|
||||
const [categories, setCategories] = useState<ProductCategory[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setKind('product')
|
||||
setCategories([])
|
||||
setCategoryId('')
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false)
|
||||
setClosing(false)
|
||||
}, ANIMATION_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [open, mounted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || closing) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => document.removeEventListener('keydown', onKey, true)
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setCategoryId('')
|
||||
setCategories([])
|
||||
|
||||
void loadCategories(kind, controller.signal)
|
||||
.then((items) => {
|
||||
setCategories(items)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('website.linkGenerator.errorLoad'))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [open, kind, t])
|
||||
|
||||
const uiCategories = useMemo(
|
||||
() => categories.map(mapProductCategoryToUi),
|
||||
[categories],
|
||||
)
|
||||
|
||||
const selected = categories.find((category) => category.id === categoryId)
|
||||
const preview = selected ? buildWebsiteCategoryUrl(kind, selected) : ''
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!preview) return
|
||||
onPick(preview)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className={`${modalStyles.modal} ${styles.modal} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="generate-link-title"
|
||||
lang={isFa ? 'fa' : 'en'}
|
||||
dir={isFa ? 'rtl' : 'ltr'}
|
||||
>
|
||||
<div className={`${modalStyles.header} ${styles.header}`}>
|
||||
<div className={styles.headerBlock}>
|
||||
<h3 id="generate-link-title" className={modalStyles.title}>
|
||||
{t('website.linkGenerator.title')}
|
||||
</h3>
|
||||
<p className={styles.subtitle}>{t('website.linkGenerator.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.closeBtn}
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`${modalStyles.form} ${styles.form}`}
|
||||
onSubmit={handleSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="generate-link-type">{t('website.linkGenerator.type')}</label>
|
||||
<select
|
||||
id="generate-link-type"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as WebsiteLinkKind)}
|
||||
>
|
||||
{WEBSITE_LINK_KINDS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{t(KIND_LABEL[item])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.field} ${styles.treeField}`}>
|
||||
<label htmlFor="generate-link-category">{t('website.linkGenerator.category')}</label>
|
||||
{loading ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.loading')}</p>
|
||||
) : uiCategories.length === 0 ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.empty')}</p>
|
||||
) : (
|
||||
<CategoryTreeSelect
|
||||
id="generate-link-category"
|
||||
categories={uiCategories}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('website.linkGenerator.pickCategory')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<p className={styles.preview} dir="ltr">
|
||||
{preview}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
|
||||
<div className={`${modalStyles.actions} ${styles.actions}`}>
|
||||
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
||||
{t('products.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={modalStyles.submitBtn}
|
||||
disabled={!preview}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!preview) return
|
||||
onPick(preview)
|
||||
}}
|
||||
>
|
||||
{t('website.linkGenerator.insert')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface SearchableSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
allowEmpty?: boolean
|
||||
}
|
||||
|
||||
function categoryLabel(cat: FlatCategory, isFa: boolean) {
|
||||
@@ -28,6 +29,7 @@ export function SearchableSelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowEmpty = true,
|
||||
}: SearchableSelectProps) {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
@@ -104,15 +106,17 @@ export function SearchableSelect({
|
||||
|
||||
{open && (
|
||||
<ul className={styles.dropdown} role="listbox">
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption('')}
|
||||
>
|
||||
{t('categories.select.none')}
|
||||
</button>
|
||||
</li>
|
||||
{allowEmpty ? (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
||||
onClick={() => selectOption('')}
|
||||
>
|
||||
{t('categories.select.none')}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{filtered.length === 0 ? (
|
||||
<li className={styles.noResults}>{t('categories.select.empty')}</li>
|
||||
) : (
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
font-family: var(--font-ui);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
max-width: min(90vw, 420px);
|
||||
text-align: start;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Tooltip.module.css'
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
label: ReactNode
|
||||
children: ReactElement
|
||||
}
|
||||
|
||||
|
||||
@@ -83,21 +83,55 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
.controlGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.controlDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin-inline: 8px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hintIcon {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.captionTip {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: 12px;
|
||||
row-gap: 8px;
|
||||
justify-content: start;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.captionRow {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.captionKey {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.captionValue {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.addCard {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { Info, Link2, Pencil, 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 controlStyles from './CategoryRow.module.css'
|
||||
import styles from './WebsiteStaticImageGallery.module.css'
|
||||
|
||||
interface WebsiteStaticImageGalleryProps {
|
||||
@@ -75,6 +76,14 @@ export function WebsiteStaticImageGallery({
|
||||
: isExtraAdd
|
||||
? addLabel
|
||||
: replaceLabel
|
||||
const titleFa = image?.titleFa?.trim() || ''
|
||||
const titleEn = (image?.titleEn || image?.title)?.trim() || ''
|
||||
const subtext = image?.subtext?.trim() || ''
|
||||
const captionRows = [
|
||||
titleFa ? { key: t('website.staticImages.titleFa'), value: titleFa } : null,
|
||||
titleEn ? { key: t('website.staticImages.titleEn'), value: titleEn } : null,
|
||||
subtext ? { key: t('website.staticImages.subtext'), value: subtext } : null,
|
||||
].filter((row): row is { key: string; value: string } => row != null)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -103,25 +112,71 @@ export function WebsiteStaticImageGallery({
|
||||
/>
|
||||
</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>
|
||||
<p className={styles.title}>{index + 1}</p>
|
||||
<div className={styles.controls}>
|
||||
{captionRows.length > 0 || image.linkUrl ? (
|
||||
<>
|
||||
<div className={styles.controlGroup}>
|
||||
{captionRows.length > 0 ? (
|
||||
<Tooltip
|
||||
label={
|
||||
<span className={styles.captionTip}>
|
||||
{captionRows.map((row) => (
|
||||
<span key={row.key} className={styles.captionRow}>
|
||||
<span className={styles.captionKey}>{row.key}:</span>
|
||||
<span className={styles.captionValue}>{row.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`${controlStyles.controlBtn} ${styles.hintIcon}`}
|
||||
role="img"
|
||||
aria-label={t('website.staticImages.imageInfo')}
|
||||
>
|
||||
<Info size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{image.linkUrl ? (
|
||||
<Tooltip label={image.linkUrl}>
|
||||
<span
|
||||
className={`${controlStyles.controlBtn} ${styles.hintIcon}`}
|
||||
role="img"
|
||||
aria-label={image.linkUrl}
|
||||
>
|
||||
<Link2 size={16} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={styles.controlDivider} aria-hidden />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.controlGroup}>
|
||||
<Tooltip label={t('website.staticImages.editImage')}>
|
||||
<button
|
||||
type="button"
|
||||
className={controlStyles.controlBtn}
|
||||
onClick={() => onAddAt(index)}
|
||||
aria-label={t('website.staticImages.editImage')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={removeLabel}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
|
||||
onClick={() => onRemove(image)}
|
||||
aria-label={removeLabel}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
|
||||
@@ -558,6 +558,8 @@ const en = {
|
||||
'categories.tooltip.technical': 'Technical data form',
|
||||
'categories.tooltip.options': 'Category options',
|
||||
'categories.tooltip.remove': 'Remove category',
|
||||
'categories.tooltip.expand': 'Expand',
|
||||
'categories.tooltip.collapse': 'Collapse',
|
||||
'categories.modal.parent': 'Parent Category',
|
||||
'categories.modal.nameFa': 'Name (FA)',
|
||||
'categories.modal.nameEn': 'Name (EN)',
|
||||
@@ -1330,6 +1332,22 @@ const en = {
|
||||
'website.sliders.modal.linkOptional': 'Link URL (optional)',
|
||||
'website.sliders.modal.addSlide': 'Add slide',
|
||||
|
||||
'website.linkGenerator.generate': 'Generate',
|
||||
'website.linkGenerator.title': 'Generate link',
|
||||
'website.linkGenerator.subtitle': 'Pick a content type, then a category. The link is inserted into the field.',
|
||||
'website.linkGenerator.type': 'Type',
|
||||
'website.linkGenerator.category': 'Category',
|
||||
'website.linkGenerator.pickCategory': 'Search categories...',
|
||||
'website.linkGenerator.insert': 'Insert link',
|
||||
'website.linkGenerator.loading': 'Loading categories...',
|
||||
'website.linkGenerator.empty': 'No categories for this type yet.',
|
||||
'website.linkGenerator.errorLoad': 'Unable to load categories.',
|
||||
'website.linkGenerator.types.product': 'Product',
|
||||
'website.linkGenerator.types.storeItem': 'Store item',
|
||||
'website.linkGenerator.types.blog': 'Blog',
|
||||
'website.linkGenerator.types.portfolio': 'Portfolio',
|
||||
'website.linkGenerator.types.video': 'Video',
|
||||
|
||||
'website.staticImages.subtitle':
|
||||
'Websites load these by key from GET /tenants/{domain}/website/static-images',
|
||||
'website.staticImages.loading': 'Loading static images...',
|
||||
@@ -1355,6 +1373,8 @@ const en = {
|
||||
'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.editImage': 'Edit image',
|
||||
'website.staticImages.imageInfo': 'Image details',
|
||||
'website.staticImages.removeImage': 'Remove image',
|
||||
'website.staticImages.removeImageTitle': 'Remove image',
|
||||
'website.staticImages.removeImageMessage': 'Remove this image from the slot?',
|
||||
@@ -2472,6 +2492,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'categories.tooltip.technical': 'فرم اطلاعات فنی',
|
||||
'categories.tooltip.options': 'گزینههای دستهبندی',
|
||||
'categories.tooltip.remove': 'حذف دستهبندی',
|
||||
'categories.tooltip.expand': 'باز کردن',
|
||||
'categories.tooltip.collapse': 'بستن',
|
||||
'categories.modal.parent': 'دسته والد',
|
||||
'categories.modal.nameFa': 'نام (فارسی)',
|
||||
'categories.modal.nameEn': 'نام (انگلیسی)',
|
||||
@@ -3245,6 +3267,22 @@ const fa: Record<MessageKey, string> = {
|
||||
'website.sliders.modal.linkOptional': 'آدرس لینک (اختیاری)',
|
||||
'website.sliders.modal.addSlide': 'افزودن اسلاید',
|
||||
|
||||
'website.linkGenerator.generate': 'ساخت لینک',
|
||||
'website.linkGenerator.title': 'ساخت لینک',
|
||||
'website.linkGenerator.subtitle': 'نوع محتوا را انتخاب کنید، سپس یک دستهبندی. لینک در فیلد درج میشود.',
|
||||
'website.linkGenerator.type': 'نوع',
|
||||
'website.linkGenerator.category': 'دستهبندی',
|
||||
'website.linkGenerator.pickCategory': 'جستجوی دستهبندیها...',
|
||||
'website.linkGenerator.insert': 'درج لینک',
|
||||
'website.linkGenerator.loading': 'در حال بارگذاری دستهبندیها...',
|
||||
'website.linkGenerator.empty': 'هنوز دستهبندیای برای این نوع نیست.',
|
||||
'website.linkGenerator.errorLoad': 'بارگذاری دستهبندیها ممکن نشد.',
|
||||
'website.linkGenerator.types.product': 'محصول',
|
||||
'website.linkGenerator.types.storeItem': 'آیتم فروشگاه',
|
||||
'website.linkGenerator.types.blog': 'بلاگ',
|
||||
'website.linkGenerator.types.portfolio': 'نمونه کار',
|
||||
'website.linkGenerator.types.video': 'ویدیو',
|
||||
|
||||
'website.staticImages.subtitle':
|
||||
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
|
||||
'website.staticImages.loading': 'در حال بارگذاری تصاویر ثابت...',
|
||||
@@ -3270,6 +3308,8 @@ const fa: Record<MessageKey, string> = {
|
||||
'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جاینگهدار وبسایت خالی میماند.',
|
||||
'website.staticImages.addImage': 'افزودن تصویر',
|
||||
'website.staticImages.replaceImage': 'جایگزینی تصویر',
|
||||
'website.staticImages.editImage': 'ویرایش تصویر',
|
||||
'website.staticImages.imageInfo': 'جزئیات تصویر',
|
||||
'website.staticImages.removeImage': 'حذف تصویر',
|
||||
'website.staticImages.removeImageTitle': 'حذف تصویر',
|
||||
'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟',
|
||||
|
||||
@@ -36,3 +36,16 @@ export function getChildren(categories: Category[], parentId: string | null): Ca
|
||||
export function hasChildren(categories: Category[], id: string): boolean {
|
||||
return categories.some((c) => c.parentId === id)
|
||||
}
|
||||
|
||||
export function getAncestorIds(categories: Category[], id: string): string[] {
|
||||
const byId = new Map(categories.map((category) => [category.id, category]))
|
||||
const ids: string[] = []
|
||||
let current = byId.get(id)
|
||||
|
||||
while (current?.parentId) {
|
||||
ids.push(current.parentId)
|
||||
current = byId.get(current.parentId)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getBusinessDomain } from '../lib/config'
|
||||
|
||||
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video'
|
||||
|
||||
export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
|
||||
'product',
|
||||
'storeItem',
|
||||
'blog',
|
||||
'portfolio',
|
||||
'video',
|
||||
]
|
||||
|
||||
export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
||||
product: '/products',
|
||||
storeItem: '/store-items',
|
||||
blog: '/blogs',
|
||||
portfolio: '/portfolios',
|
||||
video: '/videos',
|
||||
}
|
||||
|
||||
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
|
||||
const host = domain.trim().replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||
const protocol = window.location.protocol === 'http:' ? 'http:' : 'https:'
|
||||
return `${protocol}//${host}`
|
||||
}
|
||||
|
||||
export function buildWebsiteCategoryUrl(
|
||||
kind: WebsiteLinkKind,
|
||||
category: { id: string; slug?: string | null },
|
||||
domain = getBusinessDomain(),
|
||||
): string {
|
||||
const path = WEBSITE_LINK_KIND_PATH[kind]
|
||||
const query = category.slug
|
||||
? `category=${encodeURIComponent(category.slug)}`
|
||||
: `categoryId=${encodeURIComponent(category.id)}`
|
||||
return `${getWebsiteOrigin(domain)}${path}?${query}`
|
||||
}
|
||||
Reference in New Issue
Block a user