From 42d0696ea5d3a06fc8b720f402aedd8bb2633ed4 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 18:43:45 +0100 Subject: [PATCH 1/7] fix: use --name flag for wrangler deploy in PR preview workflow WRANGLER_CI_OVERRIDE_NAME is not a real wrangler env var. The correct way to override the worker name is via --name CLI flag. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr-preview.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index a51bda6..17a3ccc 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -59,11 +59,10 @@ jobs: run: make build-cloudflare - name: Deploy PR preview - run: npx wrangler deploy + run: npx wrangler deploy --name ${{ steps.pr.outputs.worker-name }} env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - WRANGLER_CI_OVERRIDE_NAME: ${{ steps.pr.outputs.worker-name }} - name: Enable workers.dev subdomain run: | From a754e4570f939213552f36873b5e5207328286cb Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 18:45:14 +0100 Subject: [PATCH 2/7] feat: add destination wizard with salary sync fixes (fixes #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DestinationWizard — a 3-step dialog for adding/editing destinations (country/year/variant/salary → tax options → living costs) - Show locked salary field with tooltip for synced followers in wizard - Add Edit (gear) button to CountryColumn header that opens the wizard - Fix salary sync leader/follower logic: only leader (index 0) propagates salary changes; followers sync from leader when their currency loads - Fix currency emission: always emit on first load per country:year:variant so synced EUR-currency columns (e.g. Germany) trigger conversion correctly - Change "Add Destination" to open wizard instead of inline empty column Co-Authored-By: Claude Opus 4.6 --- src/components/calculator/comparison-grid.tsx | 162 +++++-- src/components/calculator/country-column.tsx | 57 ++- .../calculator/destination-wizard.tsx | 400 ++++++++++++++++++ 3 files changed, 557 insertions(+), 62 deletions(-) create mode 100644 src/components/calculator/destination-wizard.tsx diff --git a/src/components/calculator/comparison-grid.tsx b/src/components/calculator/comparison-grid.tsx index ae8990e..20f3304 100644 --- a/src/components/calculator/comparison-grid.tsx +++ b/src/components/calculator/comparison-grid.tsx @@ -19,6 +19,7 @@ import { MobileCountrySelector } from "./mobile-country-selector" import { UnsupportedCurrencyError } from "@/lib/errors" import { calculateNetDelta, findBestCountryByNet } from "@/lib/comparison-utils" import { detectUserCountry } from "@/lib/detect-country" +import { DestinationWizard } from "./destination-wizard" const MAX_COUNTRIES = 4 @@ -71,6 +72,7 @@ export function ComparisonGrid() { const [saveDialogOpen, setSaveDialogOpen] = useState(false) const [activeTabIndex, setActiveTabIndex] = useState(0) const [salaryModeSynced, setSalaryModeSynced] = useState(true) + const [wizardTargetId, setWizardTargetId] = useState(null) // Initialize from URL on mount ONLY useEffect(() => { @@ -147,22 +149,23 @@ export function ComparisonGrid() { // Update a single country's state const updateCountry = useCallback((id: string, updates: Partial) => { - setCountries(prev => { - const next = prev.map(c => (c.id === id ? { ...c, ...updates } : c)) - return next - }) + setCountries(prev => prev.map(c => (c.id === id ? { ...c, ...updates } : c))) - // In synced mode, when a column's currency is set (country selected), convert its gross from a sibling - if (salaryModeSynced && "currency" in updates && updates.currency && !("gross_annual" in updates)) { + if (!salaryModeSynced) return + + // When a follower's currency loads: sync salary FROM the leader (index 0) + if ("currency" in updates && updates.currency && !("gross_annual" in updates)) { const targetCurrency = updates.currency setCountries(prev => { - const source = prev.find(c => c.id !== id && c.gross_annual) - if (!source) return prev - const amount = parseFloat(source.gross_annual) + const me = prev.find(c => c.id === id) + if (!me || me.index === 0) return prev // Leader doesn't sync from anyone + const leader = [...prev].sort((a, b) => a.index - b.index).find(c => c.index === 0) + if (!leader?.gross_annual) return prev + const amount = parseFloat(leader.gross_annual) if (isNaN(amount)) return prev - const sourceCurrency = source.currency || "EUR" + const sourceCurrency = leader.currency || "EUR" if (sourceCurrency === targetCurrency) { - return prev.map(c => c.id === id ? { ...c, gross_annual: source.gross_annual } : c) + return prev.map(c => (c.id === id ? { ...c, gross_annual: leader.gross_annual } : c)) } fetchExchangeRate(sourceCurrency, targetCurrency) .then(rate => { @@ -170,29 +173,30 @@ export function ComparisonGrid() { setCountries(cols => cols.map(col => col.id === id ? { ...col, gross_annual: converted } : col)) }) .catch(() => { - setCountries(cols => cols.map(col => col.id === id ? { ...col, gross_annual: source.gross_annual } : col)) + setCountries(cols => cols.map(col => col.id === id ? { ...col, gross_annual: leader.gross_annual } : col)) }) return prev }) } - // In synced mode, propagate gross_annual changes to all other columns with currency conversion - if (salaryModeSynced && "gross_annual" in updates) { - const newGross = updates.gross_annual - const amount = parseFloat(newGross ?? "") - if (isNaN(amount)) return - + // When the leader's salary changes: propagate to all followers + if ("gross_annual" in updates) { setCountries(prev => { - const source = prev.find(c => c.id === id) - if (!source) return prev - const sourceCurrency = source.currency || "EUR" + const leader = prev.find(c => c.id === id) + if (!leader || leader.index !== 0) return prev // Only leader propagates + const amount = parseFloat(updates.gross_annual ?? "") + if (isNaN(amount)) return prev + const sourceCurrency = leader.currency || "EUR" - // Kick off async conversion for each other column prev.forEach(c => { if (c.id === id) return const targetCurrency = c.currency || "EUR" if (targetCurrency === sourceCurrency) { - setCountries(cols => cols.map(col => col.id === c.id ? { ...col, gross_annual: newGross ?? col.gross_annual } : col)) + setCountries(cols => + cols.map(col => + col.id === c.id ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } : col + ) + ) } else { fetchExchangeRate(sourceCurrency, targetCurrency) .then(rate => { @@ -200,8 +204,11 @@ export function ComparisonGrid() { setCountries(cols => cols.map(col => col.id === c.id ? { ...col, gross_annual: converted } : col)) }) .catch(() => { - // Fallback: copy raw value if conversion unavailable - setCountries(cols => cols.map(col => col.id === c.id ? { ...col, gross_annual: newGross ?? col.gross_annual } : col)) + setCountries(cols => + cols.map(col => + col.id === c.id ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } : col + ) + ) }) } }) @@ -224,31 +231,74 @@ export function ComparisonGrid() { } }, []) - // Add new country - const addCountry = useCallback(() => { - if (countries.length >= MAX_COUNTRIES) return - - const newState = createEmptyCountryState(countries.length) - - // In synced mode, pre-fill gross from the first column that has a value - if (salaryModeSynced) { - const source = countries.find(c => c.gross_annual) - if (source) { - const amount = parseFloat(source.gross_annual) - if (!isNaN(amount)) { - // We don't know the new column's currency yet (no country selected), - // so store the source amount; it will be re-converted when the user picks a country. - newState.gross_annual = source.gross_annual - newState.currency = source.currency - } + // Wizard initial state — empty for new, existing state for edit + const wizardInitialState = useCallback((): CountryColumnState => { + if (!wizardTargetId || wizardTargetId === "__new__") { + return { + id: crypto.randomUUID(), + index: countries.length, + country: "", + year: "", + variant: "", + gross_annual: salaryModeSynced ? "" : "", + formValues: {}, + currency: "EUR", + result: null, + isCalculating: false, + calculationError: null, + costOfLiving: { rent: 0, healthcare: 0, food: 0, mobility: 0, travel: 0 }, } } - - setCountries(prev => [...prev, newState]) - if (isMobile) { - setActiveTabIndex(countries.length) + return countries.find(c => c.id === wizardTargetId) ?? { + id: crypto.randomUUID(), + index: countries.length, + country: "", + year: "", + variant: "", + gross_annual: "", + formValues: {}, + currency: "EUR", + result: null, + isCalculating: false, + calculationError: null, + costOfLiving: { rent: 0, healthcare: 0, food: 0, mobility: 0, travel: 0 }, } - }, [countries, isMobile, salaryModeSynced]) + }, [wizardTargetId, countries, salaryModeSynced]) + + const handleWizardSave = useCallback( + (saved: CountryColumnState) => { + if (wizardTargetId === "__new__") { + // New followers start with empty salary; CountryColumn will trigger + // updateCountry({ currency }) once inputsData loads, which converts from leader. + const gross_annual = salaryModeSynced ? "" : saved.gross_annual + + const newEntry: CountryColumnState = { + ...saved, + id: crypto.randomUUID(), + index: countries.length, + gross_annual, + result: null, + isCalculating: false, + calculationError: null, + } + setCountries(prev => [...prev, newEntry]) + if (isMobile) setActiveTabIndex(countries.length) + } else { + // Editing existing — preserve the id/index + setCountries(prev => + prev.map(c => (c.id === wizardTargetId ? { ...c, ...saved, id: c.id, index: c.index } : c)) + ) + } + setWizardTargetId(null) + }, + [wizardTargetId, countries, salaryModeSynced, isMobile] + ) + + // Add new country — opens wizard + const addCountry = useCallback(() => { + if (countries.length >= MAX_COUNTRIES) return + setWizardTargetId("__new__") + }, [countries.length]) // Remove country const removeCountry = useCallback( @@ -494,9 +544,12 @@ export function ComparisonGrid() { {...country} onUpdate={updates => updateCountry(country.id, updates)} onRemove={() => removeCountry(country.id)} + onEdit={() => setWizardTargetId(country.id)} showRemove={countries.length > 1} isBest={bestCountryId === country.id} comparisonDelta={getComparisonDelta(country.id)} + isLeader={country.index === 0} + salaryModeSynced={salaryModeSynced} /> ))} @@ -514,9 +567,12 @@ export function ComparisonGrid() { {...country} onUpdate={updates => updateCountry(country.id, updates)} onRemove={() => removeCountry(country.id)} + onEdit={() => setWizardTargetId(country.id)} showRemove={countries.length > 1} isBest={bestCountryId === country.id} comparisonDelta={getComparisonDelta(country.id)} + isLeader={country.index === 0} + salaryModeSynced={salaryModeSynced} /> ))} @@ -524,6 +580,18 @@ export function ComparisonGrid() { )} + {/* Destination Wizard */} + {wizardTargetId && ( + setWizardTargetId(null)} + initialState={wizardInitialState()} + onSave={handleWizardSave} + isLeader={wizardTargetId === "__new__" ? false : (countries.find(c => c.id === wizardTargetId)?.index === 0)} + salaryModeSynced={salaryModeSynced} + /> + )} + {/* Save Dialog */} ) => void onRemove: () => void + onEdit?: () => void showRemove?: boolean isBest?: boolean comparisonDelta?: number + isLeader?: boolean + salaryModeSynced?: boolean } export function CountryColumn({ @@ -63,9 +66,12 @@ export function CountryColumn({ costOfLiving = DEFAULT_COST_OF_LIVING, onUpdate, onRemove, + onEdit, showRemove = true, isBest = false, comparisonDelta, + isLeader: _isLeader = false, + salaryModeSynced: _salaryModeSynced = false, }: CountryColumnProps) { // Queries for dropdowns const { data: countries = [] } = useCountries() @@ -78,6 +84,7 @@ export function CountryColumn({ // Track if we've initialized defaults const hasInitializedYearRef = useRef(null) + const currencyEmittedForRef = useRef(null) // Auto-select latest year when years load useEffect(() => { @@ -94,11 +101,17 @@ export function CountryColumn({ useEffect(() => { if (!inputsData) return + const key = `${country}:${year}:${variant}` const updates: Partial = {} - // Update currency if changed - if (inputsData.currency && inputsData.currency !== currency) { - updates.currency = inputsData.currency + // Always emit currency on first load for this country/year/variant — even if it matches + // the default "EUR" — so synced followers can trigger salary conversion via updateCountry. + if (inputsData.currency) { + const isFirstLoad = currencyEmittedForRef.current !== key + if (isFirstLoad || inputsData.currency !== currency) { + updates.currency = inputsData.currency + currencyEmittedForRef.current = key + } } // Initialize form defaults for new inputs ONLY if they don't exist @@ -238,17 +251,31 @@ export function CountryColumn({ )} - {showRemove && onRemove && ( - - )} +
+ {onEdit && ( + + )} + {showRemove && onRemove && ( + + )} +
diff --git a/src/components/calculator/destination-wizard.tsx b/src/components/calculator/destination-wizard.tsx new file mode 100644 index 0000000..2580c54 --- /dev/null +++ b/src/components/calculator/destination-wizard.tsx @@ -0,0 +1,400 @@ +"use client" + +import { useState, useEffect } from "react" +import { ChevronLeft, ChevronRight, Check, Lock } from "lucide-react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DeductionManager } from "./deduction-manager" +import { CostOfLivingSection } from "./cost-of-living-section" +import { CountryColumnState, CostOfLiving } from "@/lib/types" +import { getCountryName, getCurrencySymbol, type InputDefinition } from "@/lib/api" +import { useCountries, useYears, useVariants, useInputs } from "@/lib/queries" + +const STEPS = ["Destination", "Tax Options", "Living Costs"] + +interface DestinationWizardProps { + open: boolean + onClose: () => void + initialState: CountryColumnState + onSave: (state: CountryColumnState) => void + isLeader?: boolean + salaryModeSynced?: boolean +} + +export function DestinationWizard({ + open, + onClose, + initialState, + onSave, + isLeader = true, + salaryModeSynced = false, +}: DestinationWizardProps) { + const [step, setStep] = useState(0) + const [draft, setDraft] = useState(initialState) + + // Reset when opened with new initialState + useEffect(() => { + if (open) { + setDraft(initialState) + setStep(0) + } + }, [open, initialState]) + + const { data: countries = [] } = useCountries() + const { data: years = [] } = useYears(draft.country) + const { data: variants = [] } = useVariants(draft.country, draft.year) + const { data: inputsData } = useInputs(draft.country, draft.year, draft.variant || undefined) + + const country = draft.country + const year = draft.year + const currency = draft.currency || "EUR" + const currencySymbol = getCurrencySymbol(currency) + + // When inputsData loads, pick up the currency and default form values + useEffect(() => { + if (!inputsData) return + const updates: Partial = {} + + if (inputsData.currency) { + updates.currency = inputsData.currency + } + + const newFormValues = { ...draft.formValues } + let hasNew = false + for (const [key, def] of Object.entries(inputsData.inputs)) { + if (!(key in draft.formValues)) { + hasNew = true + if (def.default !== undefined) { + newFormValues[key] = String(def.default) + } else if (def.type === "enum" && def.options) { + const first = Object.keys(def.options)[0] + if (first) newFormValues[key] = first + } else if (def.type === "boolean") { + newFormValues[key] = "false" + } + } + } + if (hasNew) updates.formValues = newFormValues + + if (Object.keys(updates).length > 0) { + setDraft(prev => ({ ...prev, ...updates })) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputsData?.currency, country, year, draft.variant]) + + const salaryEditable = isLeader || !salaryModeSynced + + // Step 0 requires country + year; salary required only when editable + const canAdvance = + step === 0 + ? !!(country && year && (salaryEditable ? draft.gross_annual : true)) + : true + + const handleNext = () => { + if (step < STEPS.length - 1) setStep(s => s + 1) + else handleSave() + } + + const handleSave = () => { + onSave(draft) + onClose() + } + + const updateDraftFormValue = (key: string, value: string) => { + setDraft(prev => ({ ...prev, formValues: { ...prev.formValues, [key]: value } })) + } + + const inputDefs = inputsData?.inputs || {} + const dynamicInputs = Object.entries(inputDefs).filter(([key]) => key !== "gross_annual") + const enumInputs = dynamicInputs.filter(([, def]) => def.type === "enum") + const booleanInputs = dynamicInputs.filter(([, def]) => def.type === "boolean") + + return ( + !v && onClose()}> + + + + {initialState.country ? `Edit ${getCountryName(initialState.country)}` : "Add Destination"} + + + + {/* Step indicator */} +
+ {STEPS.map((label, i) => ( +
+ + + {label} + + {i < STEPS.length - 1 &&
} +
+ ))} +
+ + {/* Step 0: Destination */} + {step === 0 && ( +
+
+
+ + +
+ +
+ + +
+
+ + {/* Variant */} + {variants.length > 0 && ( +
+ + +
+ )} + + {/* Gross Annual Salary */} + {country && year && ( +
+ + {salaryEditable ? ( +
+ + {currencySymbol} + + + setDraft(prev => ({ ...prev, gross_annual: e.target.value })) + } + /> +
+ ) : ( + + + +
+ + + {draft.gross_annual + ? `${currencySymbol}${parseInt(draft.gross_annual).toLocaleString()}` + : "Synced from first destination"} + + synced +
+
+ +

+ Salary is synced from the first destination and converted to this + country's currency automatically. Switch to "Local salaries" to set + it independently. +

+
+
+
+ )} +
+ )} +
+ )} + + {/* Step 1: Tax Options */} + {step === 1 && ( +
+ {enumInputs.length > 0 && ( +
+ {enumInputs.map(([key, def]) => ( +
+ + +
+ ))} +
+ )} + + {booleanInputs.length > 0 && ( +
+ {booleanInputs.map(([key, def]) => ( +
+ updateDraftFormValue(key, String(checked))} + /> + +
+ ))} +
+ )} + +
+

+ Tax Deductions +

+ } + formValues={draft.formValues} + onUpdateFormValue={updateDraftFormValue} + columnIndex={0} + result={draft.result} + calcRequest={ + draft.country && draft.year && draft.gross_annual + ? { + country: draft.country, + year: draft.year, + gross_annual: parseFloat(draft.gross_annual), + ...(draft.variant && { variant: draft.variant }), + ...Object.fromEntries( + Object.entries(draft.formValues).filter(([k]) => k !== "gross_annual") + ), + } + : null + } + /> +
+
+ )} + + {/* Step 2: Living Costs */} + {step === 2 && ( +
+

+ Enter your estimated monthly living costs in {currency} to see your disposable income. +

+ setDraft(prev => ({ ...prev, costOfLiving: col }))} + /> +
+ )} + + {/* Navigation */} +
+ + +
+ +
+ ) +} From e2816d5bff8aafa956e4a697cffb0c9a96876304 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 18:59:32 +0100 Subject: [PATCH 3/7] fix: use wrangler versions upload for automatic PR preview URLs - Replaced comment-triggered hack with proper pull_request event trigger - Use wrangler versions upload instead of wrangler deploy --name workaround - Cloudflare automatically generates preview URLs from the workers.dev subdomain pattern (*-universal-net-calc.reconnct.workers.dev) when Preview URLs are enabled - Parse preview URL from wrangler output and post as PR comment - Drop the manual subdomain-enable curl call and per-PR worker name approach Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr-preview.yml | 82 ++++++++------------------------ CLAUDE.md | 17 +++---- 2 files changed, 26 insertions(+), 73 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 17a3ccc..23cfe4b 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -1,50 +1,18 @@ name: PR Preview on: - issue_comment: - types: [created] + pull_request: + types: [opened, reopened, synchronize] permissions: pull-requests: write contents: read jobs: - check-permission: + deploy-preview: runs-on: ubuntu-latest - if: github.event.issue.pull_request && contains(github.event.comment.body, '/release-preview') - outputs: - has-permission: ${{ steps.check.outputs.has-permission }} steps: - - name: Check user has write access - id: check - run: | - # Check if user has write permission - if [ "${{ github.event.comment.author_association }}" = "OWNER" ] || \ - [ "${{ github.event.comment.author_association }}" = "COLLABORATOR" ] || \ - [ "${{ github.event.comment.author_association }}" = "MEMBER" ]; then - echo "has-permission=true" >> $GITHUB_OUTPUT - else - echo "has-permission=false" >> $GITHUB_OUTPUT - fi - - deploy: - runs-on: ubuntu-latest - needs: check-permission - if: needs.check-permission.outputs.has-permission == 'true' - environment: - name: preview - steps: - - name: Get PR info - id: pr - run: | - PR_NUMBER=${{ github.event.issue.number }} - echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "worker-name=universal-net-calc-pr-$PR_NUMBER" >> $GITHUB_OUTPUT - - - name: Checkout PR branch - uses: actions/checkout@v4 - with: - ref: refs/pull/${{ github.event.issue.number }}/merge + - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 @@ -58,41 +26,29 @@ jobs: - name: Build for Cloudflare run: make build-cloudflare - - name: Deploy PR preview - run: npx wrangler deploy --name ${{ steps.pr.outputs.worker-name }} - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - - name: Enable workers.dev subdomain - run: | - curl -s -X PUT \ - -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ - -H "Content-Type: application/json" \ - -d '{"enabled": true}' \ - "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/workers/scripts/${{ steps.pr.outputs.worker-name }}/subdomain" + - name: Upload preview version + id: deploy + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: versions upload - name: Comment PR with preview URL uses: actions/github-script@v7 with: script: | - const prNumber = context.issue.number; - const previewUrl = `https://universal-net-calc-pr-${prNumber}.reconnct.workers.dev`; - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: `✅ PR preview deployed!\n\n[🔗 Preview Link](${previewUrl})\n\n_This preview will be available for testing until the PR is closed._` - }); + const output = `${{ steps.deploy.outputs.command-output }}`; + const match = output.match(/https:\/\/\S+\.workers\.dev/); + const previewUrl = match ? match[0] : null; + + const body = previewUrl + ? `🔍 Preview deployed!\n\n[🔗 Preview Link](${previewUrl})\n\n_This is a staged version — not yet deployed to production._` + : `⚠️ Preview deployed but could not extract URL.\n\nCheck the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for the preview URL.`; - - name: Handle permission denied - if: ${{ failure() && needs.check-permission.outputs.has-permission == 'false' }} - uses: actions/github-script@v7 - with: - script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: `❌ You don't have permission to trigger PR previews. Only collaborators can use the \`/release-preview\` command.` + body, }); diff --git a/CLAUDE.md b/CLAUDE.md index a395092..53614b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,11 +59,8 @@ make release # Interactive process: # → GitHub Actions automatically deploys to production ``` -To create a PR preview deployment: -``` -# Comment `/release-preview` on any pull request to deploy a preview version to: -# https://universal-net-calc-pr-{PR_NUMBER}.reconnct.workers.dev -``` +PR preview deployments happen automatically when a pull request is opened or updated. +The preview URL is posted as a comment on the PR. **Note:** The developer often runs the server on port 3000 already. When 3000 is occupied assume the server is already running and use the existing service instead of trying to spin up your own @@ -302,11 +299,11 @@ This project uses GitHub Actions for tag-based releases and PR previews: ### Deployment Workflow -**PR Preview (Comment-Triggered):** -- Comment `/release-preview` on any pull request -- Deploys to: `https://universal-net-calc-pr-{PR_NUMBER}.reconnct.workers.dev` -- Only users with write access can trigger previews -- Available for testing until PR is closed +**PR Preview (Automatic):** +- Triggered automatically on PR open/update +- Uses `wrangler versions upload` to create a staged version with a preview URL +- Preview URL is posted as a comment on the PR +- Preview URLs follow the pattern: `https://-universal-net-calc.reconnct.workers.dev` **Production Release (Tag-Based):** - Create a release locally with `make release` From 59027f6410b4dfa88e8b0b40eb848bb94d8b6b14 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 19:00:43 +0100 Subject: [PATCH 4/7] chore: remove GitHub Actions deployment, Cloudflare native CI handles it - Delete pr-preview.yml (Cloudflare Workers Builds handles PR previews via wrangler versions upload on non-production branches) - Remove deploy job from release.yml (Cloudflare deploys main automatically) - Keep: test, version bump, github-release jobs in release.yml - Update CLAUDE.md to document new deployment ownership Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/pr-preview.yml | 54 -------------------------------- .github/workflows/release.yml | 44 +------------------------- CLAUDE.md | 37 ++++++++++------------ 3 files changed, 17 insertions(+), 118 deletions(-) delete mode 100644 .github/workflows/pr-preview.yml diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml deleted file mode 100644 index 23cfe4b..0000000 --- a/.github/workflows/pr-preview.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: PR Preview - -on: - pull_request: - types: [opened, reopened, synchronize] - -permissions: - pull-requests: write - contents: read - -jobs: - deploy-preview: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: make install - - - name: Build for Cloudflare - run: make build-cloudflare - - - name: Upload preview version - id: deploy - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: versions upload - - - name: Comment PR with preview URL - uses: actions/github-script@v7 - with: - script: | - const output = `${{ steps.deploy.outputs.command-output }}`; - const match = output.match(/https:\/\/\S+\.workers\.dev/); - const previewUrl = match ? match[0] : null; - - const body = previewUrl - ? `🔍 Preview deployed!\n\n[🔗 Preview Link](${previewUrl})\n\n_This is a staged version — not yet deployed to production._` - : `⚠️ Preview deployed but could not extract URL.\n\nCheck the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for the preview URL.`; - - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body, - }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1dd110..cc67caa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,6 @@ concurrency: permissions: contents: write - deployments: write jobs: test: @@ -88,46 +87,9 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - deploy: - name: Deploy to Cloudflare - needs: version - runs-on: ubuntu-latest - environment: - name: production - url: https://universal-net-calc.reconnct.workers.dev - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: ${{ needs.version.outputs.tag }} - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: make install - - - name: Deploy to production - run: make deploy-prod - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - - - name: Deployment summary - run: | - echo "## 🚀 Cloudflare Deployment" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Version:** ${{ needs.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "**Tag:** ${{ needs.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "**URL:** https://universal-net-calc.reconnct.workers.dev" >> $GITHUB_STEP_SUMMARY - echo "**Status:** ✅ Deployed Successfully" >> $GITHUB_STEP_SUMMARY - github-release: name: Create GitHub Release - needs: [version, deploy] + needs: version runs-on: ubuntu-latest steps: - name: Checkout code @@ -139,18 +101,14 @@ jobs: - name: Generate release notes id: release-notes run: | - # Get the previous tag PREVIOUS_TAG=$(git describe --tags --abbrev=0 "${{ needs.version.outputs.tag }}^" 2>/dev/null || echo "") if [ -z "$PREVIOUS_TAG" ]; then - # First release CHANGELOG=$(git log --oneline --pretty=format:"- %s" | head -20) else - # Generate changelog between tags CHANGELOG=$(git log "$PREVIOUS_TAG..${{ needs.version.outputs.tag }}" --oneline --pretty=format:"- %s") fi - # Format for GitHub release echo "changelog<> $GITHUB_OUTPUT echo "$CHANGELOG" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT diff --git a/CLAUDE.md b/CLAUDE.md index 53614b9..e1e3aa6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -295,39 +295,34 @@ If you need a calculation primitive not covered by existing node types: ## CI/CD System -This project uses GitHub Actions for tag-based releases and PR previews: +Deployments are handled by **Cloudflare Workers Builds** (native CI). GitHub Actions handles testing and versioning only. ### Deployment Workflow -**PR Preview (Automatic):** -- Triggered automatically on PR open/update -- Uses `wrangler versions upload` to create a staged version with a preview URL -- Preview URL is posted as a comment on the PR -- Preview URLs follow the pattern: `https://-universal-net-calc.reconnct.workers.dev` - -**Production Release (Tag-Based):** -- Create a release locally with `make release` -- Automatically: - - Validates tests pass - - Builds for Cloudflare - - Deploys to production - - Creates GitHub release with changelog - - Available at: `https://universal-net-calc.reconnct.workers.dev` - -**PR Validation (Automatic):** +**PR Preview (Cloudflare native):** +- Cloudflare automatically runs `wrangler versions upload` on every PR branch push +- Preview URL is posted in the Cloudflare dashboard and follows the pattern: + `https://-universal-net-calc.reconnct.workers.dev` + +**Production (Cloudflare native):** +- Cloudflare automatically runs `wrangler deploy` on every push to `main` +- Available at: `https://universal-net-calc.reconnct.workers.dev` + +**PR Validation (GitHub Actions):** - Runs on all pull requests - Code quality checks (ESLint + TypeScript) - Unit tests (Vitest) + config tests - Build validation +**Release tagging (GitHub Actions):** +- On push to `main`: bumps patch version, creates git tag, creates GitHub release + ### Documentation - **Complete guide:** `docs/ci-cd.md` - Workflows, setup, troubleshooting - **Workflow files:** `.github/workflows/` - - `pr.yml` - PR validation - - `pr-preview.yml` - PR preview deployment (comment-triggered) - - `release.yml` - Production release (tag-triggered) - - `deploy.yml` - Reusable deployment workflow + - `pr.yml` - PR validation (lint, tests, build) + - `release.yml` - Version bump and GitHub release (tag-triggered) ### Release Management From b118a02d753562c58b18c04b1aa1293e4e900e20 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 19:00:58 +0100 Subject: [PATCH 5/7] feat: implement wizard-edit-results separation fully (fixes #39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CountryColumn is now results-only: all inline config inputs removed. Shows an empty state with "Configure" CTA when not yet set up. Pencil (edit) button in header opens the wizard. - DestinationWizard: 3-step dialog (Income → Deductions → Living Costs) with div-based step indicator (no browser button hover/focus artifacts), clickable completed steps for backwards navigation - ComparisonGrid: auto-opens wizard on first visit (no URL state), "Add Destination" opens wizard, leader/follower salary sync fixed (only index-0 leader propagates; followers sync from leader on currency load) - CostOfLivingSection: add alwaysOpen prop (renders expanded in wizard) - CountryColumn: currencyEmittedForRef ensures EUR-default countries (e.g. Germany) still trigger salary sync on first load Co-Authored-By: Claude Opus 4.6 --- src/components/calculator/comparison-grid.tsx | 320 +++++---- .../calculator/cost-of-living-section.tsx | 34 +- src/components/calculator/country-column.tsx | 416 +++--------- .../calculator/destination-wizard.tsx | 618 ++++++++++-------- src/components/calculator/index.ts | 1 + 5 files changed, 594 insertions(+), 795 deletions(-) diff --git a/src/components/calculator/comparison-grid.tsx b/src/components/calculator/comparison-grid.tsx index 20f3304..4cb81a6 100644 --- a/src/components/calculator/comparison-grid.tsx +++ b/src/components/calculator/comparison-grid.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useCallback, useEffect, useRef } from "react" +import { useState, useCallback, useEffect, useRef, useMemo } from "react" import { Plus, Save } from "lucide-react" import { Button } from "@/components/ui/button" import { CountryColumn } from "./country-column" @@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { ShareButton } from "./share-button" import { SaveDialog } from "./save-dialog" +import { DestinationWizard } from "./destination-wizard" import { CountryColumnState, DEFAULT_COST_OF_LIVING } from "@/lib/types" import { decodeState, updateURL } from "@/lib/url-state" import { useSearchParams } from "next/navigation" @@ -19,7 +20,6 @@ import { MobileCountrySelector } from "./mobile-country-selector" import { UnsupportedCurrencyError } from "@/lib/errors" import { calculateNetDelta, findBestCountryByNet } from "@/lib/comparison-utils" import { detectUserCountry } from "@/lib/detect-country" -import { DestinationWizard } from "./destination-wizard" const MAX_COUNTRIES = 4 @@ -63,7 +63,6 @@ export function ComparisonGrid() { const hasInitializedFromUrl = useRef(false) const updateTimeoutRef = useRef(null) - // All country state in parent - start with empty country to avoid hydration mismatch const [countries, setCountries] = useState([ createEmptyCountryState(0), ]) @@ -72,8 +71,54 @@ export function ComparisonGrid() { const [saveDialogOpen, setSaveDialogOpen] = useState(false) const [activeTabIndex, setActiveTabIndex] = useState(0) const [salaryModeSynced, setSalaryModeSynced] = useState(true) + + // Wizard state: null = closed, '__new__' = adding, '' = editing const [wizardTargetId, setWizardTargetId] = useState(null) + const wizardInitialState = useMemo(() => { + if (wizardTargetId === "__new__") { + const newState = createEmptyCountryState(countries.length) + // In synced mode, pre-fill gross from the leader (index 0) + if (salaryModeSynced) { + const leader = [...countries].sort((a, b) => a.index - b.index).find(c => c.index === 0) + if (leader?.gross_annual) { + newState.gross_annual = leader.gross_annual + newState.currency = leader.currency + } + } + return newState + } + return countries.find(c => c.id === wizardTargetId) ?? createEmptyCountryState(0) + }, [wizardTargetId, countries, salaryModeSynced]) + + const handleWizardSave = useCallback( + (saved: CountryColumnState) => { + if (wizardTargetId === "__new__") { + const newEntry: CountryColumnState = { + ...saved, + id: crypto.randomUUID(), + index: countries.length, + result: null, + isCalculating: false, + calculationError: null, + } + setCountries(prev => [...prev, newEntry]) + if (isMobile) setActiveTabIndex(countries.length) + } else { + // Update existing, reset result so it recalculates + setCountries(prev => + prev.map(c => + c.id === wizardTargetId + ? { ...saved, id: c.id, index: c.index, result: null, isCalculating: false, calculationError: null } + : c + ) + ) + } + setWizardTargetId(null) + }, + [wizardTargetId, countries.length, isMobile] + ) + // Initialize from URL on mount ONLY useEffect(() => { if (hasInitializedFromUrl.current) return @@ -98,14 +143,16 @@ export function ComparisonGrid() { setCountries(entries) } else { - // No URL state, detect country client-side only const detectedCountry = detectUserCountry() - setCountries([createDefaultCountryState(0, detectedCountry)]) + const initial = createDefaultCountryState(0, detectedCountry) + setCountries([initial]) + // Open wizard immediately for the first destination + setWizardTargetId(initial.id) } hasInitializedFromUrl.current = true setIsInitialized(true) - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Sync state to URL (debounced) @@ -148,80 +195,93 @@ export function ComparisonGrid() { }, [countries, isInitialized]) // Update a single country's state - const updateCountry = useCallback((id: string, updates: Partial) => { - setCountries(prev => prev.map(c => (c.id === id ? { ...c, ...updates } : c))) - - if (!salaryModeSynced) return - - // When a follower's currency loads: sync salary FROM the leader (index 0) - if ("currency" in updates && updates.currency && !("gross_annual" in updates)) { - const targetCurrency = updates.currency - setCountries(prev => { - const me = prev.find(c => c.id === id) - if (!me || me.index === 0) return prev // Leader doesn't sync from anyone - const leader = [...prev].sort((a, b) => a.index - b.index).find(c => c.index === 0) - if (!leader?.gross_annual) return prev - const amount = parseFloat(leader.gross_annual) - if (isNaN(amount)) return prev - const sourceCurrency = leader.currency || "EUR" - if (sourceCurrency === targetCurrency) { - return prev.map(c => (c.id === id ? { ...c, gross_annual: leader.gross_annual } : c)) - } - fetchExchangeRate(sourceCurrency, targetCurrency) - .then(rate => { - const converted = String(Math.round(amount * rate)) - setCountries(cols => cols.map(col => col.id === id ? { ...col, gross_annual: converted } : col)) - }) - .catch(() => { - setCountries(cols => cols.map(col => col.id === id ? { ...col, gross_annual: leader.gross_annual } : col)) - }) - return prev - }) - } + const updateCountry = useCallback( + (id: string, updates: Partial) => { + setCountries(prev => prev.map(c => (c.id === id ? { ...c, ...updates } : c))) + + if (!salaryModeSynced) return + + // When a follower's currency loads: sync salary FROM the leader (index 0) + if ("currency" in updates && updates.currency && !("gross_annual" in updates)) { + const targetCurrency = updates.currency + setCountries(prev => { + const me = prev.find(c => c.id === id) + if (!me || me.index === 0) return prev // Leader doesn't sync from anyone + const leader = [...prev].sort((a, b) => a.index - b.index).find(c => c.index === 0) + if (!leader?.gross_annual) return prev + const amount = parseFloat(leader.gross_annual) + if (isNaN(amount)) return prev + const sourceCurrency = leader.currency || "EUR" + if (sourceCurrency === targetCurrency) { + return prev.map(c => (c.id === id ? { ...c, gross_annual: leader.gross_annual } : c)) + } + fetchExchangeRate(sourceCurrency, targetCurrency) + .then(rate => { + const converted = String(Math.round(amount * rate)) + setCountries(cols => + cols.map(col => (col.id === id ? { ...col, gross_annual: converted } : col)) + ) + }) + .catch(() => { + setCountries(cols => + cols.map(col => + col.id === id ? { ...col, gross_annual: leader.gross_annual } : col + ) + ) + }) + return prev + }) + } - // When the leader's salary changes: propagate to all followers - if ("gross_annual" in updates) { - setCountries(prev => { - const leader = prev.find(c => c.id === id) - if (!leader || leader.index !== 0) return prev // Only leader propagates - const amount = parseFloat(updates.gross_annual ?? "") - if (isNaN(amount)) return prev - const sourceCurrency = leader.currency || "EUR" - - prev.forEach(c => { - if (c.id === id) return - const targetCurrency = c.currency || "EUR" - if (targetCurrency === sourceCurrency) { - setCountries(cols => - cols.map(col => - col.id === c.id ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } : col + // When the leader's salary changes: propagate to all followers + if ("gross_annual" in updates) { + setCountries(prev => { + const leader = prev.find(c => c.id === id) + if (!leader || leader.index !== 0) return prev // Only leader propagates + const amount = parseFloat(updates.gross_annual ?? "") + if (isNaN(amount)) return prev + const sourceCurrency = leader.currency || "EUR" + + prev.forEach(c => { + if (c.id === id) return + const targetCurrency = c.currency || "EUR" + if (targetCurrency === sourceCurrency) { + setCountries(cols => + cols.map(col => + col.id === c.id + ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } + : col + ) ) - ) - } else { - fetchExchangeRate(sourceCurrency, targetCurrency) - .then(rate => { - const converted = String(Math.round(amount * rate)) - setCountries(cols => cols.map(col => col.id === c.id ? { ...col, gross_annual: converted } : col)) - }) - .catch(() => { - setCountries(cols => - cols.map(col => - col.id === c.id ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } : col + } else { + fetchExchangeRate(sourceCurrency, targetCurrency) + .then(rate => { + const converted = String(Math.round(amount * rate)) + setCountries(cols => + cols.map(col => (col.id === c.id ? { ...col, gross_annual: converted } : col)) ) - ) - }) - } + }) + .catch(() => { + setCountries(cols => + cols.map(col => + col.id === c.id + ? { ...col, gross_annual: updates.gross_annual ?? col.gross_annual } + : col + ) + ) + }) + } + }) + return prev }) - return prev - }) - } - }, [salaryModeSynced]) + } + }, + [salaryModeSynced] + ) - // Toggle salary mode const handleSalaryModeChange = useCallback((synced: boolean) => { setSalaryModeSynced(synced) if (synced) { - // Sync all columns to the first column that has a gross value setCountries(prev => { const sorted = [...prev].sort((a, b) => a.index - b.index) const source = sorted.find(c => c.gross_annual) @@ -231,76 +291,11 @@ export function ComparisonGrid() { } }, []) - // Wizard initial state — empty for new, existing state for edit - const wizardInitialState = useCallback((): CountryColumnState => { - if (!wizardTargetId || wizardTargetId === "__new__") { - return { - id: crypto.randomUUID(), - index: countries.length, - country: "", - year: "", - variant: "", - gross_annual: salaryModeSynced ? "" : "", - formValues: {}, - currency: "EUR", - result: null, - isCalculating: false, - calculationError: null, - costOfLiving: { rent: 0, healthcare: 0, food: 0, mobility: 0, travel: 0 }, - } - } - return countries.find(c => c.id === wizardTargetId) ?? { - id: crypto.randomUUID(), - index: countries.length, - country: "", - year: "", - variant: "", - gross_annual: "", - formValues: {}, - currency: "EUR", - result: null, - isCalculating: false, - calculationError: null, - costOfLiving: { rent: 0, healthcare: 0, food: 0, mobility: 0, travel: 0 }, - } - }, [wizardTargetId, countries, salaryModeSynced]) - - const handleWizardSave = useCallback( - (saved: CountryColumnState) => { - if (wizardTargetId === "__new__") { - // New followers start with empty salary; CountryColumn will trigger - // updateCountry({ currency }) once inputsData loads, which converts from leader. - const gross_annual = salaryModeSynced ? "" : saved.gross_annual - - const newEntry: CountryColumnState = { - ...saved, - id: crypto.randomUUID(), - index: countries.length, - gross_annual, - result: null, - isCalculating: false, - calculationError: null, - } - setCountries(prev => [...prev, newEntry]) - if (isMobile) setActiveTabIndex(countries.length) - } else { - // Editing existing — preserve the id/index - setCountries(prev => - prev.map(c => (c.id === wizardTargetId ? { ...c, ...saved, id: c.id, index: c.index } : c)) - ) - } - setWizardTargetId(null) - }, - [wizardTargetId, countries, salaryModeSynced, isMobile] - ) - - // Add new country — opens wizard const addCountry = useCallback(() => { if (countries.length >= MAX_COUNTRIES) return setWizardTargetId("__new__") }, [countries.length]) - // Remove country const removeCountry = useCallback( (id: string) => { if (countries.length > 1) { @@ -316,11 +311,9 @@ export function ComparisonGrid() { [countries, isMobile, activeTabIndex] ) - // Calculate normalized net values for comparison const [normalizedNetValues, setNormalizedNetValues] = useState>(new Map()) const BASE_CURRENCY = "EUR" - // Check if any column has cost-of-living data const anyColHasCostOfLiving = countries.some(c => { const col = c.costOfLiving return col && Object.values(col).some(v => v > 0) @@ -336,7 +329,6 @@ export function ComparisonGrid() { const { net, currency } = country.result const cur = currency || "EUR" - // Use disposable income if any column has COL data const monthlyCosts = anyColHasCostOfLiving ? Object.values(country.costOfLiving || {}).reduce((sum, v) => sum + v, 0) : 0 @@ -349,7 +341,6 @@ export function ComparisonGrid() { const rate = await fetchExchangeRate(cur, BASE_CURRENCY) normalized.set(country.id, comparableNet * rate) } catch (error) { - // Unsupported currency errors are expected, don't log as error if (error instanceof UnsupportedCurrencyError) { console.warn( `Exchange rate not available for ${error.currency}, using original value` @@ -371,13 +362,11 @@ export function ComparisonGrid() { } else { setNormalizedNetValues(new Map()) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [countries]) - // Find best country const bestCountryId = findBestCountryByNet(normalizedNetValues) - // Calculate delta for a country const getComparisonDelta = useCallback( (id: string): number | undefined => { if (!bestCountryId || bestCountryId === id) return undefined @@ -396,18 +385,15 @@ export function ComparisonGrid() { [bestCountryId, normalizedNetValues, countries] ) - // Countries with state for mobile selector const countriesWithState = countries.map(c => ({ index: c.index, country: c.country, })) - // Visible countries based on mobile/desktop const visibleCountries = isMobile ? countries.filter(c => c.index === activeTabIndex) : countries - // Results map for ComparisonSummary and SaveDialog const countryResults = new Map( countries .filter(c => c.result) @@ -440,7 +426,10 @@ export function ComparisonGrid() { -

One gross salary applied to all destinations — compare nets across tax systems.

+

+ One gross salary applied to all destinations — compare nets across tax + systems. +

@@ -450,7 +439,9 @@ export function ComparisonGrid() { -

Each destination has its own gross — for comparing real market-rate offers.

+

+ Each destination has its own gross — for comparing real market-rate offers. +

@@ -498,7 +489,6 @@ export function ComparisonGrid() { - {/* Salary mode toggle (mobile) */} handleSalaryModeChange(v === "synced")} @@ -528,9 +518,7 @@ export function ComparisonGrid() { a.index - b.index) - .map(c => c.id)} + displayOrder={countries.sort((a, b) => a.index - b.index).map(c => c.id)} /> )} @@ -548,8 +536,6 @@ export function ComparisonGrid() { showRemove={countries.length > 1} isBest={bestCountryId === country.id} comparisonDelta={getComparisonDelta(country.id)} - isLeader={country.index === 0} - salaryModeSynced={salaryModeSynced} /> ))} @@ -571,8 +557,6 @@ export function ComparisonGrid() { showRemove={countries.length > 1} isBest={bestCountryId === country.id} comparisonDelta={getComparisonDelta(country.id)} - isLeader={country.index === 0} - salaryModeSynced={salaryModeSynced} /> ))} @@ -580,18 +564,6 @@ export function ComparisonGrid() { )} - {/* Destination Wizard */} - {wizardTargetId && ( - setWizardTargetId(null)} - initialState={wizardInitialState()} - onSave={handleWizardSave} - isLeader={wizardTargetId === "__new__" ? false : (countries.find(c => c.id === wizardTargetId)?.index === 0)} - salaryModeSynced={salaryModeSynced} - /> - )} - {/* Save Dialog */} + + {/* Destination Wizard */} + {wizardTargetId && ( + setWizardTargetId(null)} + initialState={wizardInitialState} + onSave={handleWizardSave} + /> + )} ) } diff --git a/src/components/calculator/cost-of-living-section.tsx b/src/components/calculator/cost-of-living-section.tsx index ba2ec22..ce9866f 100644 --- a/src/components/calculator/cost-of-living-section.tsx +++ b/src/components/calculator/cost-of-living-section.tsx @@ -10,6 +10,7 @@ interface CostOfLivingSectionProps { value: CostOfLiving currencySymbol: string onChange: (col: CostOfLiving) => void + alwaysOpen?: boolean } const FIELDS: { key: keyof CostOfLiving; label: string }[] = [ @@ -20,8 +21,9 @@ const FIELDS: { key: keyof CostOfLiving; label: string }[] = [ { key: "travel", label: "Travel & Leisure" }, ] -export function CostOfLivingSection({ value, currencySymbol, onChange }: CostOfLivingSectionProps) { +export function CostOfLivingSection({ value, currencySymbol, onChange, alwaysOpen = false }: CostOfLivingSectionProps) { const [open, setOpen] = useState(false) + const isOpen = alwaysOpen || open const handleChange = (key: keyof CostOfLiving, raw: string) => { const num = parseFloat(raw) @@ -37,21 +39,23 @@ export function CostOfLivingSection({ value, currencySymbol, onChange }: CostOfL return (
- + {!alwaysOpen && ( + + )} - {open && ( + {isOpen && (

Monthly costs in local currency

{FIELDS.map(({ key, label }) => ( diff --git a/src/components/calculator/country-column.tsx b/src/components/calculator/country-column.tsx index 38e3f83..593443d 100644 --- a/src/components/calculator/country-column.tsx +++ b/src/components/calculator/country-column.tsx @@ -2,40 +2,18 @@ import { useEffect, useCallback, useRef, useMemo } from "react" import { toast } from "sonner" -import { X } from "lucide-react" +import { X, Pencil } from "lucide-react" import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" import { ResultBreakdown } from "./result-breakdown" import { SalaryRangeChart } from "./salary-range-chart" -import { NoticeIcon } from "./notices" import { getCountryName, getCurrencySymbol, type CalcRequest, type InputDefinition } from "@/lib/api" -import { DeductionManager } from "./deduction-manager" -import { CostOfLivingSection } from "./cost-of-living-section" -import { CountryColumnState, CostOfLiving, DEFAULT_COST_OF_LIVING } from "@/lib/types" +import { CountryColumnState } from "@/lib/types" import { getCountryFlag } from "@/lib/country-metadata" -import { Crown, Settings } from "lucide-react" +import { Crown } from "lucide-react" import { Badge } from "@/components/ui/badge" -import { Info } from "lucide-react" import { - useCountries, useYears, - useVariants, useInputs, useCalculateSalary, } from "@/lib/queries" @@ -43,12 +21,10 @@ import { interface CountryColumnProps extends CountryColumnState { onUpdate: (updates: Partial) => void onRemove: () => void - onEdit?: () => void + onEdit: () => void showRemove?: boolean isBest?: boolean comparisonDelta?: number - isLeader?: boolean - salaryModeSynced?: boolean } export function CountryColumn({ @@ -63,30 +39,22 @@ export function CountryColumn({ result, isCalculating, calculationError, - costOfLiving = DEFAULT_COST_OF_LIVING, + costOfLiving, onUpdate, onRemove, onEdit, showRemove = true, isBest = false, comparisonDelta, - isLeader: _isLeader = false, - salaryModeSynced: _salaryModeSynced = false, }: CountryColumnProps) { - // Queries for dropdowns - const { data: countries = [] } = useCountries() const { data: years = [] } = useYears(country) - const { data: variants = [] } = useVariants(country, year) const { data: inputsData } = useInputs(country, year, variant || undefined) - // Mutation for calculations const calculateMutation = useCalculateSalary() - - // Track if we've initialized defaults const hasInitializedYearRef = useRef(null) const currencyEmittedForRef = useRef(null) - // Auto-select latest year when years load + // Auto-select latest year when years load (for URL-restored state with no year) useEffect(() => { if (years.length > 0 && !year && hasInitializedYearRef.current !== country) { const sorted = [...years].sort((a, b) => b.localeCompare(a)) @@ -95,7 +63,6 @@ export function CountryColumn({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [years, year, country]) - // Only auto-select year once per country // Update currency and form defaults when inputs load useEffect(() => { @@ -114,23 +81,21 @@ export function CountryColumn({ } } - // Initialize form defaults for new inputs ONLY if they don't exist const newFormValues = { ...formValues } let hasNewDefaults = false - for (const [key, def] of Object.entries(inputsData.inputs)) { - // Only set defaults if the key doesn't exist in formValues - if (!(key in formValues)) { + for (const [k, def] of Object.entries(inputsData.inputs)) { + if (!(k in formValues)) { hasNewDefaults = true if (def.default !== undefined) { - newFormValues[key] = String(def.default) + newFormValues[k] = String(def.default) } else if (def.type === "enum" && def.options) { const firstOption = Object.keys(def.options)[0] if (firstOption) { - newFormValues[key] = firstOption + newFormValues[k] = firstOption } } else if (def.type === "boolean") { - newFormValues[key] = "false" + newFormValues[k] = "false" } } } @@ -144,9 +109,7 @@ export function CountryColumn({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [inputsData?.currency, country, year, variant]) - // Only run when country/year/variant changes, not on every formValues change - // Build the current calc request (shared by calculate() and DeductionManager) const calcRequest: CalcRequest | null = useMemo(() => { if (!country || !year || !gross_annual) return null const grossNum = parseFloat(gross_annual) @@ -170,7 +133,6 @@ export function CountryColumn({ return request }, [country, year, gross_annual, variant, formValues, inputsData]) - // Trigger calculation when inputs change const calculate = useCallback(() => { if (!country || !year || !gross_annual) { if (result) { @@ -214,61 +176,49 @@ export function CountryColumn({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [country, year, variant, gross_annual, formValues, inputsData]) - // Debounced calculation useEffect(() => { const timer = setTimeout(calculate, 500) return () => clearTimeout(timer) }, [calculate]) - const updateFormValue = (key: string, value: string) => { - onUpdate({ - formValues: { ...formValues, [key]: value }, - ...(key === "gross_annual" && { gross_annual: value }), - }) - } - - const inputDefs = inputsData?.inputs || {} - const dynamicInputs = Object.entries(inputDefs).filter( - ([key]) => key !== "gross_annual" - ) - const enumInputs = dynamicInputs.filter(([, def]) => def.type === "enum") - const booleanInputs = dynamicInputs.filter(([, def]) => def.type === "boolean") - const countryFlag = country ? getCountryFlag(country) : "" + const currencySymbol = getCurrencySymbol(currency || "EUR") + + const subtitle = + country && year && gross_annual + ? `${currencySymbol}${parseInt(gross_annual).toLocaleString()} gross · ${year}${variant ? ` · ${variant}` : ""}` + : null return ( - +
-
- - {country ? `${countryFlag} ${getCountryName(country)}` : `Country ${index + 1}`} +
+ + {country ? `${countryFlag} ${getCountryName(country)}` : `Destination ${index + 1}`} {isBest && ( - + Best )}
-
- {onEdit && ( +
+ + {showRemove && ( - )} - {showRemove && onRemove && ( -
+ {subtitle && ( +

{subtitle}

+ )} - -
- {/* Section: Income Parameters */} -

Income Parameters

-
-
- - -
- -
- - -
-
- - {/* Gross Salary */} -
-
-
- - {inputsData?.notices && ( - - )} -
-
-
- - {getCurrencySymbol(currency || "EUR")} - - updateFormValue("gross_annual", e.target.value)} - /> -
+ + {/* Empty / unconfigured state */} + {!country || !year || !gross_annual ? ( +
+

+ Configure this destination to see results +

+
- - {/* Dynamic Enum Inputs */} - {enumInputs.length > 0 && ( -
- {enumInputs.map(([key, def]) => ( -
- - -
- ))} -
- )} - - {/* Boolean Inputs */} - {booleanInputs.length > 0 && ( -
- {booleanInputs.map(([key, def]) => ( -
- updateFormValue(key, String(checked))} - /> -
- - {def.description && ( - - - - - - -

{def.description}

-
-
-
- )} -
-
- ))} -
- )} - - {/* Section: Tax Deductions */} -
-

Tax Deductions

- + {/* Results */} + key !== "gross_annual") + .map(([key, value]) => { + const inputDef = inputsData?.inputs[key] + if (inputDef?.type === "boolean") { + return [key, value === "true"] + } + return [key, value] + }) + ), + } + : undefined + } /> -
- {/* Section: Living Costs */} -
-

Living Costs

- onUpdate({ costOfLiving: col })} - /> -
- - {/* Variant Selection */} - {variants.length > 0 && ( -
- - -
- )} -
- - {/* Section: Results */} -
-

Results

- key !== "gross_annual") - .map(([key, value]) => { - const inputDef = inputsData?.inputs[key] - if (inputDef?.type === "boolean") { - return [key, value === "true"] - } - return [key, value] - }) - ), - } - : undefined - } - /> -
- - {/* Chart */} - {result && ( -
- -
+ {/* Chart */} + {result && ( +
+ +
+ )} + )} diff --git a/src/components/calculator/destination-wizard.tsx b/src/components/calculator/destination-wizard.tsx index 2580c54..905e180 100644 --- a/src/components/calculator/destination-wizard.tsx +++ b/src/components/calculator/destination-wizard.tsx @@ -1,7 +1,7 @@ "use client" -import { useState, useEffect } from "react" -import { ChevronLeft, ChevronRight, Check, Lock } from "lucide-react" +import { useState, useEffect, useRef } from "react" +import { ChevronLeft, ChevronRight, Check, Info } from "lucide-react" import { Dialog, DialogContent, @@ -11,6 +11,7 @@ import { import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" +import { Checkbox } from "@/components/ui/checkbox" import { Select, SelectContent, @@ -18,7 +19,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { Checkbox } from "@/components/ui/checkbox" import { Tooltip, TooltipContent, @@ -27,19 +27,19 @@ import { } from "@/components/ui/tooltip" import { DeductionManager } from "./deduction-manager" import { CostOfLivingSection } from "./cost-of-living-section" +import { NoticeIcon } from "./notices" import { CountryColumnState, CostOfLiving } from "@/lib/types" -import { getCountryName, getCurrencySymbol, type InputDefinition } from "@/lib/api" +import { getCountryName, getCurrencySymbol } from "@/lib/api" +import { getCountryFlag } from "@/lib/country-metadata" import { useCountries, useYears, useVariants, useInputs } from "@/lib/queries" -const STEPS = ["Destination", "Tax Options", "Living Costs"] +const STEPS = ["Income", "Deductions", "Living Costs"] interface DestinationWizardProps { open: boolean onClose: () => void initialState: CountryColumnState onSave: (state: CountryColumnState) => void - isLeader?: boolean - salaryModeSynced?: boolean } export function DestinationWizard({ @@ -47,350 +47,394 @@ export function DestinationWizard({ onClose, initialState, onSave, - isLeader = true, - salaryModeSynced = false, }: DestinationWizardProps) { const [step, setStep] = useState(0) const [draft, setDraft] = useState(initialState) - // Reset when opened with new initialState + // Reset draft and step when wizard opens useEffect(() => { if (open) { setDraft(initialState) setStep(0) } - }, [open, initialState]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const { country, year, variant, gross_annual, formValues, currency, costOfLiving } = draft const { data: countries = [] } = useCountries() - const { data: years = [] } = useYears(draft.country) - const { data: variants = [] } = useVariants(draft.country, draft.year) - const { data: inputsData } = useInputs(draft.country, draft.year, draft.variant || undefined) + const { data: years = [] } = useYears(country) + const { data: variants = [] } = useVariants(country, year) + const { data: inputsData } = useInputs(country, year, variant || undefined) - const country = draft.country - const year = draft.year - const currency = draft.currency || "EUR" - const currencySymbol = getCurrencySymbol(currency) + const hasInitializedYearRef = useRef(null) - // When inputsData loads, pick up the currency and default form values + // Auto-select latest year when country changes + useEffect(() => { + if (years.length > 0 && !year && hasInitializedYearRef.current !== country) { + const sorted = [...years].sort((a, b) => b.localeCompare(a)) + setDraft(prev => ({ ...prev, year: sorted[0] })) + hasInitializedYearRef.current = country + } + }, [years, year, country]) + + // Update currency and initialize form defaults when inputs load useEffect(() => { if (!inputsData) return + const updates: Partial = {} - if (inputsData.currency) { + if (inputsData.currency && inputsData.currency !== currency) { updates.currency = inputsData.currency } - const newFormValues = { ...draft.formValues } - let hasNew = false + const newFormValues = { ...formValues } + let hasNewDefaults = false + for (const [key, def] of Object.entries(inputsData.inputs)) { - if (!(key in draft.formValues)) { - hasNew = true + if (!(key in formValues)) { + hasNewDefaults = true if (def.default !== undefined) { newFormValues[key] = String(def.default) } else if (def.type === "enum" && def.options) { - const first = Object.keys(def.options)[0] - if (first) newFormValues[key] = first + const firstOption = Object.keys(def.options)[0] + if (firstOption) newFormValues[key] = firstOption } else if (def.type === "boolean") { newFormValues[key] = "false" } } } - if (hasNew) updates.formValues = newFormValues - if (Object.keys(updates).length > 0) { - setDraft(prev => ({ ...prev, ...updates })) - } + if (hasNewDefaults) updates.formValues = newFormValues + if (Object.keys(updates).length > 0) setDraft(prev => ({ ...prev, ...updates })) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputsData?.currency, country, year, draft.variant]) + }, [inputsData?.currency, country, year, variant]) - const salaryEditable = isLeader || !salaryModeSynced - - // Step 0 requires country + year; salary required only when editable - const canAdvance = - step === 0 - ? !!(country && year && (salaryEditable ? draft.gross_annual : true)) - : true + const inputDefs = inputsData?.inputs || {} + const dynamicInputs = Object.entries(inputDefs).filter(([key]) => key !== "gross_annual") + const enumInputs = dynamicInputs.filter(([, def]) => def.type === "enum") + const booleanInputs = dynamicInputs.filter(([, def]) => def.type === "boolean") - const handleNext = () => { - if (step < STEPS.length - 1) setStep(s => s + 1) - else handleSave() + const updateFormValue = (key: string, value: string) => { + setDraft(prev => ({ + ...prev, + formValues: { ...prev.formValues, [key]: value }, + ...(key === "gross_annual" && { gross_annual: value }), + })) } - const handleSave = () => { - onSave(draft) - onClose() + const canAdvance = step === 0 ? !!(country && year && gross_annual) : true + const currencySymbol = getCurrencySymbol(currency || "EUR") + + const handleNext = () => { + if (step < STEPS.length - 1) { + setStep(s => s + 1) + } else { + onSave(draft) + onClose() + } } - const updateDraftFormValue = (key: string, value: string) => { - setDraft(prev => ({ ...prev, formValues: { ...prev.formValues, [key]: value } })) + const handleBack = () => { + if (step > 0) setStep(s => s - 1) } - const inputDefs = inputsData?.inputs || {} - const dynamicInputs = Object.entries(inputDefs).filter(([key]) => key !== "gross_annual") - const enumInputs = dynamicInputs.filter(([, def]) => def.type === "enum") - const booleanInputs = dynamicInputs.filter(([, def]) => def.type === "boolean") + const title = country + ? `${getCountryFlag(country)} ${getCountryName(country)}` + : "New Destination" return ( !v && onClose()}> - - - - {initialState.country ? `Edit ${getCountryName(initialState.country)}` : "Add Destination"} - + + + {title} - {/* Step indicator */} -
- {STEPS.map((label, i) => ( -
- - - {label} - - {i < STEPS.length - 1 &&
} -
- ))} -
+ {/* Step indicator — div-based to avoid browser button hover/focus artifacts */} +
+ {STEPS.map((label, i) => { + const isCompleted = i < step + const isCurrent = i === step + const isClickable = i < step // only allow going back - {/* Step 0: Destination */} - {step === 0 && ( -
-
-
- - + + {isCompleted ? : i + 1} + + {label} +
+ {i < STEPS.length - 1 && ( +
+ )}
+ ) + })} +
-
- - -
-
+ {/* Step content */} +
+ {step === 0 && ( +
+ {/* Country & Year */} +
+
+ + +
- {/* Variant */} - {variants.length > 0 && ( -
- - +
+ + +
- )} - {/* Gross Annual Salary */} - {country && year && ( + {/* Gross Salary */}
- - {salaryEditable ? ( -
- - {currencySymbol} - - - setDraft(prev => ({ ...prev, gross_annual: e.target.value })) - } +
+ + {inputsData?.notices && ( + -
- ) : ( - - - -
- - - {draft.gross_annual - ? `${currencySymbol}${parseInt(draft.gross_annual).toLocaleString()}` - : "Synced from first destination"} - - synced -
-
- -

- Salary is synced from the first destination and converted to this - country's currency automatically. Switch to "Local salaries" to set - it independently. -

-
-
-
- )} + )} +
+
+ + {currencySymbol} + + updateFormValue("gross_annual", e.target.value)} + /> +
- )} -
- )} - {/* Step 1: Tax Options */} - {step === 1 && ( -
- {enumInputs.length > 0 && ( -
- {enumInputs.map(([key, def]) => ( -
- - updateFormValue(key, v === "__none__" ? "" : v)} + > + + + + + {!def.required && ( + + None - ))} - - -
- ))} -
- )} + )} + {def.options && + Object.entries(def.options).map(([optKey, opt]) => ( + + {(opt as { label: string }).label} + + ))} + + +
+ ))} +
+ )} - {booleanInputs.length > 0 && ( -
- {booleanInputs.map(([key, def]) => ( -
- updateDraftFormValue(key, String(checked))} - /> - -
- ))} -
- )} + {/* Boolean inputs */} + {booleanInputs.length > 0 && ( +
+ {booleanInputs.map(([key, def]) => ( +
+ updateFormValue(key, String(checked))} + /> +
+ + {def.description && ( + + + + + + +

{def.description}

+
+
+
+ )} +
+
+ ))} +
+ )} + + {/* Tax variant */} + {variants.length > 0 && ( +
+ + +
+ )} +
+ )} -
-

- Tax Deductions + {step === 1 && ( +

+

+ Add tax deductions applicable in{" "} + {country ? getCountryName(country) : "this country"}.

- } - formValues={draft.formValues} - onUpdateFormValue={updateDraftFormValue} - columnIndex={0} - result={draft.result} - calcRequest={ - draft.country && draft.year && draft.gross_annual - ? { - country: draft.country, - year: draft.year, - gross_annual: parseFloat(draft.gross_annual), - ...(draft.variant && { variant: draft.variant }), - ...Object.fromEntries( - Object.entries(draft.formValues).filter(([k]) => k !== "gross_annual") - ), - } - : null - } + {Object.keys(inputDefs).length === 0 ? ( +
+

+ {country && year + ? "No deductions available for this configuration." + : "Select a country and year first."} +

+
+ ) : ( + + )} +
+ )} + + {step === 2 && ( +
+

+ Enter estimated monthly living costs in {currencySymbol} to see disposable income. +

+ setDraft(prev => ({ ...prev, costOfLiving: col }))} + alwaysOpen />
-
- )} - - {/* Step 2: Living Costs */} - {step === 2 && ( -
-

- Enter your estimated monthly living costs in {currency} to see your disposable income. -

- setDraft(prev => ({ ...prev, costOfLiving: col }))} - /> -
- )} - - {/* Navigation */} -
-
+ + {/* Footer */} +
+ -
diff --git a/src/components/calculator/index.ts b/src/components/calculator/index.ts index d257194..43136e4 100644 --- a/src/components/calculator/index.ts +++ b/src/components/calculator/index.ts @@ -1,4 +1,5 @@ export { ComparisonGrid } from "./comparison-grid" +export { DestinationWizard } from "./destination-wizard" export { ComparisonSummary } from "./comparison-summary" export { CountryColumn } from "./country-column" export { ResultBreakdown } from "./result-breakdown" From 449d68207e4c3d2a1ef082bc245378de1a4c0d27 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 19:07:44 +0100 Subject: [PATCH 6/7] feat: sync salary conversion in wizard and detect region from timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Disable and show tooltip on gross salary field in wizard when in synced-salary mode editing a follower destination - Convert leader salary to destination currency when the destination's currency loads in the wizard (e.g. USD 100k → AUD ~158k) - Detect user's region from timezone first (physical location) instead of navigator.language (UI preference), fixing en-US browser in NL defaulting to United States instead of Netherlands Co-Authored-By: Claude Sonnet 4.6 --- src/components/calculator/comparison-grid.tsx | 6 ++ .../calculator/destination-wizard.tsx | 87 ++++++++++++++++--- src/lib/detect-country.ts | 63 ++++++-------- 3 files changed, 107 insertions(+), 49 deletions(-) diff --git a/src/components/calculator/comparison-grid.tsx b/src/components/calculator/comparison-grid.tsx index 4cb81a6..a8c6f80 100644 --- a/src/components/calculator/comparison-grid.tsx +++ b/src/components/calculator/comparison-grid.tsx @@ -589,6 +589,12 @@ export function ComparisonGrid() { onClose={() => setWizardTargetId(null)} initialState={wizardInitialState} onSave={handleWizardSave} + salaryModeSynced={salaryModeSynced} + isLeader={ + wizardTargetId === "__new__" + ? false + : (countries.find(c => c.id === wizardTargetId)?.index ?? 1) === 0 + } /> )}
diff --git a/src/components/calculator/destination-wizard.tsx b/src/components/calculator/destination-wizard.tsx index 905e180..add3f19 100644 --- a/src/components/calculator/destination-wizard.tsx +++ b/src/components/calculator/destination-wizard.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import { ChevronLeft, ChevronRight, Check, Info } from "lucide-react" import { Dialog, @@ -29,7 +29,7 @@ import { DeductionManager } from "./deduction-manager" import { CostOfLivingSection } from "./cost-of-living-section" import { NoticeIcon } from "./notices" import { CountryColumnState, CostOfLiving } from "@/lib/types" -import { getCountryName, getCurrencySymbol } from "@/lib/api" +import { getCountryName, getCurrencySymbol, fetchExchangeRate } from "@/lib/api" import { getCountryFlag } from "@/lib/country-metadata" import { useCountries, useYears, useVariants, useInputs } from "@/lib/queries" @@ -40,6 +40,8 @@ interface DestinationWizardProps { onClose: () => void initialState: CountryColumnState onSave: (state: CountryColumnState) => void + salaryModeSynced?: boolean + isLeader?: boolean } export function DestinationWizard({ @@ -47,15 +49,23 @@ export function DestinationWizard({ onClose, initialState, onSave, + salaryModeSynced = false, + isLeader = true, }: DestinationWizardProps) { const [step, setStep] = useState(0) const [draft, setDraft] = useState(initialState) + // Track the leader's gross/currency so we can convert when destination currency loads + const leaderGrossRef = useRef(initialState.gross_annual) + const leaderCurrencyRef = useRef(initialState.currency || "EUR") + // Reset draft and step when wizard opens useEffect(() => { if (open) { setDraft(initialState) setStep(0) + leaderGrossRef.current = initialState.gross_annual + leaderCurrencyRef.current = initialState.currency || "EUR" } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) @@ -78,6 +88,26 @@ export function DestinationWizard({ } }, [years, year, country]) + // Convert synced salary to destination currency when inputs load + const convertSyncedSalary = useCallback( + (destinationCurrency: string) => { + if (!salaryModeSynced || isLeader) return + const sourceCurrency = leaderCurrencyRef.current + const sourceAmount = parseFloat(leaderGrossRef.current) + if (isNaN(sourceAmount) || sourceAmount <= 0) return + if (sourceCurrency === destinationCurrency) return + fetchExchangeRate(sourceCurrency, destinationCurrency) + .then(rate => { + const converted = String(Math.round(sourceAmount * rate)) + setDraft(prev => ({ ...prev, gross_annual: converted })) + }) + .catch(() => { + // Leave as-is on error (already pre-filled with leader's amount) + }) + }, + [salaryModeSynced, isLeader] + ) + // Update currency and initialize form defaults when inputs load useEffect(() => { if (!inputsData) return @@ -86,6 +116,7 @@ export function DestinationWizard({ if (inputsData.currency && inputsData.currency !== currency) { updates.currency = inputsData.currency + convertSyncedSalary(inputsData.currency) } const newFormValues = { ...formValues } @@ -262,18 +293,46 @@ export function DestinationWizard({ /> )}
-
- - {currencySymbol} - - updateFormValue("gross_annual", e.target.value)} - /> -
+ {salaryModeSynced && !isLeader ? ( + + + +
+ + {currencySymbol} + + +
+
+ +

+ Salary is synced from the primary destination. Switch to{" "} + Local salaries mode to set each country independently. +

+
+
+
+ ) : ( +
+ + {currencySymbol} + + updateFormValue("gross_annual", e.target.value)} + /> +
+ )}
{/* Enum inputs */} diff --git a/src/lib/detect-country.ts b/src/lib/detect-country.ts index c532a93..d228546 100644 --- a/src/lib/detect-country.ts +++ b/src/lib/detect-country.ts @@ -1,34 +1,20 @@ /** - * Detect user's country from browser locale - * Returns ISO 3166-1 alpha-2 country code + * Detect user's country from browser signals. + * Prioritises timezone (physical location) over locale (UI language preference). + * Returns ISO 3166-1 alpha-2 country code. */ export function detectUserCountry(): string { if (typeof window === "undefined") { return "de" // Server-side fallback } - try { - // Try to get country from locale - const navigatorWithUserLanguage = navigator as Navigator & { userLanguage?: string } - const locale = navigator.language || navigatorWithUserLanguage.userLanguage || "en-DE" - - // Extract country code from locale (e.g., "en-US" -> "US", "de-DE" -> "DE") - const parts = locale.split("-") - if (parts.length === 2) { - const countryCode = parts[1].toLowerCase() + const supportedCountries = [ + "au", "ca", "ch", "de", "dk", "es", "fr", "gb", "ie", "it", + "jp", "kr", "nl", "no", "nz", "pt", "se", "sg", "us", + ] - // List of supported countries (should match available configs) - const supportedCountries = [ - "au", "ca", "ch", "de", "dk", "es", "fr", "gb", "ie", "it", - "jp", "kr", "nl", "no", "nz", "pt", "se", "sg", "us" - ] - - if (supportedCountries.includes(countryCode)) { - return countryCode - } - } - - // Fallback based on timezone + try { + // 1. Timezone is the best proxy for physical location — use it first. const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone // Map common timezones to countries @@ -62,24 +48,31 @@ export function detectUserCountry(): string { return timezoneMap[timezone] } - // Check timezone prefix for broader matching + // Broader timezone-prefix fallbacks if (timezone) { - if (timezone.startsWith("Europe/")) { - return "de" // Default to Germany for Europe - } else if (timezone.startsWith("America/")) { - return "us" // Default to US for Americas - } else if (timezone.startsWith("Asia/")) { - return "sg" // Default to Singapore for Asia - } else if (timezone.startsWith("Australia/")) { - return "au" - } else if (timezone.startsWith("Pacific/Auckland")) { - return "nz" + if (timezone.startsWith("Europe/")) return "de" + if (timezone.startsWith("America/")) return "us" + if (timezone.startsWith("Asia/")) return "sg" + if (timezone.startsWith("Australia/")) return "au" + if (timezone.startsWith("Pacific/")) return "nz" + } + + // 2. Locale as last resort — only trust the region tag when the language + // is NOT English, because English speakers appear worldwide and + // `navigator.language` reflects UI preference, not physical location. + const navigatorWithUserLanguage = navigator as Navigator & { userLanguage?: string } + const locale = navigator.language || navigatorWithUserLanguage.userLanguage || "" + const parts = locale.split("-") + if (parts.length === 2 && parts[0].toLowerCase() !== "en") { + const countryCode = parts[1].toLowerCase() + if (supportedCountries.includes(countryCode)) { + return countryCode } } } catch (error) { console.warn("Failed to detect user country:", error) } - // Final fallback to Germany + // Final fallback return "de" } From 36196f07d62664634d70e47b4245a7cea7ff76e6 Mon Sep 17 00:00:00 2001 From: Pascal Brokmeier Date: Sun, 22 Feb 2026 19:21:10 +0100 Subject: [PATCH 7/7] chore: add build:cloudflare npm script for Cloudflare Workers Builds Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 614f9c2..bd4af78 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "npm run build:configs && npm run generate:manifest && npm run cf-typegen && next build", + "build:cloudflare": "npm run build:configs && npm run generate:manifest && npm run cf-typegen && npx @opennextjs/cloudflare build", "build:configs": "node scripts/bundle-configs.mjs", "generate:manifest": "node generate-manifest.mjs", "start": "next start",