From e2fb08cccdbbd2f7a4419253212c007edba1fa31 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 10:13:06 +0330 Subject: [PATCH] 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 --- .../business/src/components/HomeChartSlot.tsx | 8 +- apps/business/src/i18n/messages.ts | 4 +- .../src/services/dailyActivityService.ts | 18 ++- .../src/pages/BusinessesPage.module.css | 56 ++++++- apps/super-admin/src/pages/BusinessesPage.tsx | 138 ++++++++++++++++-- apps/super-admin/src/pages/WebsitesPage.tsx | 22 ++- .../src/services/businessService.ts | 1 + .../src/services/userTitleService.ts | 15 ++ apps/super-admin/src/types/business.ts | 3 + apps/super-admin/src/types/domain.ts | 3 + docs/PROJECT_CONTEXT.md | 2 +- 11 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 apps/super-admin/src/services/userTitleService.ts diff --git a/apps/business/src/components/HomeChartSlot.tsx b/apps/business/src/components/HomeChartSlot.tsx index cdd9ca2..762cb01 100644 --- a/apps/business/src/components/HomeChartSlot.tsx +++ b/apps/business/src/components/HomeChartSlot.tsx @@ -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', diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 78a5114..6c6af6a 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -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 = { 'home.welcome': '{name} عزیز، خوش آمدی.', 'home.welcomeFallback': 'کاربر', - 'home.subtitle': 'وضعیت فروشگاهتان را از اینجا دنبال کنید.', + 'home.subtitle': 'وضعیت مجموعه‌ی خود را از اینجا دنبال کنید.', 'home.card.products.title': 'محصولات', 'home.card.products.desc': 'محصولات، موجودی و دسته‌بندی‌ها را مدیریت کنید.', 'home.card.products.link': 'مشاهده محصولات', diff --git a/apps/business/src/services/dailyActivityService.ts b/apps/business/src/services/dailyActivityService.ts index 48d74ea..6ed3b7c 100644 --- a/apps/business/src/services/dailyActivityService.ts +++ b/apps/business/src/services/dailyActivityService.ts @@ -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, diff --git a/apps/super-admin/src/pages/BusinessesPage.module.css b/apps/super-admin/src/pages/BusinessesPage.module.css index d3d2b7c..9a019b6 100644 --- a/apps/super-admin/src/pages/BusinessesPage.module.css +++ b/apps/super-admin/src/pages/BusinessesPage.module.css @@ -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; diff --git a/apps/super-admin/src/pages/BusinessesPage.tsx b/apps/super-admin/src/pages/BusinessesPage.tsx index f937e9f..e99d057 100644 --- a/apps/super-admin/src/pages/BusinessesPage.tsx +++ b/apps/super-admin/src/pages/BusinessesPage.tsx @@ -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 } -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 = { @@ -253,6 +300,8 @@ export function BusinessesPage() { const [createNameFa, setCreateNameFa] = useState('') const [createAbout, setCreateAbout] = useState('') const [createCategoryIds, setCreateCategoryIds] = useState([]) + 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([]) const [removeTarget, setRemoveTarget] = useState(null) const [togglingId, setTogglingId] = useState(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() { Name - Date created + Contract Data / Renewal Domain Owner Theme @@ -1199,10 +1259,29 @@ export function BusinessesPage() { )} {!loading && - data?.items?.map((b) => ( + data?.items?.map((b) => { + const renewal = formatRenewalCell(b.annualRenewalAt ?? null) + return ( {b.name} - {formatDate(b.createdAt)} + +
+ {renewal.yearLabel} + {renewal.daysLabel ? ( + + {renewal.daysLabel} + + ) : null} +
+ {b.domain ? (
@@ -1355,7 +1434,8 @@ export function BusinessesPage() {
- ))} + ) + })} @@ -1945,7 +2025,7 @@ export function BusinessesPage() { /> -
+
+
+ + setCreateAnnualRenewalAt(e.target.value)} + disabled={createSubmitting} + /> +
+
Owner
-
+
+ + +
+ +
-
+
-
+