Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
97 lines
4 KiB
TypeScript
97 lines
4 KiB
TypeScript
// lib/format.ts
|
|
// Defensive formatters. All PocketBase text fields may come back as
|
|
// null, undefined, or — for some columns historically stored as
|
|
// numerics — actual numbers. String() coerce first.
|
|
|
|
const PLACEHOLDER = "—";
|
|
|
|
export function text(value: unknown, fallback: string = PLACEHOLDER): string {
|
|
if (value === null || value === undefined) return fallback;
|
|
const s = String(value).trim();
|
|
return s.length === 0 ? fallback : s;
|
|
}
|
|
|
|
export function number(value: unknown, fallback: number = 0): number {
|
|
if (value === null || value === undefined || value === "") return fallback;
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
export function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return PLACEHOLDER;
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return PLACEHOLDER;
|
|
// SA-style dd MMM yyyy (e.g. "08 Jul 2026"). Uses user locale-stable
|
|
// English month names to avoid I18n drift in screenshots.
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
const month = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
|
|
const year = d.getUTCFullYear();
|
|
return `${day} ${month} ${year}`;
|
|
}
|
|
|
|
/** Normalise a PocketBase `date` field value for use as the `value` of
|
|
* an `<input type="date">`. PB returns these as full ISO timestamps like
|
|
* `"2018-08-31 00:00:00.000Z"`; HTML5 date inputs only accept the bare
|
|
* `YYYY-MM-DD` prefix — anything longer is rejected and the field renders
|
|
* empty, which made saved dates look like they hadn't persisted when
|
|
* the user re-opened the edit modal.
|
|
*
|
|
* Returns "" for null/empty/non-date inputs so the input stays blank
|
|
* instead of showing the raw ISO string. */
|
|
export function dateInputValue(value: string | null | undefined): string {
|
|
if (typeof value !== "string") return "";
|
|
const s = value.trim();
|
|
if (!s) return "";
|
|
// Anything starting with YYYY-MM-DD is a date — keep just the prefix.
|
|
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
|
|
return "";
|
|
}
|
|
|
|
export function formatDateTime(iso: string | null | undefined): string {
|
|
if (!iso) return PLACEHOLDER;
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return PLACEHOLDER;
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
const month = d.toLocaleString("en-US", { month: "short", timeZone: "UTC" });
|
|
const year = d.getUTCFullYear();
|
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
return `${day} ${month} ${year} ${hh}:${mm}`;
|
|
}
|
|
|
|
/** Map raw `enterprise_status` text into the three buckets we render. */
|
|
export function deriveCompanyStatus(
|
|
enterpriseStatus: string | null,
|
|
isActive: boolean,
|
|
): "active" | "inactive" | "in_process" | "deregistered" {
|
|
const s = (enterpriseStatus ?? "").toLowerCase();
|
|
if (s.includes("deregist")) return "deregistered";
|
|
if (s.includes("in process") || s.includes("process")) return "in_process";
|
|
if (s.includes("in business") || s.includes("active")) return "active";
|
|
if (s.includes("inactive")) return "inactive";
|
|
// Fall back to the boolean if text status is ambiguous.
|
|
return isActive ? "active" : "inactive";
|
|
}
|
|
|
|
/** Build a PocketBase filter string safely. Escapes single quotes
|
|
* by doubling them (PB's own escape convention). */
|
|
export function quoteFilter(input: string): string {
|
|
return input.replace(/'/g, "''");
|
|
}
|
|
|
|
|
|
/** Loose email format check. Returns true if the string looks like
|
|
* a real address — not a typo, not empty, not missing the domain. */
|
|
export function isValidEmail(value: unknown): boolean {
|
|
if (typeof value !== "string") return false;
|
|
const v = value.trim();
|
|
if (!v) return false;
|
|
// Standard-ish: local@domain.tld, no spaces, must contain @ and a dot in the domain
|
|
// Allow + (plus addressing), dots in local part, etc.
|
|
return /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(v);
|
|
}
|
|
|
|
/** True if the recipient is missing OR has an invalid format. */
|
|
export function isMissingOrInvalidEmail(value: unknown): boolean {
|
|
return !isValidEmail(value);
|
|
}
|