import type { Field, Choice } from '@/types/field' import { DEFAULT_CURRENCY_CODE, type CurrencyCode, getCurrencySymbolForCode, SUPPORTED_CURRENCIES, } from '@/constants/supportedCurrencies' export type ProductFieldType = 'single' | 'checkbox' | 'radio' | 'select' | 'user_defined' export type ProductChoiceLayout = 'inline' | 'columns' | 'button' /** * Product field stores extended configuration in `htmlContent` as JSON * (the DB `settings` column already persists `htmlContent` for all field types). */ export interface ProductFieldConfig { configVersion?: number productType: ProductFieldType paymentAmount: string amountLabel: string showPriceAfterItemLabel: boolean /** Map of field option `value` -> price string (e.g. "29.99") */ prices: Record choiceLayout: ProductChoiceLayout /** When choiceLayout is columns (2–6). */ columnsCount: number } const CURRENT_CONFIG_VERSION = 1 const defaultConfig = (): ProductFieldConfig => ({ configVersion: CURRENT_CONFIG_VERSION, productType: 'radio', paymentAmount: '', amountLabel: '', showPriceAfterItemLabel: false, prices: {}, choiceLayout: 'inline', columnsCount: 3, }) function normalizeProductType(parsed: Partial): ProductFieldType { const raw = parsed.productType as string | undefined const allowed: ProductFieldType[] = ['single', 'checkbox', 'radio', 'select', 'user_defined'] if (raw && allowed.includes(raw as ProductFieldType)) { return raw as ProductFieldType } return 'radio' } export function parseProductFieldConfig(field?: Field | null): ProductFieldConfig { if (!field) { return defaultConfig() } const raw = field.htmlContent if (typeof raw !== 'string' || raw.trim() === '') { return defaultConfig() } try { const parsed = JSON.parse(raw) as Partial const rawVersion = typeof parsed.configVersion === 'number' ? parsed.configVersion : 0 const choiceLayout = parsed.choiceLayout === 'inline' || parsed.choiceLayout === 'columns' || parsed.choiceLayout === 'button' ? parsed.choiceLayout : 'inline' let columnsCount = typeof parsed.columnsCount === 'number' && Number.isFinite(parsed.columnsCount) ? Math.round(parsed.columnsCount) : 3 columnsCount = Math.min(6, Math.max(2, columnsCount)) return { ...defaultConfig(), ...parsed, configVersion: rawVersion >= 1 ? rawVersion : 0, productType: normalizeProductType(parsed), prices: parsed.prices && typeof parsed.prices === 'object' && !Array.isArray(parsed.prices) ? Object.fromEntries( Object.entries(parsed.prices as Record).map(([key, value]) => [ key, typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '', ]), ) : {}, choiceLayout, columnsCount, } } catch { return defaultConfig() } } export function stringifyProductFieldConfig(config: ProductFieldConfig): string { return JSON.stringify({ configVersion: CURRENT_CONFIG_VERSION, productType: config.productType, paymentAmount: config.paymentAmount, amountLabel: config.amountLabel, showPriceAfterItemLabel: config.showPriceAfterItemLabel, prices: config.prices, choiceLayout: config.choiceLayout, columnsCount: config.columnsCount, }) } export function productFieldUsesOptionList(config: ProductFieldConfig): boolean { return ( config.productType === 'checkbox' || config.productType === 'radio' || config.productType === 'select' ) } export function productFieldValueIsArray(config: ProductFieldConfig): boolean { return config.productType === 'checkbox' } export function getProductOptionPriceRaw(config: ProductFieldConfig, option: Choice): string { return (config.prices[String(option.value)] ?? '').trim() } export function showProductOptionPrice(config: ProductFieldConfig, option: Choice): boolean { return Boolean(config.showPriceAfterItemLabel) && getProductOptionPriceRaw(config, option) !== '' } /** Formatted amount for the price column, e.g. "29.99 $". */ export function formatProductPriceWithCurrencySuffix( priceRaw: string, currencyCode: CurrencyCode = DEFAULT_CURRENCY_CODE, ): string { const t = priceRaw.trim() if (t === '') { return '' } return `${t} ${getCurrencySymbolForCode(currencyCode)}` } export const PRODUCT_ENTRY_SNAPSHOT_VERSION = 1 as const /** Unique symbols, longest first, so stripping legacy formatted prices matches "R$" before "$". */ function getCurrencySymbolsLongestFirst(): string[] { return [...new Set(SUPPORTED_CURRENCIES.map((c) => c.symbol))].sort((a, b) => b.length - a.length) } /** * Recover numeric amount from a legacy snapshot `price` like "29.99 £". */ export function extractLegacyProductPriceAmount(formattedPrice: string): string | null { let t = formattedPrice.trim() if (t === '') { return null } for (const sym of getCurrencySymbolsLongestFirst()) { if (t.endsWith(sym)) { t = t.slice(0, -sym.length).trimEnd() break } } t = t.replace(/\s/g, '') if (t === '') { return null } if (!/^[\d,.]+$/.test(t)) { return null } return t } export interface ProductEntrySnapshotLine { label: string /** Raw amount string (e.g. "29.99"); formatted with form payment currency when shown. */ priceAmount?: string /** @deprecated Pre-formatted at submit — use extractLegacyProductPriceAmount + current currency when no priceAmount */ price?: string } function resolveSnapshotLinePriceAmount(line: ProductEntrySnapshotLine): string | null { const direct = (line.priceAmount ?? '').trim() if (direct !== '') { return direct } const legacyFormatted = (line.price ?? '').trim() if (legacyFormatted !== '') { return extractLegacyProductPriceAmount(legacyFormatted) } return null } export interface ProductEntrySnapshotV1 { v: typeof PRODUCT_ENTRY_SNAPSHOT_VERSION productType: ProductFieldType raw: string lines: ProductEntrySnapshotLine[] summary: string } function optionLineForEntry( cfg: ProductFieldConfig, optLabel: string, valueKey: string, ): ProductEntrySnapshotLine { const priceRaw = (cfg.prices[String(valueKey)] ?? '').trim() if (!priceRaw) { return { label: optLabel } } return { label: optLabel, priceAmount: priceRaw, } } /** One line for entry UI / exports (amount + current form currency). */ export function formatProductSnapshotLineForDisplay( line: ProductEntrySnapshotLine, currencyCode: CurrencyCode = DEFAULT_CURRENCY_CODE, ): string { const amount = resolveSnapshotLinePriceAmount(line) if (amount) { const withSym = formatProductPriceWithCurrencySuffix(amount, currencyCode) if (line.label) { return `${line.label} - ${withSym}` } return withSym } return line.label } function linesToSummary( lines: ProductEntrySnapshotLine[], currencyCode: CurrencyCode = DEFAULT_CURRENCY_CODE, ): string { return lines .map((l) => formatProductSnapshotLineForDisplay(l, currencyCode)) .filter(Boolean) .join(' · ') } function normalizeProductSubmitRaw(productType: ProductFieldType, rawValue: unknown): string { if (productType === 'checkbox') { if (Array.isArray(rawValue)) { return rawValue .map((x) => String(x).trim()) .filter(Boolean) .join(',') } } if (rawValue === null || rawValue === undefined) { return '' } return String(rawValue).trim() } /** * JSON string persisted as entry field value: labels and raw amounts; currency is applied when viewing (same as total field). */ export function buildProductEntrySnapshot( field: Field, rawValue: unknown, currencyCode: CurrencyCode = DEFAULT_CURRENCY_CODE, ): string { const cfg = parseProductFieldConfig(field) const raw = normalizeProductSubmitRaw(cfg.productType, rawValue) const lines: ProductEntrySnapshotLine[] = [] if (cfg.productType === 'single') { const title = String(field.defaultValue ?? '').trim() if (title) { lines.push({ label: title }) } const pay = (cfg.paymentAmount ?? '').trim() const amountLbl = (cfg.amountLabel ?? '').trim() if (amountLbl && pay) { lines.push({ label: amountLbl, priceAmount: pay, }) } else if (pay) { lines.push({ label: '', priceAmount: pay, }) } else if (amountLbl) { lines.push({ label: amountLbl }) } } else if (cfg.productType === 'user_defined') { if (raw) { lines.push({ label: raw }) } } else if (cfg.productType === 'checkbox') { const opts: Choice[] = Array.isArray(field.fieldOptions) ? field.fieldOptions : [] const values = raw .split(',') .map((v) => v.trim()) .filter(Boolean) values.forEach((val) => { const opt = opts.find((o) => String(o.value) === val) const label = opt?.label ?? val lines.push(optionLineForEntry(cfg, label, val)) }) } else { const opts: Choice[] = Array.isArray(field.fieldOptions) ? field.fieldOptions : [] if (raw) { const opt = opts.find((o) => String(o.value) === raw) const label = opt?.label ?? raw lines.push(optionLineForEntry(cfg, label, raw)) } } const summary = linesToSummary(lines, currencyCode) const snapshot: ProductEntrySnapshotV1 = { v: PRODUCT_ENTRY_SNAPSHOT_VERSION, productType: cfg.productType, raw, lines, summary, } return JSON.stringify(snapshot) } export function parseProductEntrySnapshot( stored: string | number | null | undefined, ): ProductEntrySnapshotV1 | null { if (stored === null || stored === undefined) { return null } const s = typeof stored === 'string' ? stored.trim() : String(stored).trim() if (s === '' || s[0] !== '{') { return null } try { const o = JSON.parse(s) as Record if (o.v !== PRODUCT_ENTRY_SNAPSHOT_VERSION || !Array.isArray(o.lines)) { return null } const allowed: ProductFieldType[] = ['single', 'checkbox', 'radio', 'select', 'user_defined'] const pt = o.productType as ProductFieldType const productType = allowed.includes(pt) ? pt : 'radio' const lines = (o.lines as Record[]).map((line) => { const label = typeof line.label === 'string' ? line.label : '' const priceAmountRaw = line.priceAmount const priceAmount = typeof priceAmountRaw === 'string' && priceAmountRaw.trim() !== '' ? priceAmountRaw.trim() : undefined const price = typeof line.price === 'string' && line.price.trim() !== '' ? line.price.trim() : undefined const out: ProductEntrySnapshotLine = { label } if (priceAmount) { out.priceAmount = priceAmount } if (price) { out.price = price } return out }) return { v: PRODUCT_ENTRY_SNAPSHOT_VERSION, productType, raw: typeof o.raw === 'string' ? o.raw : '', lines, summary: typeof o.summary === 'string' ? o.summary : linesToSummary(lines, DEFAULT_CURRENCY_CODE), } } catch { return null } } /** Plain text for exports (snapshot JSON only). Uses same currency rules as admin entry UI when provided. */ export function getProductEntryDisplayPlainText( stored: string | number | null | undefined, currencyCode: CurrencyCode = DEFAULT_CURRENCY_CODE, ): string { const snap = parseProductEntrySnapshot(stored) if (snap) { return linesToSummary(snap.lines, currencyCode) } return '' }