Add a Torob toggle in store settings and show parent category on product chips.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-03 15:26:23 +03:30
co-authored by Cursor
parent fde6a34e44
commit 8c187b18be
16 changed files with 181 additions and 16 deletions
@@ -93,7 +93,8 @@
.panelInFlow {
position: static;
margin-top: 6px;
max-height: min(42vh, 320px);
max-height: none;
overflow: visible;
z-index: auto;
}
@@ -15,6 +15,8 @@ interface CategoryTreeSelectProps {
placeholder?: string
disabled?: boolean
id?: string
/** Overlay floats over content; inFlow expands the parent (use inside modals). */
dropdownVariant?: 'overlay' | 'inFlow'
}
function categoryLabel(category: Category, isFa: boolean) {
@@ -163,6 +165,7 @@ export function CategoryTreeSelect({
placeholder,
disabled = false,
id,
dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) {
const t = useT()
const { locale } = useLocale()
@@ -269,7 +272,14 @@ export function CategoryTreeSelect({
</div>
{open && !disabled ? (
<div className={styles.panel}>
<div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
]
.filter(Boolean)
.join(' ')}
>
{!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p>
) : (
@@ -26,7 +26,8 @@
.form {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
}
.treeField {
@@ -274,6 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
value={categoryId}
onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')}
dropdownVariant="inFlow"
/>
)}
</div>
@@ -144,10 +144,12 @@
.categoryChip {
display: inline-block;
max-width: 100%;
padding: 3px 10px;
font-family: var(--font-ui);
font-size: 11px;
font-weight: 500;
line-height: 1.35;
color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15);
border-radius: 50px;
+2 -1
View File
@@ -13,6 +13,7 @@ import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product'
import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip'
import { formatProductCategoryChip } from '../services/productService'
import styles from './ProductCard.module.css'
interface ProductCardProps {
@@ -47,7 +48,7 @@ export function ProductCard({
? product.nameEn
: ''
: product.nameFa
const categoryLabel = isFa ? product.categoryFa || product.category : product.category
const categoryLabel = formatProductCategoryChip(product, isFa)
const imageSrc = product.image?.trim() || ''
const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed
+14
View File
@@ -1017,6 +1017,13 @@ const en = {
'storeSettings.onlineSell': 'Online sell',
'storeSettings.onlineSellDesc':
'When disabled, all sales on your website are turned off. Customers will not be able to place new orders online.',
'storeSettings.torob': 'Torob',
'storeSettings.torobDesc':
'Turn on to share your catalog with Torob. Give them this product API URL.',
'storeSettings.torobLinkAria': 'Torob product API URL',
'storeSettings.torobCopy': 'Copy link',
'storeSettings.torobCopied': 'Torob link copied.',
'storeSettings.torobCopyError': 'Unable to copy the Torob link.',
'storeSettings.orderProcess': 'Order process',
'storeSettings.orderProcessDesc':
'Define the steps an order can move through — for example: under processing, ready for shipping, shipped, delivered.',
@@ -3347,6 +3354,13 @@ const fa: Record<MessageKey, string> = {
'storeSettings.onlineSell': 'فروش آنلاین',
'storeSettings.onlineSellDesc':
'در صورت غیرفعال بودن، همه فروش‌های وب‌سایت خاموش می‌شوند و مشتریان نمی‌توانند سفارش آنلاین ثبت کنند.',
'storeSettings.torob': 'ترب',
'storeSettings.torobDesc':
'برای همگام‌سازی کاتالوگ با ترب این گزینه را روشن کنید و این آدرس API را به ترب بدهید.',
'storeSettings.torobLinkAria': 'آدرس API محصولات ترب',
'storeSettings.torobCopy': 'کپی لینک',
'storeSettings.torobCopied': 'لینک ترب کپی شد.',
'storeSettings.torobCopyError': 'کپی لینک ترب ممکن نشد.',
'storeSettings.orderProcess': 'فرآیند سفارش',
'storeSettings.orderProcessDesc':
'مراحل طی‌شده توسط سفارش را تعریف کنید — مثلاً: در حال پردازش، آماده ارسال، ارسال‌شده، تحویل‌شده.',
@@ -7,6 +7,7 @@ import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
formatProductCategoryChip,
getProduct,
mapProductApiToUi,
} from '../services/productService'
@@ -101,7 +102,8 @@ export function ProductDetailsPage() {
const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '',
)
const categoryLocale = textLocaleAttrs(product.category)
const categoryLabel = formatProductCategoryChip(product, isFa)
const categoryLocale = textLocaleAttrs(categoryLabel)
return (
<main className={pageStyles.content}>
@@ -154,15 +156,15 @@ export function ProductDetailsPage() {
</div>
<div className={styles.details}>
{product.category && (
{categoryLabel ? (
<span
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang}
dir={categoryLocale.dir}
dir={isFa ? 'rtl' : 'ltr'}
>
{product.category}
{categoryLabel}
</span>
)}
) : null}
<h1
className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')}
@@ -119,6 +119,37 @@
cursor: not-allowed;
}
.torobLinkRow {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.torobUrl {
flex: 1;
min-width: 0;
margin: 0;
padding: 8px 10px;
font-family: var(--font-en);
font-size: 12px;
line-height: 1.4;
color: var(--text-primary);
background: rgba(255, 255, 255, 0.55);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: var(--radius-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: filter 0.2s ease;
}
.torobUrlBlurred {
filter: blur(6px);
user-select: none;
pointer-events: none;
}
@media (max-width: 768px) {
.stepRow {
flex-wrap: wrap;
+83 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react'
import { ChevronDown, ChevronUp, Copy, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { StepColorPicker } from '../components/StepColorPicker'
@@ -16,6 +16,7 @@ import {
type StoreSettings,
} from '../services/settingsService'
import { createId } from '../utils/id'
import { getWebsiteOrigin } from '../utils/websiteLinks'
import {
defaultStepColorForId,
normalizeStepColor,
@@ -29,6 +30,7 @@ import styles from './StoreSettingsPage.module.css'
const DEFAULT_STORE_SETTINGS: StoreSettings = {
onlineSellEnabled: true,
torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: {
enabled: false,
@@ -74,6 +76,14 @@ function stepsAreEqual(a: OrderProcessStep[], b: OrderProcessStep[]) {
})
}
function applyStore(store: StoreSettings): StoreSettings {
return {
...DEFAULT_STORE_SETTINGS,
...store,
torobEnabled: store.torobEnabled ?? false,
}
}
export function StoreSettingsPage() {
const t = useT()
const { showToast } = useToast()
@@ -97,7 +107,7 @@ export function StoreSettingsPage() {
try {
const data = await getSettings(signal)
setSettings(data.settings.store)
setSettings(applyStore(data.settings.store))
setDraftSteps(data.settings.store.orderProcessSteps)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
@@ -117,7 +127,7 @@ export function StoreSettingsPage() {
try {
const data = await updateStoreSettings({ onlineSellEnabled: checked })
setSettings(data.settings.store)
setSettings(applyStore(data.settings.store))
showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
@@ -130,6 +140,35 @@ export function StoreSettingsPage() {
}
}
async function handleTorobChange(checked: boolean) {
setSavingKey('torob')
setError('')
try {
const data = await updateStoreSettings({ torobEnabled: checked })
setSettings(applyStore(data.settings.store))
showToast(t('productSettings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('productSettings.errorSave'))
}
} finally {
setSavingKey(null)
}
}
async function handleCopyTorobUrl() {
if (!settings.torobEnabled) return
try {
await navigator.clipboard.writeText(torobUrl)
showToast(t('storeSettings.torobCopied'), 'success')
} catch {
setError(t('storeSettings.torobCopyError'))
}
}
function updateStepLabel(id: string, label: string) {
setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, label } : step)),
@@ -199,7 +238,7 @@ export function StoreSettingsPage() {
try {
const data = await updateStoreSettings({ orderProcessSteps: normalized })
setSettings(data.settings.store)
setSettings(applyStore(data.settings.store))
setDraftSteps(data.settings.store.orderProcessSteps)
showToast(t('storeSettings.stepsSaved'), 'success')
} catch (err) {
@@ -223,6 +262,7 @@ export function StoreSettingsPage() {
draftSteps.every(
(step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0,
)
const torobUrl = `${getWebsiteOrigin()}/torob_api/v3/products`
return (
<main className={pageStyles.content}>
@@ -269,6 +309,45 @@ export function StoreSettingsPage() {
onChange={(checked) => void handleOnlineSellChange(checked)}
/>
</div>
<div className={sharedStyles.row}>
<div className={sharedStyles.rowText}>
<label htmlFor="torob-sync" className={sharedStyles.rowLabel}>
{t('storeSettings.torob')}
</label>
<p className={sharedStyles.rowDescription}>
{t('storeSettings.torobDesc')}
</p>
<div className={styles.torobLinkRow}>
<p
className={`${styles.torobUrl} ${settings.torobEnabled ? '' : styles.torobUrlBlurred}`}
dir="ltr"
lang="en"
aria-hidden={!settings.torobEnabled}
aria-label={t('storeSettings.torobLinkAria')}
>
{torobUrl}
</p>
<Tooltip label={t('storeSettings.torobCopy')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => void handleCopyTorobUrl()}
disabled={!settings.torobEnabled || savingKey === 'torob'}
aria-label={t('storeSettings.torobCopy')}
>
<Copy size={15} />
</button>
</Tooltip>
</div>
</div>
<Switch
id="torob-sync"
checked={settings.torobEnabled}
disabled={savingKey === 'torob'}
aria-label={t('storeSettings.torob')}
onChange={(checked) => void handleTorobChange(checked)}
/>
</div>
</div>
)}
</section>
@@ -16,6 +16,8 @@ export interface ProductApi {
categoryId: string | null
categoryName: string
categoryNameFa?: string
categoryParentName?: string
categoryParentNameFa?: string
brandId: string | null
brand: {
id: string
@@ -86,6 +88,8 @@ export function mapProductApiToUi(product: ProductApi): Product {
categoryId: product.categoryId ?? '',
category: product.categoryName,
categoryFa: product.categoryNameFa || '',
categoryParent: product.categoryParentName || '',
categoryParentFa: product.categoryParentNameFa || '',
summary: product.summary,
description: product.descriptionHtml,
image: product.image || product.thumbnail || '',
@@ -98,6 +102,19 @@ export function mapProductApiToUi(product: ProductApi): Product {
}
}
export function formatProductCategoryChip(
product: Pick<Product, 'category' | 'categoryFa' | 'categoryParent' | 'categoryParentFa'>,
isFa: boolean,
): string {
const leaf = isFa ? product.categoryFa || product.category : product.category
if (!leaf) return ''
const parent = isFa
? product.categoryParentFa || product.categoryParent
: product.categoryParent
if (!parent) return leaf
return `${parent} >> ${leaf}`
}
export function getProductGalleryUrls(product: ProductApi): string[] {
const urls: string[] = []
const featured = product.thumbnailUrl || product.thumbnail || product.image
@@ -59,6 +59,7 @@ export interface EPaymentSettings {
export interface StoreSettings {
onlineSellEnabled: boolean
torobEnabled: boolean
orderProcessSteps: OrderProcessStep[]
ePayment: EPaymentSettings
}
+2
View File
@@ -5,6 +5,8 @@ export interface Product {
categoryId: string
category: string
categoryFa?: string
categoryParent?: string
categoryParentFa?: string
summary: string
description: string
image: string
@@ -69,7 +69,8 @@ export function EmailDnsModal({ domain, onClose }: EmailDnsModalProps) {
zone file). We upsert mail records on{' '}
{dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (DNS-only / not
proxied): <strong>mail</strong> A → 185.214.101.41, MX → mail.meshkee.com,
SPF, both DKIM keys, autoconfig / autodiscover. Existing DMARC is left as-is.
SPF, both DKIM keys, autoconfig / autodiscover. Extra MX,{' '}
<strong>dkim._domainkey</strong>, and <strong>_dmarc</strong> are removed.
</p>
{error ? (
<p className={tableStyles.alertError} role="alert">
@@ -25,6 +25,7 @@ export interface BusinessSettings {
}
store: {
onlineSellEnabled: boolean
torobEnabled?: boolean
orderProcessSteps: Array<{ id: string; label: string; color: string }>
}
modules: ModulesSettings
+3 -2
View File
@@ -3,7 +3,7 @@
> **For AI agents:** Read this file at the start of a new chat before making changes.
> Update this document when a major feature is completed or architecture changes.
Last updated: September 1, 2026
Last updated: September 3, 2026
---
@@ -142,7 +142,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **Email DNS** (Stalwart DKIM + MX/SPF on Arvan or Cloudflare), **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **Email DNS** (Stalwart DKIM + MX/SPF on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, `_dmarc`), **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
| `/templates` | Global AI prompt templates. Assign from Businesses or Websites ⋯ → **AI prompts** (add from library; public pack link). |
| `/settings` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template |
@@ -276,6 +276,7 @@ Products accept optional `brandId` on create/update.
### Business settings
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website`
- `website.specialProductsSource`: `product` \| `store_item`. Unset → `store_item` if the store module is enabled, otherwise `product`.
- **Torob:** store-module websites with Store settings → Torob on. Nginx on the shop apex proxies `POST /torob_api/v3/products` to `POST /api/v1/tenants/{domain}/torob_api/v3/products`. Otherwise 404. Do not implement this in Next.js.
- `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`, `finance`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH.
- `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`, `product_views_30d`, `website_views_30d`, `portfolio_views_30d`); `none` hides that slot; defaults orders + customers. View charts read `website_page_views` (auto-counted on public list/detail GETs; ~6 month retention). `website_views_30d` aggregates **all** page kinds (`kind=all`); product/portfolio/blog charts filter by detail kind. Home-only hits also increment on `GET .../website/sliders` and `GET .../website/static-images?pageKey=home`. Lifetime totals in `website_page_view_counters`.
- **Statistics** (`/statistics`, always-on sidebar): same two home chart slots, then visit KPI rows (today / this week Sat–Fri / this month / lifetime total) for all pages, home, products (+ store items), blog, portfolio, videos — module-gated except total + home. API: `GET businesses/:id/analytics/views/summary`.