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 {
|
.formFit {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionsBar {
|
.actionsBar {
|
||||||
position: sticky;
|
flex-shrink: 0;
|
||||||
bottom: 0;
|
margin-top: 0;
|
||||||
z-index: 1;
|
padding-top: 14px;
|
||||||
margin-top: auto;
|
}
|
||||||
padding-top: 12px;
|
|
||||||
background: linear-gradient(
|
.linkRow {
|
||||||
to top,
|
display: flex;
|
||||||
rgba(255, 255, 255, 0.96) 72%,
|
gap: 8px;
|
||||||
rgba(255, 255, 255, 0.78)
|
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 { createPortal } from 'react-dom'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { ImageCropper } from './ImageCropper'
|
import { ImageCropper } from './ImageCropper'
|
||||||
|
import { GenerateWebsiteLinkButton } from './GenerateWebsiteLinkModal'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import modalStyles from './CategoryModal.module.css'
|
import modalStyles from './CategoryModal.module.css'
|
||||||
import styles from './AddWebsiteSliderSlideModal.module.css'
|
import styles from './AddWebsiteSliderSlideModal.module.css'
|
||||||
@@ -128,17 +129,18 @@ export function AddWebsiteSliderSlideModal({
|
|||||||
className={`${modalStyles.form} ${styles.formFit}`}
|
className={`${modalStyles.form} ${styles.formFit}`}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<div className={modalStyles.field}>
|
<div className={styles.fields}>
|
||||||
<label>{t('website.sliders.modal.image')}</label>
|
<div className={modalStyles.field}>
|
||||||
<ImageCropper
|
<label>{t('website.sliders.modal.image')}</label>
|
||||||
value={image}
|
<ImageCropper
|
||||||
onChange={setImage}
|
value={image}
|
||||||
aspect={aspect}
|
onChange={setImage}
|
||||||
uploadLabel={t('website.sliders.modal.upload')}
|
aspect={aspect}
|
||||||
hint={hint ?? t('website.sliders.modal.hint')}
|
uploadLabel={t('website.sliders.modal.upload')}
|
||||||
changeLabel={t('website.sliders.modal.changeImage')}
|
hint={hint ?? t('website.sliders.modal.hint')}
|
||||||
/>
|
changeLabel={t('website.sliders.modal.changeImage')}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{captionMode === 'bilingual' ? (
|
{captionMode === 'bilingual' ? (
|
||||||
<>
|
<>
|
||||||
@@ -197,15 +199,20 @@ export function AddWebsiteSliderSlideModal({
|
|||||||
|
|
||||||
<div className={modalStyles.field}>
|
<div className={modalStyles.field}>
|
||||||
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
|
<label htmlFor="slide-link">{t('website.sliders.modal.linkOptional')}</label>
|
||||||
<input
|
<div className={styles.linkRow}>
|
||||||
id="slide-link"
|
<input
|
||||||
type="url"
|
id="slide-link"
|
||||||
dir="ltr"
|
type="text"
|
||||||
placeholder="https://example.com/promo"
|
inputMode="url"
|
||||||
value={linkUrl}
|
dir="ltr"
|
||||||
onChange={(e) => setLinkUrl(e.target.value)}
|
placeholder="https://example.com/promo"
|
||||||
disabled={isSubmitting}
|
value={linkUrl}
|
||||||
/>
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
<GenerateWebsiteLinkButton onPick={setLinkUrl} disabled={isSubmitting} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${modalStyles.actions} ${styles.actionsBar}`}>
|
<div className={`${modalStyles.actions} ${styles.actionsBar}`}>
|
||||||
@@ -227,8 +234,7 @@ export function AddWebsiteSliderSlideModal({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
,
|
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,32 @@
|
|||||||
z-index: 1200;
|
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 {
|
.modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -103,12 +129,19 @@
|
|||||||
.field select {
|
.field select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-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-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-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-repeat: no-repeat;
|
||||||
background-position: right var(--select-arrow-offset) center;
|
background-position: right var(--select-arrow-offset) center;
|
||||||
background-size: var(--select-arrow-size);
|
background-size: var(--select-arrow-size);
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
cursor: pointer;
|
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
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
allowEmpty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryLabel(cat: FlatCategory, isFa: boolean) {
|
function categoryLabel(cat: FlatCategory, isFa: boolean) {
|
||||||
@@ -28,6 +29,7 @@ export function SearchableSelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
allowEmpty = true,
|
||||||
}: SearchableSelectProps) {
|
}: SearchableSelectProps) {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
const { locale } = useLocale()
|
const { locale } = useLocale()
|
||||||
@@ -104,15 +106,17 @@ export function SearchableSelect({
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<ul className={styles.dropdown} role="listbox">
|
<ul className={styles.dropdown} role="listbox">
|
||||||
<li>
|
{allowEmpty ? (
|
||||||
<button
|
<li>
|
||||||
type="button"
|
<button
|
||||||
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
type="button"
|
||||||
onClick={() => selectOption('')}
|
className={`${styles.option} ${value === '' ? styles.optionSelected : ''}`}
|
||||||
>
|
onClick={() => selectOption('')}
|
||||||
{t('categories.select.none')}
|
>
|
||||||
</button>
|
{t('categories.select.none')}
|
||||||
</li>
|
</button>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<li className={styles.noResults}>{t('categories.select.empty')}</li>
|
<li className={styles.noResults}>{t('categories.select.empty')}</li>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,9 +11,13 @@
|
|||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.3;
|
line-height: 1.55;
|
||||||
color: var(--text-primary);
|
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;
|
pointer-events: none;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
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 { createPortal } from 'react-dom'
|
||||||
import styles from './Tooltip.module.css'
|
import styles from './Tooltip.module.css'
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
label: string
|
label: ReactNode
|
||||||
children: ReactElement
|
children: ReactElement
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,21 +83,55 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeBtn {
|
.controls {
|
||||||
flex-shrink: 0;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
flex-shrink: 0;
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: background 0.2s, color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeBtn:hover {
|
.controlGroup {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
display: flex;
|
||||||
color: #ef4444;
|
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 {
|
.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 { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
|
import type { WebsiteStaticImageItem } from '../types/websiteStaticImage'
|
||||||
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
|
import { cssAspectRatio, slotPreviewSize } from '../utils/aspectRatio'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
|
import controlStyles from './CategoryRow.module.css'
|
||||||
import styles from './WebsiteStaticImageGallery.module.css'
|
import styles from './WebsiteStaticImageGallery.module.css'
|
||||||
|
|
||||||
interface WebsiteStaticImageGalleryProps {
|
interface WebsiteStaticImageGalleryProps {
|
||||||
@@ -75,6 +76,14 @@ export function WebsiteStaticImageGallery({
|
|||||||
: isExtraAdd
|
: isExtraAdd
|
||||||
? addLabel
|
? addLabel
|
||||||
: replaceLabel
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -103,25 +112,71 @@ export function WebsiteStaticImageGallery({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<p className={styles.title}>
|
<p className={styles.title}>{index + 1}</p>
|
||||||
{(locale === 'fa'
|
<div className={styles.controls}>
|
||||||
? image.titleFa || image.title || image.titleEn
|
{captionRows.length > 0 || image.linkUrl ? (
|
||||||
: image.titleEn || image.titleFa || image.title
|
<>
|
||||||
)?.trim() ||
|
<div className={styles.controlGroup}>
|
||||||
image.subtext?.trim() ||
|
{captionRows.length > 0 ? (
|
||||||
image.linkUrl ||
|
<Tooltip
|
||||||
`${index + 1}`}
|
label={
|
||||||
</p>
|
<span className={styles.captionTip}>
|
||||||
<Tooltip label={removeLabel}>
|
{captionRows.map((row) => (
|
||||||
<button
|
<span key={row.key} className={styles.captionRow}>
|
||||||
type="button"
|
<span className={styles.captionKey}>{row.key}:</span>
|
||||||
className={styles.removeBtn}
|
<span className={styles.captionValue}>{row.value}</span>
|
||||||
onClick={() => onRemove(image)}
|
</span>
|
||||||
aria-label={removeLabel}
|
))}
|
||||||
>
|
</span>
|
||||||
<Trash2 size={16} />
|
}
|
||||||
</button>
|
>
|
||||||
</Tooltip>
|
<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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -558,6 +558,8 @@ const en = {
|
|||||||
'categories.tooltip.technical': 'Technical data form',
|
'categories.tooltip.technical': 'Technical data form',
|
||||||
'categories.tooltip.options': 'Category options',
|
'categories.tooltip.options': 'Category options',
|
||||||
'categories.tooltip.remove': 'Remove category',
|
'categories.tooltip.remove': 'Remove category',
|
||||||
|
'categories.tooltip.expand': 'Expand',
|
||||||
|
'categories.tooltip.collapse': 'Collapse',
|
||||||
'categories.modal.parent': 'Parent Category',
|
'categories.modal.parent': 'Parent Category',
|
||||||
'categories.modal.nameFa': 'Name (FA)',
|
'categories.modal.nameFa': 'Name (FA)',
|
||||||
'categories.modal.nameEn': 'Name (EN)',
|
'categories.modal.nameEn': 'Name (EN)',
|
||||||
@@ -1330,6 +1332,22 @@ const en = {
|
|||||||
'website.sliders.modal.linkOptional': 'Link URL (optional)',
|
'website.sliders.modal.linkOptional': 'Link URL (optional)',
|
||||||
'website.sliders.modal.addSlide': 'Add slide',
|
'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':
|
'website.staticImages.subtitle':
|
||||||
'Websites load these by key from GET /tenants/{domain}/website/static-images',
|
'Websites load these by key from GET /tenants/{domain}/website/static-images',
|
||||||
'website.staticImages.loading': 'Loading 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.',
|
'Delete slot {label}? The website placeholder will have no image until you add one again.',
|
||||||
'website.staticImages.addImage': 'Add image',
|
'website.staticImages.addImage': 'Add image',
|
||||||
'website.staticImages.replaceImage': 'Replace image',
|
'website.staticImages.replaceImage': 'Replace image',
|
||||||
|
'website.staticImages.editImage': 'Edit image',
|
||||||
|
'website.staticImages.imageInfo': 'Image details',
|
||||||
'website.staticImages.removeImage': 'Remove image',
|
'website.staticImages.removeImage': 'Remove image',
|
||||||
'website.staticImages.removeImageTitle': 'Remove image',
|
'website.staticImages.removeImageTitle': 'Remove image',
|
||||||
'website.staticImages.removeImageMessage': 'Remove this image from the slot?',
|
'website.staticImages.removeImageMessage': 'Remove this image from the slot?',
|
||||||
@@ -2472,6 +2492,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'categories.tooltip.technical': 'فرم اطلاعات فنی',
|
'categories.tooltip.technical': 'فرم اطلاعات فنی',
|
||||||
'categories.tooltip.options': 'گزینههای دستهبندی',
|
'categories.tooltip.options': 'گزینههای دستهبندی',
|
||||||
'categories.tooltip.remove': 'حذف دستهبندی',
|
'categories.tooltip.remove': 'حذف دستهبندی',
|
||||||
|
'categories.tooltip.expand': 'باز کردن',
|
||||||
|
'categories.tooltip.collapse': 'بستن',
|
||||||
'categories.modal.parent': 'دسته والد',
|
'categories.modal.parent': 'دسته والد',
|
||||||
'categories.modal.nameFa': 'نام (فارسی)',
|
'categories.modal.nameFa': 'نام (فارسی)',
|
||||||
'categories.modal.nameEn': 'نام (انگلیسی)',
|
'categories.modal.nameEn': 'نام (انگلیسی)',
|
||||||
@@ -3245,6 +3267,22 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'website.sliders.modal.linkOptional': 'آدرس لینک (اختیاری)',
|
'website.sliders.modal.linkOptional': 'آدرس لینک (اختیاری)',
|
||||||
'website.sliders.modal.addSlide': 'افزودن اسلاید',
|
'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':
|
'website.staticImages.subtitle':
|
||||||
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
|
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
|
||||||
'website.staticImages.loading': 'در حال بارگذاری تصاویر ثابت...',
|
'website.staticImages.loading': 'در حال بارگذاری تصاویر ثابت...',
|
||||||
@@ -3270,6 +3308,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جاینگهدار وبسایت خالی میماند.',
|
'اسلات {label} حذف شود؟ تا وقتی تصویر جدیدی نگذارید، جاینگهدار وبسایت خالی میماند.',
|
||||||
'website.staticImages.addImage': 'افزودن تصویر',
|
'website.staticImages.addImage': 'افزودن تصویر',
|
||||||
'website.staticImages.replaceImage': 'جایگزینی تصویر',
|
'website.staticImages.replaceImage': 'جایگزینی تصویر',
|
||||||
|
'website.staticImages.editImage': 'ویرایش تصویر',
|
||||||
|
'website.staticImages.imageInfo': 'جزئیات تصویر',
|
||||||
'website.staticImages.removeImage': 'حذف تصویر',
|
'website.staticImages.removeImage': 'حذف تصویر',
|
||||||
'website.staticImages.removeImageTitle': 'حذف تصویر',
|
'website.staticImages.removeImageTitle': 'حذف تصویر',
|
||||||
'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟',
|
'website.staticImages.removeImageMessage': 'این تصویر از اسلات حذف شود؟',
|
||||||
|
|||||||
@@ -36,3 +36,16 @@ export function getChildren(categories: Category[], parentId: string | null): Ca
|
|||||||
export function hasChildren(categories: Category[], id: string): boolean {
|
export function hasChildren(categories: Category[], id: string): boolean {
|
||||||
return categories.some((c) => c.parentId === id)
|
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