diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.module.css b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css
index 1d3ca26..39b2910 100644
--- a/apps/business/src/components/AddWebsiteSliderSlideModal.module.css
+++ b/apps/business/src/components/AddWebsiteSliderSlideModal.module.css
@@ -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;
}
diff --git a/apps/business/src/components/AddWebsiteSliderSlideModal.tsx b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx
index 3a6f93d..cbf71b7 100644
--- a/apps/business/src/components/AddWebsiteSliderSlideModal.tsx
+++ b/apps/business/src/components/AddWebsiteSliderSlideModal.tsx
@@ -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}
>
-
-
-
-
+
+
+
+
+
{captionMode === 'bilingual' ? (
<>
@@ -197,15 +199,20 @@ export function AddWebsiteSliderSlideModal({
@@ -227,8 +234,7 @@ export function AddWebsiteSliderSlideModal({
-
- ,
+ ,
document.body,
)
}
diff --git a/apps/business/src/components/CategoryModal.module.css b/apps/business/src/components/CategoryModal.module.css
index 6fc0b77..fcca78c 100644
--- a/apps/business/src/components/CategoryModal.module.css
+++ b/apps/business/src/components/CategoryModal.module.css
@@ -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;
}
diff --git a/apps/business/src/components/CategoryTreeSelect.module.css b/apps/business/src/components/CategoryTreeSelect.module.css
new file mode 100644
index 0000000..085cbf4
--- /dev/null
+++ b/apps/business/src/components/CategoryTreeSelect.module.css
@@ -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;
+}
diff --git a/apps/business/src/components/CategoryTreeSelect.tsx b/apps/business/src/components/CategoryTreeSelect.tsx
new file mode 100644
index 0000000..3043779
--- /dev/null
+++ b/apps/business/src/components/CategoryTreeSelect.tsx
@@ -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 | null {
+ const q = query.trim()
+ if (!q) return null
+
+ const matched = categories.filter((category) => categoryMatches(category, q))
+ const visible = new Set()
+
+ 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
+ visibleIds: Set | 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 (
+
+
+ {childCount ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ {childCount ? (
+
+ ) : null}
+
+ )
+ })}
+ >
+ )
+}
+
+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>(() => new Set())
+ const containerRef = useRef(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 (
+
+
+
+ {
+ 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 ? (
+
+ ) : null}
+
+
+
+ {open && !disabled ? (
+
+ {!hasVisible ? (
+
{t('categories.select.empty')}
+ ) : (
+
+ )}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.module.css b/apps/business/src/components/GenerateWebsiteLinkModal.module.css
new file mode 100644
index 0000000..0e4cf21
--- /dev/null
+++ b/apps/business/src/components/GenerateWebsiteLinkModal.module.css
@@ -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;
+}
diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx
new file mode 100644
index 0000000..cb1cbd8
--- /dev/null
+++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx
@@ -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 = {
+ 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 (
+ <>
+
+ 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('product')
+ const [categories, setCategories] = useState([])
+ 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) {
+ e.preventDefault()
+ e.stopPropagation()
+ if (!preview) return
+ onPick(preview)
+ }
+
+ return createPortal(
+ e.target === e.currentTarget && onClose()}
+ role="presentation"
+ >
+
+
+
+
+ {t('website.linkGenerator.title')}
+
+
{t('website.linkGenerator.subtitle')}
+
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/apps/business/src/components/SearchableSelect.tsx b/apps/business/src/components/SearchableSelect.tsx
index 9e77a7f..5b6b2f8 100644
--- a/apps/business/src/components/SearchableSelect.tsx
+++ b/apps/business/src/components/SearchableSelect.tsx
@@ -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 && (