110 lines
4.4 KiB
TypeScript
110 lines
4.4 KiB
TypeScript
/**
|
|
* Display labels for admin values that are stored as identifiers.
|
|
*
|
|
* The admin audit from 2026-07-26 found the same value rendered two ways
|
|
* depending on the screen: `/support` listed `open` / `normal` / `seed` while the
|
|
* ticket detail showed "Offen" / "Normal", and `/accounts/billing` printed
|
|
* `business` where `/accounts` printed "Business". These helpers are the single
|
|
* place that decides how such a value reads, so a list and a detail view can no
|
|
* longer drift apart.
|
|
*
|
|
* Two kinds of values are handled differently:
|
|
*
|
|
* - Closed sets (ticket status, priority) resolve to an i18n key.
|
|
* - Open sets (operator-defined plan codes, provider keys) are identifiers we
|
|
* cannot translate exhaustively, so they are humanized instead — never shown
|
|
* raw and never invented as a missing translation key.
|
|
*/
|
|
|
|
/** Ticket statuses that have a translation under `admin.support.status.*`. */
|
|
export const SUPPORT_TICKET_STATUSES = ["open", "pending", "answered", "closed"] as const;
|
|
|
|
/** Ticket priorities that have a translation under `admin.support.priority.*`. */
|
|
export const SUPPORT_TICKET_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
|
|
|
/** Subscription states that have a translation under `admin.billing.status.*`. */
|
|
export const SUBSCRIPTION_STATUSES = ["trial", "active", "past_due", "cancelled", "frozen"] as const;
|
|
|
|
/**
|
|
* Provider keys whose casing is a brand name rather than a word, so title-casing
|
|
* would spell them wrong ("Hubspot" instead of "HubSpot").
|
|
*/
|
|
const BRAND_NAMES: Readonly<Record<string, string>> = {
|
|
aircall: "Aircall",
|
|
brevo: "Brevo",
|
|
discord: "Discord",
|
|
github: "GitHub",
|
|
gitlab: "GitLab",
|
|
hubspot: "HubSpot",
|
|
kwp: "KWP",
|
|
lago: "Lago",
|
|
mailchimp: "Mailchimp",
|
|
mailjet: "Mailjet",
|
|
meilisearch: "Meilisearch",
|
|
mollie: "Mollie",
|
|
pandadoc: "PandaDoc",
|
|
sendgrid: "SendGrid",
|
|
slack: "Slack",
|
|
webhook: "Webhook",
|
|
};
|
|
|
|
/**
|
|
* Turns a stored identifier into readable text: `past_due` → "Past due",
|
|
* `admin-sales` → "Admin sales", `business` → "Business". Only the first word is
|
|
* capitalized, because German sentence case reads better than Title Case for
|
|
* multi-word values.
|
|
*/
|
|
export function humanizeIdentifier(value: string): string {
|
|
const words = value
|
|
.trim()
|
|
.split(/[\s_.-]+/u)
|
|
.filter(Boolean);
|
|
if (words.length === 0) return value;
|
|
const [first, ...rest] = words;
|
|
return [first.charAt(0).toUpperCase() + first.slice(1).toLowerCase(), ...rest.map((word) => word.toLowerCase())].join(" ");
|
|
}
|
|
|
|
/** i18n key for a ticket status, or `null` when the value is not a known status. */
|
|
export function supportStatusLabelKey(status: string): string | null {
|
|
return (SUPPORT_TICKET_STATUSES as readonly string[]).includes(status) ? `admin.support.status.${status}` : null;
|
|
}
|
|
|
|
/** i18n key for a ticket priority, or `null` when the value is unknown. */
|
|
export function supportPriorityLabelKey(priority: string): string | null {
|
|
return (SUPPORT_TICKET_PRIORITIES as readonly string[]).includes(priority) ? `admin.support.priority.${priority}` : null;
|
|
}
|
|
|
|
/** i18n key for a subscription state, or `null` when the value is unknown. */
|
|
export function subscriptionStatusLabelKey(status: string): string | null {
|
|
return (SUBSCRIPTION_STATUSES as readonly string[]).includes(status) ? `admin.billing.status.${status}` : null;
|
|
}
|
|
|
|
/**
|
|
* Ticket intake channel. The set grows with every new intake path (`email`,
|
|
* `inline`, seeded demo data), so it is humanized rather than translated.
|
|
*/
|
|
export function supportSourceLabel(source: string): string {
|
|
return humanizeIdentifier(source);
|
|
}
|
|
|
|
/** Operator-defined subscription plan code, e.g. `business` → "Business". */
|
|
export function planCodeLabel(planCode: string | null | undefined): string | null {
|
|
if (!planCode?.trim()) return null;
|
|
return humanizeIdentifier(planCode);
|
|
}
|
|
|
|
/** Integration or provider key, with brand casing preserved. */
|
|
export function providerLabel(provider: string): string {
|
|
const normalized = provider.trim().toLowerCase();
|
|
return BRAND_NAMES[normalized] ?? humanizeIdentifier(provider);
|
|
}
|
|
|
|
/**
|
|
* Renders a closed-set value through i18n and falls back to a humanized form for
|
|
* values the catalog does not know yet. Keeps an unexpected value readable
|
|
* instead of showing either a raw enum or a missing translation key.
|
|
*/
|
|
export function translateOrHumanize(translate: (key: string) => string, labelKey: string | null, rawValue: string): string {
|
|
return labelKey ? translate(labelKey) : humanizeIdentifier(rawValue);
|
|
}
|