Show contract renewal in businesses and align SSL/view charts.
Add annual renewal column with color-coded days left, owner title and renewal fields on create, SSL expiry display, and updated view chart kinds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
a52f963f52
commit
e2fb08cccd
@@ -30,7 +30,7 @@ const VIEW_CHART_CONFIG: Record<
|
||||
}
|
||||
> = {
|
||||
product_views_30d: {
|
||||
kind: 'product',
|
||||
kind: 'product_detail',
|
||||
titleKey: 'home.chart.productViews.title',
|
||||
subtitleKey: 'home.chart.productViews.subtitle',
|
||||
legendKey: 'home.chart.productViews.legend',
|
||||
@@ -39,7 +39,7 @@ const VIEW_CHART_CONFIG: Record<
|
||||
barKey: 'home.chart.productViews.bar',
|
||||
},
|
||||
website_views_30d: {
|
||||
kind: 'website',
|
||||
kind: 'home',
|
||||
titleKey: 'home.chart.websiteViews.title',
|
||||
subtitleKey: 'home.chart.websiteViews.subtitle',
|
||||
legendKey: 'home.chart.websiteViews.legend',
|
||||
@@ -48,7 +48,7 @@ const VIEW_CHART_CONFIG: Record<
|
||||
barKey: 'home.chart.websiteViews.bar',
|
||||
},
|
||||
portfolio_views_30d: {
|
||||
kind: 'portfolio',
|
||||
kind: 'portfolio_detail',
|
||||
titleKey: 'home.chart.portfolioViews.title',
|
||||
subtitleKey: 'home.chart.portfolioViews.subtitle',
|
||||
legendKey: 'home.chart.portfolioViews.legend',
|
||||
@@ -57,7 +57,7 @@ const VIEW_CHART_CONFIG: Record<
|
||||
barKey: 'home.chart.portfolioViews.bar',
|
||||
},
|
||||
blog_views_30d: {
|
||||
kind: 'blog',
|
||||
kind: 'blog_detail',
|
||||
titleKey: 'home.chart.blogViews.title',
|
||||
subtitleKey: 'home.chart.blogViews.subtitle',
|
||||
legendKey: 'home.chart.blogViews.legend',
|
||||
|
||||
@@ -196,7 +196,7 @@ const en = {
|
||||
|
||||
'home.welcome': 'Welcome back, {name}!',
|
||||
'home.welcomeFallback': 'there',
|
||||
'home.subtitle': "Here's what's happening with your store today.",
|
||||
'home.subtitle': 'Track your business status from here.',
|
||||
'home.card.products.title': 'Products',
|
||||
'home.card.products.desc': 'Manage your products, inventory and categories.',
|
||||
'home.card.products.link': 'View products',
|
||||
@@ -2455,7 +2455,7 @@ const fa: Record<MessageKey, string> = {
|
||||
|
||||
'home.welcome': '{name} عزیز، خوش آمدی.',
|
||||
'home.welcomeFallback': 'کاربر',
|
||||
'home.subtitle': 'وضعیت فروشگاهتان را از اینجا دنبال کنید.',
|
||||
'home.subtitle': 'وضعیت مجموعهی خود را از اینجا دنبال کنید.',
|
||||
'home.card.products.title': 'محصولات',
|
||||
'home.card.products.desc': 'محصولات، موجودی و دستهبندیها را مدیریت کنید.',
|
||||
'home.card.products.link': 'مشاهده محصولات',
|
||||
|
||||
@@ -40,7 +40,23 @@ export async function getCustomersDailyActivity(days = 30, signal?: AbortSignal)
|
||||
)
|
||||
}
|
||||
|
||||
export type WebsiteViewKind = 'website' | 'product' | 'portfolio' | 'blog'
|
||||
export type WebsiteViewKind =
|
||||
| 'home'
|
||||
| 'portfolio_list'
|
||||
| 'portfolio_detail'
|
||||
| 'product_list'
|
||||
| 'product_detail'
|
||||
| 'store_item_list'
|
||||
| 'store_item_detail'
|
||||
| 'blog_list'
|
||||
| 'blog_detail'
|
||||
| 'video_list'
|
||||
| 'video_detail'
|
||||
/** @deprecated aliases still accepted by API */
|
||||
| 'website'
|
||||
| 'product'
|
||||
| 'portfolio'
|
||||
| 'blog'
|
||||
|
||||
export async function getViewDailyActivity(
|
||||
kind: WebsiteViewKind,
|
||||
|
||||
@@ -557,6 +557,37 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.renewalCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.renewalYear {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.renewalDays {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.renewalOk {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.renewalWarn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.renewalDanger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.ownerName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -696,10 +727,26 @@
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.formGrid > .field {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.formCol2 {
|
||||
grid-column: span 2 !important;
|
||||
}
|
||||
|
||||
.formCol4 {
|
||||
grid-column: span 4 !important;
|
||||
}
|
||||
|
||||
.formCol8 {
|
||||
grid-column: span 8 !important;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
@@ -818,6 +865,13 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.formGrid > .field,
|
||||
.formCol2,
|
||||
.formCol4,
|
||||
.formCol8 {
|
||||
grid-column: 1 / -1 !important;
|
||||
}
|
||||
|
||||
.fab {
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type LegacyMigrateLookupResponse,
|
||||
} from '../services/businessService'
|
||||
import { listBusinessCategories } from '../services/categoryService'
|
||||
import { listUserTitles, type UserTitleOption } from '../services/userTitleService'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { MultiSelectDropdown } from '../components/MultiSelectDropdown'
|
||||
import { PrimaryColorSwatchControl } from '../components/PrimaryColorSwatchControl'
|
||||
@@ -100,10 +101,56 @@ function emptyModuleSelection(
|
||||
) as Record<BusinessModuleId, boolean>
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return value
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })
|
||||
/** Next anniversary of the contract date (renews every year). */
|
||||
function nextAnnualRenewal(contractAt: string, now = new Date()) {
|
||||
const contract = new Date(contractAt)
|
||||
if (Number.isNaN(contract.getTime())) return null
|
||||
|
||||
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
let next = new Date(
|
||||
startToday.getFullYear(),
|
||||
contract.getMonth(),
|
||||
contract.getDate(),
|
||||
)
|
||||
if (next.getTime() < startToday.getTime()) {
|
||||
next = new Date(
|
||||
startToday.getFullYear() + 1,
|
||||
contract.getMonth(),
|
||||
contract.getDate(),
|
||||
)
|
||||
}
|
||||
const daysRemaining = Math.round(
|
||||
(next.getTime() - startToday.getTime()) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
return { next, year: next.getFullYear(), daysRemaining }
|
||||
}
|
||||
|
||||
function renewalTone(daysRemaining: number): 'ok' | 'warn' | 'danger' {
|
||||
const months = daysRemaining / 30.437
|
||||
if (months > 8) return 'ok'
|
||||
if (months >= 2) return 'warn'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
function formatRenewalCell(annualRenewalAt: string | null) {
|
||||
if (!annualRenewalAt) {
|
||||
return { yearLabel: '—', daysLabel: null as string | null, tone: null as 'ok' | 'warn' | 'danger' | null }
|
||||
}
|
||||
const next = nextAnnualRenewal(annualRenewalAt)
|
||||
if (!next) {
|
||||
return { yearLabel: '—', daysLabel: null, tone: null }
|
||||
}
|
||||
const daysLabel =
|
||||
next.daysRemaining === 0
|
||||
? 'Renews today'
|
||||
: next.daysRemaining === 1
|
||||
? '1 day left'
|
||||
: `${next.daysRemaining} days left`
|
||||
return {
|
||||
yearLabel: String(next.year),
|
||||
daysLabel,
|
||||
tone: renewalTone(next.daysRemaining),
|
||||
}
|
||||
}
|
||||
|
||||
const MIGRATE_ENTITY_LABELS: Record<MigrateFromOldEntity, string> = {
|
||||
@@ -253,6 +300,8 @@ export function BusinessesPage() {
|
||||
const [createNameFa, setCreateNameFa] = useState('')
|
||||
const [createAbout, setCreateAbout] = useState('')
|
||||
const [createCategoryIds, setCreateCategoryIds] = useState<number[]>([])
|
||||
const [createAnnualRenewalAt, setCreateAnnualRenewalAt] = useState('')
|
||||
const [createOwnerTitleId, setCreateOwnerTitleId] = useState('')
|
||||
const [createOwnerFirstName, setCreateOwnerFirstName] = useState('')
|
||||
const [createOwnerLastName, setCreateOwnerLastName] = useState('')
|
||||
const [createOwnerFirstNameEn, setCreateOwnerFirstNameEn] = useState('')
|
||||
@@ -261,6 +310,7 @@ export function BusinessesPage() {
|
||||
const [createOwnerPassword, setCreateOwnerPassword] = useState('')
|
||||
const [createSubmitting, setCreateSubmitting] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [userTitles, setUserTitles] = useState<UserTitleOption[]>([])
|
||||
|
||||
const [removeTarget, setRemoveTarget] = useState<BusinessListItem | null>(null)
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||
@@ -324,7 +374,7 @@ export function BusinessesPage() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [result, categoryResult] = await Promise.all([
|
||||
const [result, categoryResult, titlesResult] = await Promise.all([
|
||||
listBusinesses(
|
||||
{
|
||||
page,
|
||||
@@ -334,10 +384,12 @@ export function BusinessesPage() {
|
||||
controller.signal,
|
||||
),
|
||||
listBusinessCategories(controller.signal),
|
||||
listUserTitles(controller.signal),
|
||||
])
|
||||
if (controller.signal.aborted) return
|
||||
setData(result)
|
||||
setCategories(categoryResult.items)
|
||||
setUserTitles(titlesResult.items)
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return
|
||||
setError(err instanceof ApiError ? err.message : 'Unable to load businesses.')
|
||||
@@ -968,6 +1020,8 @@ export function BusinessesPage() {
|
||||
setCreateNameFa('')
|
||||
setCreateAbout('')
|
||||
setCreateCategoryIds([])
|
||||
setCreateAnnualRenewalAt('')
|
||||
setCreateOwnerTitleId('')
|
||||
setCreateOwnerFirstName('')
|
||||
setCreateOwnerLastName('')
|
||||
setCreateOwnerFirstNameEn('')
|
||||
@@ -994,6 +1048,12 @@ export function BusinessesPage() {
|
||||
nameFa: createNameFa.trim(),
|
||||
about: createAbout.trim() || undefined,
|
||||
categoryIds: createCategoryIds,
|
||||
...(createAnnualRenewalAt
|
||||
? { annualRenewalAt: new Date(createAnnualRenewalAt).toISOString() }
|
||||
: {}),
|
||||
...(createOwnerTitleId
|
||||
? { ownerTitleId: Number(createOwnerTitleId) }
|
||||
: {}),
|
||||
ownerFirstName: createOwnerFirstName.trim(),
|
||||
ownerLastName: createOwnerLastName.trim(),
|
||||
ownerFirstNameEn: createOwnerFirstNameEn.trim(),
|
||||
@@ -1172,7 +1232,7 @@ export function BusinessesPage() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.th}>Name</th>
|
||||
<th className={styles.th}>Date created</th>
|
||||
<th className={styles.th}>Contract Data / Renewal</th>
|
||||
<th className={styles.th}>Domain</th>
|
||||
<th className={styles.th}>Owner</th>
|
||||
<th className={`${styles.th} ${styles.thTheme}`}>Theme</th>
|
||||
@@ -1199,10 +1259,29 @@ export function BusinessesPage() {
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data?.items?.map((b) => (
|
||||
data?.items?.map((b) => {
|
||||
const renewal = formatRenewalCell(b.annualRenewalAt ?? null)
|
||||
return (
|
||||
<tr key={b.id}>
|
||||
<td className={styles.td}>{b.name}</td>
|
||||
<td className={styles.td}>{formatDate(b.createdAt)}</td>
|
||||
<td className={styles.td}>
|
||||
<div className={styles.renewalCell}>
|
||||
<span className={styles.renewalYear}>{renewal.yearLabel}</span>
|
||||
{renewal.daysLabel ? (
|
||||
<span
|
||||
className={`${styles.renewalDays} ${
|
||||
renewal.tone === 'ok'
|
||||
? styles.renewalOk
|
||||
: renewal.tone === 'warn'
|
||||
? styles.renewalWarn
|
||||
: styles.renewalDanger
|
||||
}`}
|
||||
>
|
||||
{renewal.daysLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.td}>
|
||||
{b.domain ? (
|
||||
<div className={styles.domainCell}>
|
||||
@@ -1355,7 +1434,8 @@ export function BusinessesPage() {
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1945,7 +2025,7 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<div className={`${styles.field} ${styles.formCol8}`}>
|
||||
<label htmlFor="create-categories">Categories</label>
|
||||
<MultiSelectDropdown
|
||||
id="create-categories"
|
||||
@@ -1957,11 +2037,39 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formCol4}`}>
|
||||
<label htmlFor="create-annual-renewal">Annual renewal</label>
|
||||
<input
|
||||
id="create-annual-renewal"
|
||||
type="date"
|
||||
value={createAnnualRenewalAt}
|
||||
onChange={(e) => setCreateAnnualRenewalAt(e.target.value)}
|
||||
disabled={createSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formFull}`}>
|
||||
<div className={styles.sectionLabel}>Owner</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<div className={`${styles.field} ${styles.formCol2}`}>
|
||||
<label htmlFor="create-owner-title">Title</label>
|
||||
<select
|
||||
id="create-owner-title"
|
||||
value={createOwnerTitleId}
|
||||
onChange={(e) => setCreateOwnerTitleId(e.target.value)}
|
||||
disabled={createSubmitting || userTitles.length === 0}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{userTitles.map((title) => (
|
||||
<option key={title.id} value={title.id}>
|
||||
{title.nameEn} / {title.nameFa}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.formCol4}`}>
|
||||
<label htmlFor="create-owner-first-name-en">First name (EN)</label>
|
||||
<input
|
||||
id="create-owner-first-name-en"
|
||||
@@ -1972,7 +2080,7 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<div className={`${styles.field} ${styles.formCol4}`}>
|
||||
<label htmlFor="create-owner-last-name-en">Last name (EN)</label>
|
||||
<input
|
||||
id="create-owner-last-name-en"
|
||||
@@ -1983,7 +2091,9 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<div className={`${styles.field} ${styles.formCol2}`} aria-hidden="true" />
|
||||
|
||||
<div className={`${styles.field} ${styles.formCol4}`}>
|
||||
<label htmlFor="create-owner-first-name">First name (FA)</label>
|
||||
<input
|
||||
id="create-owner-first-name"
|
||||
@@ -1997,7 +2107,7 @@ export function BusinessesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<div className={`${styles.field} ${styles.formCol4}`}>
|
||||
<label htmlFor="create-owner-last-name">Last name (FA)</label>
|
||||
<input
|
||||
id="create-owner-last-name"
|
||||
|
||||
@@ -323,7 +323,13 @@ export function WebsitesPage() {
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domain.id ? { ...item, sslEnabled: result.sslEnabled } : item,
|
||||
item.id === domain.id
|
||||
? {
|
||||
...item,
|
||||
sslEnabled: result.sslEnabled,
|
||||
sslExpiresAt: result.sslExpiresAt,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -561,7 +567,7 @@ export function WebsitesPage() {
|
||||
<tr>
|
||||
<th className={tableStyles.th}>Name</th>
|
||||
<th className={tableStyles.th}>Owner business</th>
|
||||
<th className={tableStyles.th}>Days to expire</th>
|
||||
<th className={tableStyles.th}>Service expires</th>
|
||||
<th className={tableStyles.th}>SSL</th>
|
||||
<th className={tableStyles.th}>Latest deploy</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||
@@ -602,7 +608,11 @@ export function WebsitesPage() {
|
||||
{domain.sslEnabled ? (
|
||||
<span
|
||||
className={tableStyles.sslIcon}
|
||||
title="SSL certificate valid"
|
||||
title={
|
||||
domain.sslExpiresAt
|
||||
? `SSL valid until ${new Date(domain.sslExpiresAt).toLocaleDateString()}`
|
||||
: 'SSL certificate valid'
|
||||
}
|
||||
aria-label="SSL certificate valid"
|
||||
>
|
||||
<Lock size={16} className={tableStyles.sslOk} aria-hidden="true" />
|
||||
@@ -621,7 +631,11 @@ export function WebsitesPage() {
|
||||
</span>
|
||||
)}
|
||||
<span className={tableStyles.subText}>
|
||||
{domain.sslEnabled ? 'Valid' : 'Invalid'}
|
||||
{domain.sslEnabled
|
||||
? domain.sslExpiresAt
|
||||
? `Valid · ${formatDaysLeft(domain.sslExpiresAt)}`
|
||||
: 'Valid'
|
||||
: 'Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface UpdateBusinessPayload {
|
||||
about?: string
|
||||
slug?: string
|
||||
oldBusinessId?: number | null
|
||||
annualRenewalAt?: string | null
|
||||
}
|
||||
|
||||
export interface AddDomainPayload {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiRequest } from '../lib/api'
|
||||
|
||||
export interface UserTitleOption {
|
||||
id: string
|
||||
slug: string
|
||||
nameEn: string
|
||||
nameFa: string
|
||||
}
|
||||
|
||||
export async function listUserTitles(signal?: AbortSignal) {
|
||||
return apiRequest<{ items: UserTitleOption[] }>('/user-titles', {
|
||||
auth: true,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export interface BusinessListItem {
|
||||
nameFa: string | null
|
||||
about: string | null
|
||||
createdAt: string
|
||||
annualRenewalAt: string | null
|
||||
domainId: number | null
|
||||
domain: string | null
|
||||
sslEnabled: boolean | null
|
||||
@@ -107,6 +108,8 @@ export interface CreateBusinessPayload {
|
||||
about?: string
|
||||
slug?: string
|
||||
categoryIds: number[]
|
||||
annualRenewalAt?: string
|
||||
ownerTitleId?: number
|
||||
ownerFirstName: string
|
||||
ownerLastName: string
|
||||
ownerFirstNameEn: string
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface DomainListItem {
|
||||
businessId: number
|
||||
businessName: string
|
||||
sslEnabled: boolean
|
||||
/** TLS certificate notAfter when SSL was last confirmed. */
|
||||
sslExpiresAt: string | null
|
||||
isActive: boolean
|
||||
expiresAt: string | null
|
||||
createdAt: string
|
||||
@@ -53,6 +55,7 @@ export interface IssueDomainSslResponse {
|
||||
status: 'ok' | 'partial'
|
||||
host: string
|
||||
sslEnabled: boolean
|
||||
sslExpiresAt: string | null
|
||||
hosts: {
|
||||
apex: IssueDomainSslHostResult
|
||||
www: IssueDomainSslHostResult
|
||||
|
||||
@@ -276,7 +276,7 @@ Products accept optional `brandId` on create/update.
|
||||
- `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`.
|
||||
- `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 need `POST /tenants/{domain}/analytics/views` from the storefront.
|
||||
- `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). Lifetime totals in `website_page_view_counters`.
|
||||
- Always-on (not in modules list): business — customers, website, profile, settings, home; customer — home, profile, addresses, orders, favorites
|
||||
- Tenant public: `GET /tenants/:host` includes `enabledModules` + `homeCharts` + `specialProductsSource`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user