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 { .panelInFlow {
position: static; position: static;
margin-top: 6px; margin-top: 6px;
max-height: min(42vh, 320px); max-height: none;
overflow: visible;
z-index: auto; z-index: auto;
} }
@@ -15,6 +15,8 @@ interface CategoryTreeSelectProps {
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
id?: string id?: string
/** Overlay floats over content; inFlow expands the parent (use inside modals). */
dropdownVariant?: 'overlay' | 'inFlow'
} }
function categoryLabel(category: Category, isFa: boolean) { function categoryLabel(category: Category, isFa: boolean) {
@@ -163,6 +165,7 @@ export function CategoryTreeSelect({
placeholder, placeholder,
disabled = false, disabled = false,
id, id,
dropdownVariant = 'overlay',
}: CategoryTreeSelectProps) { }: CategoryTreeSelectProps) {
const t = useT() const t = useT()
const { locale } = useLocale() const { locale } = useLocale()
@@ -269,7 +272,14 @@ export function CategoryTreeSelect({
</div> </div>
{open && !disabled ? ( {open && !disabled ? (
<div className={styles.panel}> <div
className={[
styles.panel,
dropdownVariant === 'inFlow' ? styles.panelInFlow : '',
]
.filter(Boolean)
.join(' ')}
>
{!hasVisible ? ( {!hasVisible ? (
<p className={styles.noResults}>{t('categories.select.empty')}</p> <p className={styles.noResults}>{t('categories.select.empty')}</p>
) : ( ) : (
@@ -26,7 +26,8 @@
.form { .form {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
.treeField { .treeField {
@@ -274,6 +274,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
value={categoryId} value={categoryId}
onChange={setCategoryId} onChange={setCategoryId}
placeholder={t('website.linkGenerator.pickCategory')} placeholder={t('website.linkGenerator.pickCategory')}
dropdownVariant="inFlow"
/> />
)} )}
</div> </div>
@@ -144,10 +144,12 @@
.categoryChip { .categoryChip {
display: inline-block; display: inline-block;
max-width: 100%;
padding: 3px 10px; padding: 3px 10px;
font-family: var(--font-ui); font-family: var(--font-ui);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
line-height: 1.35;
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(148, 163, 184, 0.15); background: rgba(148, 163, 184, 0.15);
border-radius: 50px; border-radius: 50px;
+2 -1
View File
@@ -13,6 +13,7 @@ import { useLocale } from '@meshkee/dashboard-ui'
import type { Product } from '../types/product' import type { Product } from '../types/product'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import { formatProductCategoryChip } from '../services/productService'
import styles from './ProductCard.module.css' import styles from './ProductCard.module.css'
interface ProductCardProps { interface ProductCardProps {
@@ -47,7 +48,7 @@ export function ProductCard({
? product.nameEn ? product.nameEn
: '' : ''
: product.nameFa : product.nameFa
const categoryLabel = isFa ? product.categoryFa || product.category : product.category const categoryLabel = formatProductCategoryChip(product, isFa)
const imageSrc = product.image?.trim() || '' const imageSrc = product.image?.trim() || ''
const [imageFailed, setImageFailed] = useState(false) const [imageFailed, setImageFailed] = useState(false)
const showImage = Boolean(imageSrc) && !imageFailed const showImage = Boolean(imageSrc) && !imageFailed
+14
View File
@@ -1017,6 +1017,13 @@ const en = {
'storeSettings.onlineSell': 'Online sell', 'storeSettings.onlineSell': 'Online sell',
'storeSettings.onlineSellDesc': 'storeSettings.onlineSellDesc':
'When disabled, all sales on your website are turned off. Customers will not be able to place new orders online.', '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.orderProcess': 'Order process',
'storeSettings.orderProcessDesc': 'storeSettings.orderProcessDesc':
'Define the steps an order can move through — for example: under processing, ready for shipping, shipped, delivered.', '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.onlineSell': 'فروش آنلاین',
'storeSettings.onlineSellDesc': 'storeSettings.onlineSellDesc':
'در صورت غیرفعال بودن، همه فروش‌های وب‌سایت خاموش می‌شوند و مشتریان نمی‌توانند سفارش آنلاین ثبت کنند.', 'در صورت غیرفعال بودن، همه فروش‌های وب‌سایت خاموش می‌شوند و مشتریان نمی‌توانند سفارش آنلاین ثبت کنند.',
'storeSettings.torob': 'ترب',
'storeSettings.torobDesc':
'برای همگام‌سازی کاتالوگ با ترب این گزینه را روشن کنید و این آدرس API را به ترب بدهید.',
'storeSettings.torobLinkAria': 'آدرس API محصولات ترب',
'storeSettings.torobCopy': 'کپی لینک',
'storeSettings.torobCopied': 'لینک ترب کپی شد.',
'storeSettings.torobCopyError': 'کپی لینک ترب ممکن نشد.',
'storeSettings.orderProcess': 'فرآیند سفارش', 'storeSettings.orderProcess': 'فرآیند سفارش',
'storeSettings.orderProcessDesc': 'storeSettings.orderProcessDesc':
'مراحل طی‌شده توسط سفارش را تعریف کنید — مثلاً: در حال پردازش، آماده ارسال، ارسال‌شده، تحویل‌شده.', 'مراحل طی‌شده توسط سفارش را تعریف کنید — مثلاً: در حال پردازش، آماده ارسال، ارسال‌شده، تحویل‌شده.',
@@ -7,6 +7,7 @@ import { ProductDetailsTabs } from '../components/ProductDetailsTabs'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { import {
formatProductCategoryChip,
getProduct, getProduct,
mapProductApiToUi, mapProductApiToUi,
} from '../services/productService' } from '../services/productService'
@@ -101,7 +102,8 @@ export function ProductDetailsPage() {
const descriptionLocale = textLocaleAttrs( const descriptionLocale = textLocaleAttrs(
product.description?.replace(/<[^>]+>/g, ' ') ?? '', product.description?.replace(/<[^>]+>/g, ' ') ?? '',
) )
const categoryLocale = textLocaleAttrs(product.category) const categoryLabel = formatProductCategoryChip(product, isFa)
const categoryLocale = textLocaleAttrs(categoryLabel)
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -154,15 +156,15 @@ export function ProductDetailsPage() {
</div> </div>
<div className={styles.details}> <div className={styles.details}>
{product.category && ( {categoryLabel ? (
<span <span
className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')} className={[styles.categoryChip, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang} lang={categoryLocale.lang}
dir={categoryLocale.dir} dir={isFa ? 'rtl' : 'ltr'}
> >
{product.category} {categoryLabel}
</span> </span>
)} ) : null}
<h1 <h1
className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')} className={[styles.nameEn, primaryLocale.className].filter(Boolean).join(' ')}
@@ -119,6 +119,37 @@
cursor: not-allowed; 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) { @media (max-width: 768px) {
.stepRow { .stepRow {
flex-wrap: wrap; flex-wrap: wrap;
+83 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { StepColorPicker } from '../components/StepColorPicker' import { StepColorPicker } from '../components/StepColorPicker'
@@ -16,6 +16,7 @@ import {
type StoreSettings, type StoreSettings,
} from '../services/settingsService' } from '../services/settingsService'
import { createId } from '../utils/id' import { createId } from '../utils/id'
import { getWebsiteOrigin } from '../utils/websiteLinks'
import { import {
defaultStepColorForId, defaultStepColorForId,
normalizeStepColor, normalizeStepColor,
@@ -29,6 +30,7 @@ import styles from './StoreSettingsPage.module.css'
const DEFAULT_STORE_SETTINGS: StoreSettings = { const DEFAULT_STORE_SETTINGS: StoreSettings = {
onlineSellEnabled: true, onlineSellEnabled: true,
torobEnabled: false,
orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS, orderProcessSteps: DEFAULT_ORDER_PROCESS_STEPS,
ePayment: { ePayment: {
enabled: false, 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() { export function StoreSettingsPage() {
const t = useT() const t = useT()
const { showToast } = useToast() const { showToast } = useToast()
@@ -97,7 +107,7 @@ export function StoreSettingsPage() {
try { try {
const data = await getSettings(signal) const data = await getSettings(signal)
setSettings(data.settings.store) setSettings(applyStore(data.settings.store))
setDraftSteps(data.settings.store.orderProcessSteps) setDraftSteps(data.settings.store.orderProcessSteps)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
@@ -117,7 +127,7 @@ export function StoreSettingsPage() {
try { try {
const data = await updateStoreSettings({ onlineSellEnabled: checked }) const data = await updateStoreSettings({ onlineSellEnabled: checked })
setSettings(data.settings.store) setSettings(applyStore(data.settings.store))
showToast(t('productSettings.saved'), 'success') showToast(t('productSettings.saved'), 'success')
} catch (err) { } catch (err) {
if (err instanceof ApiError) { 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) { function updateStepLabel(id: string, label: string) {
setDraftSteps((current) => setDraftSteps((current) =>
current.map((step) => (step.id === id ? { ...step, label } : step)), current.map((step) => (step.id === id ? { ...step, label } : step)),
@@ -199,7 +238,7 @@ export function StoreSettingsPage() {
try { try {
const data = await updateStoreSettings({ orderProcessSteps: normalized }) const data = await updateStoreSettings({ orderProcessSteps: normalized })
setSettings(data.settings.store) setSettings(applyStore(data.settings.store))
setDraftSteps(data.settings.store.orderProcessSteps) setDraftSteps(data.settings.store.orderProcessSteps)
showToast(t('storeSettings.stepsSaved'), 'success') showToast(t('storeSettings.stepsSaved'), 'success')
} catch (err) { } catch (err) {
@@ -223,6 +262,7 @@ export function StoreSettingsPage() {
draftSteps.every( draftSteps.every(
(step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0, (step) => step.label.trim().length > 0 && step.labelFa.trim().length > 0,
) )
const torobUrl = `${getWebsiteOrigin()}/torob_api/v3/products`
return ( return (
<main className={pageStyles.content}> <main className={pageStyles.content}>
@@ -269,6 +309,45 @@ export function StoreSettingsPage() {
onChange={(checked) => void handleOnlineSellChange(checked)} onChange={(checked) => void handleOnlineSellChange(checked)}
/> />
</div> </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> </div>
)} )}
</section> </section>
@@ -16,6 +16,8 @@ export interface ProductApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
categoryNameFa?: string categoryNameFa?: string
categoryParentName?: string
categoryParentNameFa?: string
brandId: string | null brandId: string | null
brand: { brand: {
id: string id: string
@@ -86,6 +88,8 @@ export function mapProductApiToUi(product: ProductApi): Product {
categoryId: product.categoryId ?? '', categoryId: product.categoryId ?? '',
category: product.categoryName, category: product.categoryName,
categoryFa: product.categoryNameFa || '', categoryFa: product.categoryNameFa || '',
categoryParent: product.categoryParentName || '',
categoryParentFa: product.categoryParentNameFa || '',
summary: product.summary, summary: product.summary,
description: product.descriptionHtml, description: product.descriptionHtml,
image: product.image || product.thumbnail || '', 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[] { export function getProductGalleryUrls(product: ProductApi): string[] {
const urls: string[] = [] const urls: string[] = []
const featured = product.thumbnailUrl || product.thumbnail || product.image const featured = product.thumbnailUrl || product.thumbnail || product.image
@@ -59,6 +59,7 @@ export interface EPaymentSettings {
export interface StoreSettings { export interface StoreSettings {
onlineSellEnabled: boolean onlineSellEnabled: boolean
torobEnabled: boolean
orderProcessSteps: OrderProcessStep[] orderProcessSteps: OrderProcessStep[]
ePayment: EPaymentSettings ePayment: EPaymentSettings
} }
+2
View File
@@ -5,6 +5,8 @@ export interface Product {
categoryId: string categoryId: string
category: string category: string
categoryFa?: string categoryFa?: string
categoryParent?: string
categoryParentFa?: string
summary: string summary: string
description: string description: string
image: string image: string
@@ -69,7 +69,8 @@ export function EmailDnsModal({ domain, onClose }: EmailDnsModalProps) {
zone file). We upsert mail records on{' '} zone file). We upsert mail records on{' '}
{dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (DNS-only / not {dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (DNS-only / not
proxied): <strong>mail</strong> A → 185.214.101.41, MX → mail.meshkee.com, 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> </p>
{error ? ( {error ? (
<p className={tableStyles.alertError} role="alert"> <p className={tableStyles.alertError} role="alert">
@@ -25,6 +25,7 @@ export interface BusinessSettings {
} }
store: { store: {
onlineSellEnabled: boolean onlineSellEnabled: boolean
torobEnabled?: boolean
orderProcessSteps: Array<{ id: string; label: string; color: string }> orderProcessSteps: Array<{ id: string; label: string; color: string }>
} }
modules: ModulesSettings 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. > **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. > 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` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) | | `/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) | | `/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). | | `/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` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template | | `/settings/invoice-templates/new` | Create invoice template |
@@ -276,6 +276,7 @@ Products accept optional `brandId` on create/update.
### Business settings ### Business settings
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website` - `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`. - `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.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`. - `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`. - **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`.