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
@@ -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