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:
Alireza Hassani
2026-08-26 10:13:06 +03:30
co-authored by Cursor
parent a52f963f52
commit e2fb08cccd
11 changed files with 243 additions and 27 deletions
@@ -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',
+2 -2
View File
@@ -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;
+124 -14
View File
@@ -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"
+18 -4
View File
@@ -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,
})
}
+3
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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`