feat: Implement Phase 1 & 2 - Full monorepo architecture
## Backend API (apps/api) - Express.js server with TypeScript - JWT authentication with access/refresh tokens - Multi-tenant middleware (schema per tenant) - Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts - SAT integration: CFDI 4.0 XML parser, FIEL authentication - Metrics engine: 50+ financial metrics (Core, Startup, Enterprise) - Rate limiting, CORS, Helmet security ## Frontend Web (apps/web) - Next.js 14 with App Router - Authentication pages: login, register, forgot-password - Dashboard layout with Sidebar and Header - Dashboard pages: overview, cash-flow, revenue, expenses, metrics - Zustand stores for auth and UI state - Theme support with flash prevention ## Database Package (packages/database) - PostgreSQL migrations with multi-tenant architecture - Public schema: plans, tenants, users, sessions, subscriptions - Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts - Tenant management functions - Seed data for plans and super admin ## Shared Package (packages/shared) - TypeScript types: auth, tenant, financial, metrics, reports - Zod validation schemas for all entities - Utility functions for formatting ## UI Package (packages/ui) - Chart components: LineChart, BarChart, AreaChart, PieChart - Data components: DataTable, MetricCard, KPICard, AlertBadge - PeriodSelector and Skeleton components ## Infrastructure - Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog - Makefile with 25+ development commands - Development scripts: dev-setup.sh, dev-down.sh - Complete .env.example template Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
658
packages/shared/src/utils/format.ts
Normal file
658
packages/shared/src/utils/format.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* Formatting Utilities for Horux Strategy
|
||||
* Currency, percentage, date, and other formatting functions
|
||||
*/
|
||||
|
||||
import { DEFAULT_LOCALE, DEFAULT_TIMEZONE, CURRENCIES, DEFAULT_CURRENCY } from '../constants';
|
||||
|
||||
// ============================================================================
|
||||
// Currency Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface CurrencyFormatOptions {
|
||||
currency?: string;
|
||||
locale?: string;
|
||||
showSymbol?: boolean;
|
||||
showCode?: boolean;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as currency (default: Mexican Pesos)
|
||||
*
|
||||
* @example
|
||||
* formatCurrency(1234.56) // "$1,234.56"
|
||||
* formatCurrency(1234.56, { currency: 'USD' }) // "US$1,234.56"
|
||||
* formatCurrency(-1234.56) // "-$1,234.56"
|
||||
* formatCurrency(1234.56, { showCode: true }) // "$1,234.56 MXN"
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
options: CurrencyFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
currency = DEFAULT_CURRENCY,
|
||||
locale = DEFAULT_LOCALE,
|
||||
showSymbol = true,
|
||||
showCode = false,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
signDisplay = 'auto',
|
||||
} = options;
|
||||
|
||||
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||
const decimals = minimumFractionDigits ?? maximumFractionDigits ?? currencyInfo.decimals;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: showSymbol ? 'currency' : 'decimal',
|
||||
currency: showSymbol ? currency : undefined,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: maximumFractionDigits ?? decimals,
|
||||
signDisplay,
|
||||
});
|
||||
|
||||
let formatted = formatter.format(amount);
|
||||
|
||||
// Add currency code if requested
|
||||
if (showCode) {
|
||||
formatted = `${formatted} ${currency}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
} catch {
|
||||
// Fallback formatting
|
||||
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
const absAmount = Math.abs(amount).toFixed(decimals);
|
||||
const [intPart, decPart] = absAmount.split('.');
|
||||
const formattedInt = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
let result = `${sign}${symbol}${formattedInt}`;
|
||||
if (decPart) {
|
||||
result += `.${decPart}`;
|
||||
}
|
||||
if (showCode) {
|
||||
result += ` ${currency}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency for display in compact form
|
||||
*
|
||||
* @example
|
||||
* formatCurrencyCompact(1234) // "$1.2K"
|
||||
* formatCurrencyCompact(1234567) // "$1.2M"
|
||||
*/
|
||||
export function formatCurrencyCompact(
|
||||
amount: number,
|
||||
options: Omit<CurrencyFormatOptions, 'minimumFractionDigits' | 'maximumFractionDigits'> = {}
|
||||
): string {
|
||||
const {
|
||||
currency = DEFAULT_CURRENCY,
|
||||
locale = DEFAULT_LOCALE,
|
||||
showSymbol = true,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: showSymbol ? 'currency' : 'decimal',
|
||||
currency: showSymbol ? currency : undefined,
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
return formatter.format(amount);
|
||||
} catch {
|
||||
// Fallback
|
||||
const currencyInfo = CURRENCIES[currency] || CURRENCIES[DEFAULT_CURRENCY];
|
||||
const symbol = showSymbol ? currencyInfo.symbol : '';
|
||||
const absAmount = Math.abs(amount);
|
||||
const sign = amount < 0 ? '-' : '';
|
||||
|
||||
if (absAmount >= 1000000000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000000000).toFixed(1)}B`;
|
||||
}
|
||||
if (absAmount >= 1000000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (absAmount >= 1000) {
|
||||
return `${sign}${symbol}${(absAmount / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return `${sign}${symbol}${absAmount.toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a currency string to number
|
||||
*
|
||||
* @example
|
||||
* parseCurrency("$1,234.56") // 1234.56
|
||||
* parseCurrency("-$1,234.56") // -1234.56
|
||||
*/
|
||||
export function parseCurrency(value: string): number {
|
||||
// Remove currency symbols, spaces, and thousand separators
|
||||
const cleaned = value
|
||||
.replace(/[^0-9.,-]/g, '')
|
||||
.replace(/,/g, '');
|
||||
|
||||
const number = parseFloat(cleaned);
|
||||
return isNaN(number) ? 0 : number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Percentage Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface PercentFormatOptions {
|
||||
locale?: string;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
multiply?: boolean; // If true, multiply by 100 (e.g., 0.16 -> 16%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as percentage
|
||||
*
|
||||
* @example
|
||||
* formatPercent(16.5) // "16.5%"
|
||||
* formatPercent(0.165, { multiply: true }) // "16.5%"
|
||||
* formatPercent(-5.2, { signDisplay: 'always' }) // "-5.2%"
|
||||
*/
|
||||
export function formatPercent(
|
||||
value: number,
|
||||
options: PercentFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
minimumFractionDigits = 0,
|
||||
maximumFractionDigits = 2,
|
||||
signDisplay = 'auto',
|
||||
multiply = false,
|
||||
} = options;
|
||||
|
||||
const displayValue = multiply ? value : value / 100;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
signDisplay,
|
||||
});
|
||||
|
||||
return formatter.format(displayValue);
|
||||
} catch {
|
||||
// Fallback
|
||||
const sign = signDisplay === 'always' && value > 0 ? '+' : '';
|
||||
const actualValue = multiply ? value * 100 : value;
|
||||
return `${sign}${actualValue.toFixed(maximumFractionDigits)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a percentage change with color indicator
|
||||
* Returns an object with formatted value and direction
|
||||
*/
|
||||
export function formatPercentChange(
|
||||
value: number,
|
||||
options: PercentFormatOptions = {}
|
||||
): {
|
||||
formatted: string;
|
||||
direction: 'up' | 'down' | 'unchanged';
|
||||
isPositive: boolean;
|
||||
} {
|
||||
const formatted = formatPercent(value, { ...options, signDisplay: 'exceptZero' });
|
||||
const direction = value > 0 ? 'up' : value < 0 ? 'down' : 'unchanged';
|
||||
|
||||
return {
|
||||
formatted,
|
||||
direction,
|
||||
isPositive: value > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Date Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface DateFormatOptions {
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
format?: 'short' | 'medium' | 'long' | 'full' | 'relative' | 'iso';
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date
|
||||
*
|
||||
* @example
|
||||
* formatDate(new Date()) // "31/01/2024"
|
||||
* formatDate(new Date(), { format: 'long' }) // "31 de enero de 2024"
|
||||
* formatDate(new Date(), { includeTime: true }) // "31/01/2024 14:30"
|
||||
*/
|
||||
export function formatDate(
|
||||
date: Date | string | number,
|
||||
options: DateFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
timezone = DEFAULT_TIMEZONE,
|
||||
format = 'short',
|
||||
includeTime = false,
|
||||
} = options;
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Fecha inválida';
|
||||
}
|
||||
|
||||
// ISO format
|
||||
if (format === 'iso') {
|
||||
return dateObj.toISOString();
|
||||
}
|
||||
|
||||
// Relative format
|
||||
if (format === 'relative') {
|
||||
return formatRelativeTime(dateObj, locale);
|
||||
}
|
||||
|
||||
try {
|
||||
const dateStyle = format === 'short' ? 'short'
|
||||
: format === 'medium' ? 'medium'
|
||||
: format === 'long' ? 'long'
|
||||
: 'full';
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle,
|
||||
timeStyle: includeTime ? 'short' : undefined,
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
return formatter.format(dateObj);
|
||||
} catch {
|
||||
// Fallback
|
||||
const day = dateObj.getDate().toString().padStart(2, '0');
|
||||
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = dateObj.getFullYear();
|
||||
let result = `${day}/${month}/${year}`;
|
||||
|
||||
if (includeTime) {
|
||||
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||
result += ` ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "hace 2 días")
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: Date | string | number,
|
||||
locale: string = DEFAULT_LOCALE
|
||||
): string {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - dateObj.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffWeeks = Math.floor(diffDays / 7);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
||||
|
||||
if (Math.abs(diffSeconds) < 60) {
|
||||
return rtf.format(-diffSeconds, 'second');
|
||||
}
|
||||
if (Math.abs(diffMinutes) < 60) {
|
||||
return rtf.format(-diffMinutes, 'minute');
|
||||
}
|
||||
if (Math.abs(diffHours) < 24) {
|
||||
return rtf.format(-diffHours, 'hour');
|
||||
}
|
||||
if (Math.abs(diffDays) < 7) {
|
||||
return rtf.format(-diffDays, 'day');
|
||||
}
|
||||
if (Math.abs(diffWeeks) < 4) {
|
||||
return rtf.format(-diffWeeks, 'week');
|
||||
}
|
||||
if (Math.abs(diffMonths) < 12) {
|
||||
return rtf.format(-diffMonths, 'month');
|
||||
}
|
||||
return rtf.format(-diffYears, 'year');
|
||||
} catch {
|
||||
// Fallback for environments without Intl.RelativeTimeFormat
|
||||
if (diffSeconds < 60) return 'hace un momento';
|
||||
if (diffMinutes < 60) return `hace ${diffMinutes} minuto${diffMinutes !== 1 ? 's' : ''}`;
|
||||
if (diffHours < 24) return `hace ${diffHours} hora${diffHours !== 1 ? 's' : ''}`;
|
||||
if (diffDays < 7) return `hace ${diffDays} día${diffDays !== 1 ? 's' : ''}`;
|
||||
if (diffWeeks < 4) return `hace ${diffWeeks} semana${diffWeeks !== 1 ? 's' : ''}`;
|
||||
if (diffMonths < 12) return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`;
|
||||
return `hace ${diffYears} año${diffYears !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date range
|
||||
*
|
||||
* @example
|
||||
* formatDateRange(start, end) // "1 - 31 de enero de 2024"
|
||||
*/
|
||||
export function formatDateRange(
|
||||
start: Date | string | number,
|
||||
end: Date | string | number,
|
||||
options: Omit<DateFormatOptions, 'includeTime'> = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE } = options;
|
||||
|
||||
const startDate = start instanceof Date ? start : new Date(start);
|
||||
const endDate = end instanceof Date ? end : new Date(end);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'long',
|
||||
timeZone: timezone,
|
||||
});
|
||||
|
||||
return formatter.formatRange(startDate, endDate);
|
||||
} catch {
|
||||
// Fallback
|
||||
return `${formatDate(startDate, options)} - ${formatDate(endDate, options)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time only
|
||||
*/
|
||||
export function formatTime(
|
||||
date: Date | string | number,
|
||||
options: { locale?: string; timezone?: string; style?: 'short' | 'medium' } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, timezone = DEFAULT_TIMEZONE, style = 'short' } = options;
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
timeStyle: style,
|
||||
timeZone: timezone,
|
||||
});
|
||||
return formatter.format(dateObj);
|
||||
} catch {
|
||||
const hours = dateObj.getHours().toString().padStart(2, '0');
|
||||
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Number Formatting
|
||||
// ============================================================================
|
||||
|
||||
export interface NumberFormatOptions {
|
||||
locale?: string;
|
||||
minimumFractionDigits?: number;
|
||||
maximumFractionDigits?: number;
|
||||
notation?: 'standard' | 'scientific' | 'engineering' | 'compact';
|
||||
signDisplay?: 'auto' | 'always' | 'exceptZero' | 'never';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with thousand separators
|
||||
*
|
||||
* @example
|
||||
* formatNumber(1234567.89) // "1,234,567.89"
|
||||
* formatNumber(1234567, { notation: 'compact' }) // "1.2M"
|
||||
*/
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
options: NumberFormatOptions = {}
|
||||
): string {
|
||||
const {
|
||||
locale = DEFAULT_LOCALE,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
notation = 'standard',
|
||||
signDisplay = 'auto',
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
notation,
|
||||
signDisplay,
|
||||
});
|
||||
return formatter.format(value);
|
||||
} catch {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number in compact notation
|
||||
*
|
||||
* @example
|
||||
* formatCompactNumber(1234) // "1.2K"
|
||||
* formatCompactNumber(1234567) // "1.2M"
|
||||
*/
|
||||
export function formatCompactNumber(
|
||||
value: number,
|
||||
options: Omit<NumberFormatOptions, 'notation'> = {}
|
||||
): string {
|
||||
return formatNumber(value, { ...options, notation: 'compact' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*
|
||||
* @example
|
||||
* formatBytes(1024) // "1 KB"
|
||||
* formatBytes(1536) // "1.5 KB"
|
||||
* formatBytes(1048576) // "1 MB"
|
||||
*/
|
||||
export function formatBytes(
|
||||
bytes: number,
|
||||
options: { locale?: string; decimals?: number } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, decimals = 1 } = options;
|
||||
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = bytes / Math.pow(k, i);
|
||||
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
});
|
||||
return `${formatter.format(value)} ${sizes[i]}`;
|
||||
} catch {
|
||||
return `${value.toFixed(decimals)} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Formatting
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*
|
||||
* @example
|
||||
* truncate("Hello World", 8) // "Hello..."
|
||||
*/
|
||||
export function truncate(text: string, maxLength: number, suffix = '...'): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - suffix.length).trim() + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize first letter
|
||||
*
|
||||
* @example
|
||||
* capitalize("hello world") // "Hello world"
|
||||
*/
|
||||
export function capitalize(text: string): string {
|
||||
if (!text) return '';
|
||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Title case
|
||||
*
|
||||
* @example
|
||||
* titleCase("hello world") // "Hello World"
|
||||
*/
|
||||
export function titleCase(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RFC for display (with spaces)
|
||||
*
|
||||
* @example
|
||||
* formatRFC("XAXX010101000") // "XAXX 010101 000"
|
||||
*/
|
||||
export function formatRFC(rfc: string): string {
|
||||
const cleaned = rfc.replace(/\s/g, '').toUpperCase();
|
||||
if (cleaned.length === 12) {
|
||||
// Persona física
|
||||
return `${cleaned.slice(0, 4)} ${cleaned.slice(4, 10)} ${cleaned.slice(10)}`;
|
||||
}
|
||||
if (cleaned.length === 13) {
|
||||
// Persona moral
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 9)} ${cleaned.slice(9)}`;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask sensitive data
|
||||
*
|
||||
* @example
|
||||
* maskString("1234567890", 4) // "******7890"
|
||||
* maskString("email@example.com", 3, { maskChar: '*', type: 'email' }) // "ema***@example.com"
|
||||
*/
|
||||
export function maskString(
|
||||
value: string,
|
||||
visibleChars: number = 4,
|
||||
options: { maskChar?: string; position?: 'start' | 'end' } = {}
|
||||
): string {
|
||||
const { maskChar = '*', position = 'end' } = options;
|
||||
|
||||
if (value.length <= visibleChars) return value;
|
||||
|
||||
const maskLength = value.length - visibleChars;
|
||||
const mask = maskChar.repeat(maskLength);
|
||||
|
||||
if (position === 'start') {
|
||||
return value.slice(0, visibleChars) + mask;
|
||||
}
|
||||
return mask + value.slice(-visibleChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CLABE for display
|
||||
*
|
||||
* @example
|
||||
* formatCLABE("123456789012345678") // "123 456 789012345678"
|
||||
*/
|
||||
export function formatCLABE(clabe: string): string {
|
||||
const cleaned = clabe.replace(/\s/g, '');
|
||||
if (cleaned.length !== 18) return clabe;
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)} ${cleaned.slice(6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format phone number (Mexican format)
|
||||
*
|
||||
* @example
|
||||
* formatPhone("5512345678") // "(55) 1234-5678"
|
||||
*/
|
||||
export function formatPhone(phone: string): string {
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
if (cleaned.length === 12 && cleaned.startsWith('52')) {
|
||||
const national = cleaned.slice(2);
|
||||
return `+52 (${national.slice(0, 2)}) ${national.slice(2, 6)}-${national.slice(6)}`;
|
||||
}
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pluralization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Simple Spanish pluralization
|
||||
*
|
||||
* @example
|
||||
* pluralize(1, 'factura', 'facturas') // "1 factura"
|
||||
* pluralize(5, 'factura', 'facturas') // "5 facturas"
|
||||
*/
|
||||
export function pluralize(
|
||||
count: number,
|
||||
singular: string,
|
||||
plural: string
|
||||
): string {
|
||||
const word = count === 1 ? singular : plural;
|
||||
return `${formatNumber(count)} ${word}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list of items with proper grammar
|
||||
*
|
||||
* @example
|
||||
* formatList(['a', 'b', 'c']) // "a, b y c"
|
||||
* formatList(['a', 'b']) // "a y b"
|
||||
* formatList(['a']) // "a"
|
||||
*/
|
||||
export function formatList(
|
||||
items: string[],
|
||||
options: { locale?: string; type?: 'conjunction' | 'disjunction' } = {}
|
||||
): string {
|
||||
const { locale = DEFAULT_LOCALE, type = 'conjunction' } = options;
|
||||
|
||||
if (items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
|
||||
try {
|
||||
const formatter = new Intl.ListFormat(locale, {
|
||||
style: 'long',
|
||||
type,
|
||||
});
|
||||
return formatter.format(items);
|
||||
} catch {
|
||||
// Fallback
|
||||
const connector = type === 'conjunction' ? 'y' : 'o';
|
||||
if (items.length === 2) {
|
||||
return `${items[0]} ${connector} ${items[1]}`;
|
||||
}
|
||||
return `${items.slice(0, -1).join(', ')} ${connector} ${items[items.length - 1]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user