From 8c187b18beccb371f2a1807486b41d083eb04f8f Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Thu, 3 Sep 2026 15:26:23 +0330 Subject: [PATCH] Add a Torob toggle in store settings and show parent category on product chips. Co-authored-by: Cursor --- .../components/CategoryTreeSelect.module.css | 3 +- .../src/components/CategoryTreeSelect.tsx | 12 ++- .../GenerateWebsiteLinkModal.module.css | 3 +- .../components/GenerateWebsiteLinkModal.tsx | 1 + .../src/components/ProductCard.module.css | 2 + apps/business/src/components/ProductCard.tsx | 3 +- apps/business/src/i18n/messages.ts | 14 +++ .../business/src/pages/ProductDetailsPage.tsx | 12 +-- .../src/pages/StoreSettingsPage.module.css | 31 +++++++ apps/business/src/pages/StoreSettingsPage.tsx | 87 ++++++++++++++++++- apps/business/src/services/productService.ts | 17 ++++ apps/business/src/services/settingsService.ts | 1 + apps/business/src/types/product.ts | 2 + .../src/components/EmailDnsModal.tsx | 3 +- .../src/services/businessSettingsService.ts | 1 + docs/PROJECT_CONTEXT.md | 5 +- 16 files changed, 181 insertions(+), 16 deletions(-) diff --git a/apps/business/src/components/CategoryTreeSelect.module.css b/apps/business/src/components/CategoryTreeSelect.module.css index c5a0ec9..8c97875 100644 --- a/apps/business/src/components/CategoryTreeSelect.module.css +++ b/apps/business/src/components/CategoryTreeSelect.module.css @@ -93,7 +93,8 @@ .panelInFlow { position: static; margin-top: 6px; - max-height: min(42vh, 320px); + max-height: none; + overflow: visible; z-index: auto; } diff --git a/apps/business/src/components/CategoryTreeSelect.tsx b/apps/business/src/components/CategoryTreeSelect.tsx index 3043779..8a2d473 100644 --- a/apps/business/src/components/CategoryTreeSelect.tsx +++ b/apps/business/src/components/CategoryTreeSelect.tsx @@ -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({ {open && !disabled ? ( -
+
{!hasVisible ? (

{t('categories.select.empty')}

) : ( diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.module.css b/apps/business/src/components/GenerateWebsiteLinkModal.module.css index 2351dff..425cebe 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.module.css +++ b/apps/business/src/components/GenerateWebsiteLinkModal.module.css @@ -26,7 +26,8 @@ .form { flex: 1 1 auto; min-height: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } .treeField { diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx index e37ac50..7cc85b4 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.tsx +++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx @@ -274,6 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs value={categoryId} onChange={setCategoryId} placeholder={t('website.linkGenerator.pickCategory')} + dropdownVariant="inFlow" /> )}
diff --git a/apps/business/src/components/ProductCard.module.css b/apps/business/src/components/ProductCard.module.css index 2292921..483a7c0 100644 --- a/apps/business/src/components/ProductCard.module.css +++ b/apps/business/src/components/ProductCard.module.css @@ -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; diff --git a/apps/business/src/components/ProductCard.tsx b/apps/business/src/components/ProductCard.tsx index 6c1f4fd..62588bf 100644 --- a/apps/business/src/components/ProductCard.tsx +++ b/apps/business/src/components/ProductCard.tsx @@ -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 diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index ee2393a..db64c8c 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { 'storeSettings.onlineSell': 'فروش آنلاین', 'storeSettings.onlineSellDesc': 'در صورت غیرفعال بودن، همه فروش‌های وب‌سایت خاموش می‌شوند و مشتریان نمی‌توانند سفارش آنلاین ثبت کنند.', + 'storeSettings.torob': 'ترب', + 'storeSettings.torobDesc': + 'برای همگام‌سازی کاتالوگ با ترب این گزینه را روشن کنید و این آدرس API را به ترب بدهید.', + 'storeSettings.torobLinkAria': 'آدرس API محصولات ترب', + 'storeSettings.torobCopy': 'کپی لینک', + 'storeSettings.torobCopied': 'لینک ترب کپی شد.', + 'storeSettings.torobCopyError': 'کپی لینک ترب ممکن نشد.', 'storeSettings.orderProcess': 'فرآیند سفارش', 'storeSettings.orderProcessDesc': 'مراحل طی‌شده توسط سفارش را تعریف کنید — مثلاً: در حال پردازش، آماده ارسال، ارسال‌شده، تحویل‌شده.', diff --git a/apps/business/src/pages/ProductDetailsPage.tsx b/apps/business/src/pages/ProductDetailsPage.tsx index 458581c..89cdcb2 100644 --- a/apps/business/src/pages/ProductDetailsPage.tsx +++ b/apps/business/src/pages/ProductDetailsPage.tsx @@ -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 (
@@ -154,15 +156,15 @@ export function ProductDetailsPage() {
- {product.category && ( + {categoryLabel ? ( - {product.category} + {categoryLabel} - )} + ) : null}

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 (
@@ -269,6 +309,45 @@ export function StoreSettingsPage() { onChange={(checked) => void handleOnlineSellChange(checked)} />

+
+
+ +

+ {t('storeSettings.torobDesc')} +

+
+

+ {torobUrl} +

+ + + +
+
+ void handleTorobChange(checked)} + /> +
)} diff --git a/apps/business/src/services/productService.ts b/apps/business/src/services/productService.ts index c17eb40..e4e68d1 100644 --- a/apps/business/src/services/productService.ts +++ b/apps/business/src/services/productService.ts @@ -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, + 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 diff --git a/apps/business/src/services/settingsService.ts b/apps/business/src/services/settingsService.ts index 49540d7..dedab51 100644 --- a/apps/business/src/services/settingsService.ts +++ b/apps/business/src/services/settingsService.ts @@ -59,6 +59,7 @@ export interface EPaymentSettings { export interface StoreSettings { onlineSellEnabled: boolean + torobEnabled: boolean orderProcessSteps: OrderProcessStep[] ePayment: EPaymentSettings } diff --git a/apps/business/src/types/product.ts b/apps/business/src/types/product.ts index ce5fc80..49f896d 100644 --- a/apps/business/src/types/product.ts +++ b/apps/business/src/types/product.ts @@ -5,6 +5,8 @@ export interface Product { categoryId: string category: string categoryFa?: string + categoryParent?: string + categoryParentFa?: string summary: string description: string image: string diff --git a/apps/super-admin/src/components/EmailDnsModal.tsx b/apps/super-admin/src/components/EmailDnsModal.tsx index 7e7c573..e68d334 100644 --- a/apps/super-admin/src/components/EmailDnsModal.tsx +++ b/apps/super-admin/src/components/EmailDnsModal.tsx @@ -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): mail 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,{' '} + dkim._domainkey, and _dmarc are removed.

{error ? (

diff --git a/apps/super-admin/src/services/businessSettingsService.ts b/apps/super-admin/src/services/businessSettingsService.ts index 417b7dc..199a987 100644 --- a/apps/super-admin/src/services/businessSettingsService.ts +++ b/apps/super-admin/src/services/businessSettingsService.ts @@ -25,6 +25,7 @@ export interface BusinessSettings { } store: { onlineSellEnabled: boolean + torobEnabled?: boolean orderProcessSteps: Array<{ id: string; label: string; color: string }> } modules: ModulesSettings diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 2c04db8..e1f81ef 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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`.