/ lib / utils.ts
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, '&amp;')
255      .replace(/</g, '&lt;')
256      .replace(/>/g, '&gt;')
257      .replace(/"/g, '&quot;')
258      .replace(/'/g, '&#39;');
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  }