utils.ts
1 import { type ClassValue, clsx } from 'clsx'; 2 import { twMerge } from 'tailwind-merge'; 3 4 /** 5 * Merge Tailwind classes with clsx — standard pattern for shadcn/ui projects 6 */ 7 export function cn(...inputs: ClassValue[]) { 8 return twMerge(clsx(inputs)); 9 } 10 11 /** 12 * Format currency values with locale awareness 13 */ 14 export function formatCurrency( 15 amount: number, 16 currency: string = 'EUR', 17 locale: string = 'en-US' 18 ): string { 19 return new Intl.NumberFormat(locale, { 20 style: 'currency', 21 currency, 22 maximumFractionDigits: 0, 23 }).format(amount); 24 } 25 26 /** 27 * Format a salary range string 28 */ 29 export function formatSalaryRange( 30 low: number, 31 high: number, 32 currency: string = 'EUR' 33 ): string { 34 return `${formatCurrency(low, currency)} – ${formatCurrency(high, currency)}`; 35 } 36 37 /** 38 * Get color class for gap severity 39 */ 40 export function getSeverityColor(severity: 'critical' | 'moderate' | 'minor'): string { 41 const colors = { 42 critical: 'text-danger', 43 moderate: 'text-warning', 44 minor: 'text-success', 45 }; 46 return colors[severity]; 47 } 48 49 /** 50 * Get background color class for gap severity 51 */ 52 export function getSeverityBg(severity: 'critical' | 'moderate' | 'minor'): string { 53 const colors = { 54 critical: 'bg-danger/10 border-danger/20', 55 moderate: 'bg-warning/10 border-warning/20', 56 minor: 'bg-success/10 border-success/20', 57 }; 58 return colors[severity]; 59 } 60 61 /** 62 * Get color for fit score gauge 63 */ 64 export function getFitScoreColor(score: number): string { 65 if (score >= 8) return '#10B981'; // emerald — strong fit 66 if (score >= 6) return '#FBBF24'; // amber — moderate fit 67 if (score >= 4) return '#FB923C'; // orange — stretch 68 return '#EF4444'; // red — significant gap 69 } 70 71 /** 72 * Get label for fit score 73 */ 74 export function getFitScoreLabel(score: number): string { 75 if (score >= 8) return 'Strong Fit'; 76 if (score >= 6) return 'Moderate Fit'; 77 if (score >= 4) return 'Stretch'; 78 return 'Significant Gap'; 79 } 80 81 /** 82 * Get tier color for strengths 83 */ 84 export function getTierColor(tier: 'differentiator' | 'strong' | 'supporting'): string { 85 const colors = { 86 differentiator: 'text-primary', 87 strong: 'text-success', 88 supporting: 'text-text-secondary', 89 }; 90 return colors[tier]; 91 } 92 93 /** 94 * Get tier badge style 95 */ 96 export function getTierBg(tier: 'differentiator' | 'strong' | 'supporting'): string { 97 const colors = { 98 differentiator: 'bg-primary/10 text-primary border-primary/20', 99 strong: 'bg-success/10 text-success border-success/20', 100 supporting: 'bg-zinc-800/50 text-text-secondary border-card-border', 101 }; 102 return colors[tier]; 103 } 104 105 /** 106 * Get priority color for action items 107 */ 108 export function getPriorityColor(priority: 'critical' | 'high' | 'medium'): string { 109 const colors = { 110 critical: 'text-danger', 111 high: 'text-warning', 112 medium: 'text-primary-light', 113 }; 114 return colors[priority]; 115 } 116 117 /** 118 * Truncate text to a max length with ellipsis 119 */ 120 export function truncate(text: string, maxLength: number): string { 121 if (text.length <= maxLength) return text; 122 return text.slice(0, maxLength - 3) + '...'; 123 } 124 125 /** 126 * Format file size in human readable format 127 */ 128 export function formatFileSize(bytes: number): string { 129 if (bytes === 0) return '0 Bytes'; 130 const k = 1024; 131 const sizes = ['Bytes', 'KB', 'MB', 'GB']; 132 const i = Math.floor(Math.log(bytes) / Math.log(k)); 133 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 134 } 135 136 /** 137 * Validate PDF file 138 */ 139 export function validatePDFFile(file: File): { valid: boolean; error?: string } { 140 if (!file) { 141 return { valid: false, error: 'No file provided' }; 142 } 143 144 if (file.type !== 'application/pdf') { 145 return { valid: false, error: 'File must be a PDF document' }; 146 } 147 148 const maxSize = 5 * 1024 * 1024; // 5MB 149 if (file.size > maxSize) { 150 return { valid: false, error: 'File size must be less than 5MB' }; 151 } 152 153 return { valid: true }; 154 } 155 156 /** 157 * Parse JSON safely with a fallback 158 */ 159 export function safeParseJSON<T>(text: string, fallback: T): T { 160 try { 161 // Strip markdown code fences if present 162 const cleaned = text 163 .replace(/```json\s*/g, '') 164 .replace(/```\s*/g, '') 165 .trim(); 166 return JSON.parse(cleaned) as T; 167 } catch { 168 console.error('Failed to parse JSON:', text.slice(0, 200)); 169 return fallback; 170 } 171 } 172 173 /** 174 * Country list for the questionnaire dropdown 175 */ 176 export const COUNTRIES = [ 177 'Romania', 178 'Germany', 179 'United Kingdom', 180 'United States', 181 'Netherlands', 182 'France', 183 'Spain', 184 'Italy', 185 'Poland', 186 'Austria', 187 'Switzerland', 188 'Sweden', 189 'Denmark', 190 'Norway', 191 'Finland', 192 'Belgium', 193 'Ireland', 194 'Portugal', 195 'Czech Republic', 196 'Hungary', 197 'Canada', 198 'Australia', 199 'India', 200 'Singapore', 201 'Japan', 202 'Brazil', 203 'Other', 204 ] as const; 205 206 /** 207 * Map countries to their primary currency 208 */ 209 export const COUNTRY_CURRENCY: Record<string, { code: string; symbol: string }> = { 210 'Romania': { code: 'RON', symbol: 'RON' }, 211 'Germany': { code: 'EUR', symbol: 'EUR' }, 212 'United Kingdom': { code: 'GBP', symbol: 'GBP' }, 213 'United States': { code: 'USD', symbol: 'USD' }, 214 'Netherlands': { code: 'EUR', symbol: 'EUR' }, 215 'France': { code: 'EUR', symbol: 'EUR' }, 216 'Spain': { code: 'EUR', symbol: 'EUR' }, 217 'Italy': { code: 'EUR', symbol: 'EUR' }, 218 'Poland': { code: 'PLN', symbol: 'PLN' }, 219 'Austria': { code: 'EUR', symbol: 'EUR' }, 220 'Switzerland': { code: 'CHF', symbol: 'CHF' }, 221 'Sweden': { code: 'SEK', symbol: 'SEK' }, 222 'Denmark': { code: 'DKK', symbol: 'DKK' }, 223 'Norway': { code: 'NOK', symbol: 'NOK' }, 224 'Finland': { code: 'EUR', symbol: 'EUR' }, 225 'Belgium': { code: 'EUR', symbol: 'EUR' }, 226 'Ireland': { code: 'EUR', symbol: 'EUR' }, 227 'Portugal': { code: 'EUR', symbol: 'EUR' }, 228 'Czech Republic': { code: 'CZK', symbol: 'CZK' }, 229 'Hungary': { code: 'HUF', symbol: 'HUF' }, 230 'Canada': { code: 'CAD', symbol: 'CAD' }, 231 'Australia': { code: 'AUD', symbol: 'AUD' }, 232 'India': { code: 'INR', symbol: 'INR' }, 233 'Singapore': { code: 'SGD', symbol: 'SGD' }, 234 'Japan': { code: 'JPY', symbol: 'JPY' }, 235 'Brazil': { code: 'BRL', symbol: 'BRL' }, 236 'Other': { code: 'EUR', symbol: 'EUR' }, 237 }; 238 239 /** 240 * Work preference options 241 */ 242 export const WORK_PREFERENCES = [ 243 { value: 'remote', label: 'Remote' }, 244 { value: 'hybrid', label: 'Hybrid' }, 245 { value: 'onsite', label: 'On-site' }, 246 { value: 'flexible', label: 'Flexible' }, 247 ] as const; 248 249 /** 250 * Escape HTML special characters to prevent XSS in HTML string contexts. 251 */ 252 export function escapeHtml(text: string): string { 253 return text 254 .replace(/&/g, '&') 255 .replace(/</g, '<') 256 .replace(/>/g, '>') 257 .replace(/"/g, '"') 258 .replace(/'/g, '''); 259 } 260 261 /** 262 * Sanitize text from API responses — replaces Unicode special characters 263 * with ASCII equivalents to prevent display issues. 264 */ 265 export function sanitizeText(text: string): string { 266 return text 267 .replace(/\u2014/g, ' - ') 268 .replace(/\u2013/g, ' - ') 269 .replace(/[\u2018\u2019\u201A]/g, "'") 270 .replace(/[\u201C\u201D\u201E]/g, '"') 271 .replace(/\u2026/g, '...') 272 .replace(/\u2192/g, '->') 273 .replace(/\u2022/g, '-'); 274 } 275 276 /** 277 * Recursively sanitize all string values in an object. 278 */ 279 export function sanitizeResult<T>(obj: T): T { 280 if (typeof obj === 'string') return sanitizeText(obj) as T; 281 if (Array.isArray(obj)) return obj.map(sanitizeResult) as T; 282 if (obj && typeof obj === 'object') { 283 const result: Record<string, unknown> = {}; 284 for (const [key, value] of Object.entries(obj)) { 285 result[key] = sanitizeResult(value); 286 } 287 return result as T; 288 } 289 return obj; 290 }