diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 04c7d89..e7b1c2c 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as applications from "../applications.js"; import type * as resumes from "../resumes.js"; +import type * as shared from "../shared.js"; import type * as users from "../users.js"; import type { @@ -21,6 +22,7 @@ import type { declare const fullApi: ApiFromModules<{ applications: typeof applications; resumes: typeof resumes; + shared: typeof shared; users: typeof users; }>; diff --git a/convex/applications.ts b/convex/applications.ts index 623f279..c9a8ceb 100644 --- a/convex/applications.ts +++ b/convex/applications.ts @@ -1,5 +1,6 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; +import { CURRENCIES, currencyValidator } from "./shared"; const STAGES = [ "applied", @@ -11,6 +12,7 @@ const STAGES = [ "ghosted", ] as const; type Stage = (typeof STAGES)[number]; +type Currency = (typeof CURRENCIES)[number]; export const listApplications = query({ args: {}, @@ -40,6 +42,7 @@ export const createApplication = mutation({ company: v.string(), jobTitle: v.string(), salary: v.optional(v.number()), + currency: v.optional(currencyValidator), stage: v.string(), // validate against STAGES at runtime date: v.string(), notes: v.string(), @@ -90,6 +93,8 @@ export const createApplication = mutation({ jobTitle: args.jobTitle, // Only include salary if provided ...(args.salary !== undefined ? { salary: args.salary } : {}), + // Only include currency if provided (client-side defaults to DEFAULT_CURRENCY) + ...(args.currency ? { currency: args.currency } : {}), stage: args.stage, date: args.date, notes: args.notes, @@ -106,6 +111,7 @@ export const updateApplication = mutation({ company: v.optional(v.string()), jobTitle: v.optional(v.string()), salary: v.optional(v.number()), + currency: v.optional(currencyValidator), clearSalary: v.optional(v.boolean()), stage: v.optional(v.string()), date: v.optional(v.string()), @@ -134,6 +140,7 @@ export const updateApplication = mutation({ company?: string; jobTitle?: string; salary?: number | undefined; + currency?: Currency; stage?: string; date?: string; notes?: string; @@ -142,6 +149,7 @@ export const updateApplication = mutation({ if (args.company !== undefined) patch.company = args.company; if (args.jobTitle !== undefined) patch.jobTitle = args.jobTitle; if (args.salary !== undefined) patch.salary = args.salary; + if (args.currency !== undefined) patch.currency = args.currency; if (args.stage !== undefined) patch.stage = args.stage; if (args.date !== undefined) patch.date = args.date; if (args.notes !== undefined) patch.notes = args.notes; diff --git a/convex/schema.ts b/convex/schema.ts index ebacb86..f6c6915 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,5 +1,6 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { currencyValidator } from "./shared"; export default defineSchema({ // Users (synced with Clerk) @@ -16,6 +17,7 @@ export default defineSchema({ company: v.string(), // Company name jobTitle: v.string(), // Job title salary: v.optional(v.number()), // Salary (optional) + currency: v.optional(currencyValidator), // Currency code: USD, EUR, GBP stage: v.string(), // applied | cv_rejected | hr_call | interview | offer | rejected | ghosted date: v.string(), // Date of the stage notes: v.string(), // Optional notes diff --git a/convex/shared.ts b/convex/shared.ts new file mode 100644 index 0000000..e90193e --- /dev/null +++ b/convex/shared.ts @@ -0,0 +1,12 @@ +/** + * Shared constants and validators used by both Convex backend and frontend. + * This file should NOT import any Convex server modules (query, mutation, etc.) + * to allow safe browser imports. + */ +import { v } from "convex/values"; + +export const CURRENCIES = ["USD", "EUR", "GBP"] as const; +export type Currency = (typeof CURRENCIES)[number]; + +// Validator for currency field - use in schema and mutations +export const currencyValidator = v.union(v.literal("USD"), v.literal("EUR"), v.literal("GBP")); diff --git a/src/app/applications/applications-client.tsx b/src/app/applications/applications-client.tsx index 1fd22ed..0625a17 100644 --- a/src/app/applications/applications-client.tsx +++ b/src/app/applications/applications-client.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useQuery, useMutation } from "convex/react"; import { api } from "../../../convex/_generated/api"; +import { CURRENCIES } from "../../../convex/shared"; import { Button } from "@/components/ui/button"; import { Pencil, Trash2, ChevronDown, ChevronUp, Star } from "lucide-react"; import { Card } from "@/components/ui/card"; @@ -34,6 +35,130 @@ const STAGES = [ "ghosted", ] as const; type Stage = (typeof STAGES)[number]; + +type Currency = (typeof CURRENCIES)[number]; + +// Default currency used as fallback when detection fails or currency is not set +const DEFAULT_CURRENCY: Currency = "USD"; + +const CURRENCY_SYMBOLS: Record = { + USD: "$", + EUR: "€", + GBP: "£", +}; + +// Eurozone IANA timezones for timezone-based currency detection (Set for O(1) lookup) +const EUROZONE_TIMEZONES = new Set([ + "Europe/Vienna", // Austria + "Europe/Brussels", // Belgium + "Europe/Nicosia", // Cyprus + "Europe/Tallinn", // Estonia + "Europe/Helsinki", // Finland + "Europe/Paris", // France + "Europe/Berlin", // Germany + "Europe/Athens", // Greece + "Europe/Dublin", // Ireland + "Europe/Rome", // Italy + "Europe/Riga", // Latvia + "Europe/Vilnius", // Lithuania + "Europe/Luxembourg", // Luxembourg + "Europe/Malta", // Malta + "Europe/Amsterdam", // Netherlands + "Europe/Lisbon", // Portugal + "Europe/Bratislava", // Slovakia + "Europe/Ljubljana", // Slovenia + "Europe/Madrid", // Spain + "Europe/Zagreb", // Croatia + "Atlantic/Canary", // Spain (Canary Islands) +]); + +// ISO 3166-1 alpha-2 country codes for Eurozone members (Set for O(1) lookup) +const EUROZONE_REGIONS = new Set([ + "AT", + "BE", + "CY", + "EE", + "FI", + "FR", + "DE", + "GR", + "IE", + "IT", + "LV", + "LT", + "LU", + "MT", + "NL", + "PT", + "SK", + "SI", + "ES", + "HR", +]); + +// ISO 639-1 language codes commonly used in Eurozone countries (Set for O(1) lookup). +// Used as fallback when locale has no region suffix (e.g., "de" instead of "de-AT"). +const EUROZONE_LANGUAGE_CODES = new Set([ + "de", // German + "fr", // French + "es", // Spanish + "it", // Italian + "nl", // Dutch + "pt", // Portuguese + "el", // Greek + "fi", // Finnish + "sk", // Slovak + "sl", // Slovenian + "et", // Estonian + "lv", // Latvian + "lt", // Lithuanian +]); + +// Detect default currency based on user's timezone (most reliable) or locale +function detectDefaultCurrency(): Currency { + if (typeof Intl === "undefined") return DEFAULT_CURRENCY; + + // Try timezone first (more reliable than locale for physical location) + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + + if (EUROZONE_TIMEZONES.has(tz)) return "EUR"; + + // GBP timezones + if (tz === "Europe/London" || tz === "Europe/Belfast") return "GBP"; + } catch { + // Ignore timezone detection errors + } + + // Fallback to locale-based detection + if (typeof navigator === "undefined") return DEFAULT_CURRENCY; + const locale = navigator.language || "en-US"; + + // Extract region code (e.g., "AT" from "de-AT" or "en-AT") + const regionMatch = locale.match(/-([A-Z]{2})$/i); + const region = regionMatch ? regionMatch[1].toUpperCase() : null; + + if (region && EUROZONE_REGIONS.has(region)) return "EUR"; + if (region === "GB" || region === "UK") return "GBP"; + + // Fallback: check language prefix for locales without region + const lang = locale.split("-")[0].toLowerCase(); + if (EUROZONE_LANGUAGE_CODES.has(lang)) { + return "EUR"; + } + + return DEFAULT_CURRENCY; +} + +// Memoize default currency - computed once on first access +let _defaultCurrency: Currency | null = null; +function getDefaultCurrency(): Currency { + if (_defaultCurrency === null) { + _defaultCurrency = detectDefaultCurrency(); + } + return _defaultCurrency; +} + const STAGE_META: Record = { applied: { label: "Applied", @@ -117,6 +242,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -144,6 +270,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -209,6 +336,7 @@ export function ApplicationsClient() { stage: form.stage, date: form.date, notes: form.notes, + currency: form.currency, }; const salaryPatch = parsedSalary !== undefined @@ -253,6 +381,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -272,6 +401,7 @@ export function ApplicationsClient() { company: a.company, jobTitle: a.jobTitle, salary: a.salary != null ? String(a.salary) : "", + currency: (a.currency as Currency) || DEFAULT_CURRENCY, stage: (a.stage as Stage) ?? "applied", date: a.date, notes: a.notes, @@ -308,6 +438,7 @@ export function ApplicationsClient() { company: "", jobTitle: "", salary: "", + currency: getDefaultCurrency(), stage: "applied", date: nowLocalYMD(), notes: "", @@ -340,19 +471,36 @@ export function ApplicationsClient() {
- { - const val = e.target.value; - setForm((s) => ({ ...s, salary: val })); - if (errors.salary) setErrors((prev) => ({ ...prev, salary: undefined })); - }} - placeholder="150000" - /> +
+ + { + const val = e.target.value; + setForm((s) => ({ ...s, salary: val })); + if (errors.salary) setErrors((prev) => ({ ...prev, salary: undefined })); + }} + placeholder="150000" + className="flex-1" + /> +
{errors.salary &&

{errors.salary}

}
@@ -508,7 +656,9 @@ export function ApplicationsClient() { {a.company} {a.jobTitle} - {a.salary != null ? `${Number(a.salary).toLocaleString()}` : "—"} + {a.salary != null + ? `${CURRENCY_SYMBOLS[(a.currency as Currency) || DEFAULT_CURRENCY]}${Number(a.salary).toLocaleString()}` + : "—"}