From 7defeb87b3214743d8c26fa9e8a9fda3899716dc Mon Sep 17 00:00:00 2001 From: antopoid Date: Sun, 22 Feb 2026 20:00:33 +0100 Subject: [PATCH] feat: simulation state management (React Context) and sharing/export --- src/contexts/SimulationContext.tsx | 192 +++++++++++++++++++++++++++++ src/lib/sharing.ts | 57 +++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/contexts/SimulationContext.tsx create mode 100644 src/lib/sharing.ts diff --git a/src/contexts/SimulationContext.tsx b/src/contexts/SimulationContext.tsx new file mode 100644 index 0000000..47b4079 --- /dev/null +++ b/src/contexts/SimulationContext.tsx @@ -0,0 +1,192 @@ +'use client'; + +import React, { + createContext, + useContext, + useReducer, + useMemo, + useEffect, + useCallback, + type ReactNode, +} from 'react'; +import type { SimulationState, SimulationResults, LoanInput } from '@/lib/types'; +import { computeSimulation } from '@/lib/calculations'; +import { defaultStateNeuf, defaultStateAncien, createEmptyLoan, generateLoanId } from '@/lib/defaults'; +import { decodeState } from '@/lib/sharing'; + +// ─── Actions ──────────────────────────────────────────────────────────────── + +type Action = + | { type: 'SET_FIELD'; field: keyof SimulationState; value: unknown } + | { type: 'SET_PROPERTY_TYPE'; value: 'neuf' | 'ancien' } + | { type: 'ADD_LOAN'; loan: LoanInput } + | { type: 'REMOVE_LOAN'; id: string } + | { type: 'UPDATE_LOAN'; id: string; field: keyof LoanInput; value: unknown } + | { type: 'TOGGLE_LOAN'; id: string } + | { type: 'LOAD_STATE'; state: SimulationState } + | { type: 'RESET' }; + +function reducer(state: SimulationState, action: Action): SimulationState { + switch (action.type) { + case 'SET_FIELD': + return { ...state, [action.field]: action.value }; + + case 'SET_PROPERTY_TYPE': { + if (action.value === state.propertyType) return state; + // Switch between neuf and ancien defaults + const base = action.value === 'neuf' ? defaultStateNeuf() : defaultStateAncien(); + return { + ...state, + propertyType: action.value, + notaryFeesRate: base.notaryFeesRate, + loans: action.value === 'ancien' + ? state.loans.filter((l) => l.name !== 'Prêt Taux Zéro') + : state.loans, + }; + } + + case 'ADD_LOAN': + return { ...state, loans: [...state.loans, action.loan] }; + + case 'REMOVE_LOAN': + return { ...state, loans: state.loans.filter((l) => l.id !== action.id) }; + + case 'UPDATE_LOAN': + return { + ...state, + loans: state.loans.map((l) => + l.id === action.id ? { ...l, [action.field]: action.value } : l + ), + }; + + case 'TOGGLE_LOAN': + return { + ...state, + loans: state.loans.map((l) => + l.id === action.id ? { ...l, enabled: !l.enabled } : l + ), + }; + + case 'LOAD_STATE': + return action.state; + + case 'RESET': + return defaultStateNeuf(); + + default: + return state; + } +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +interface SimulationContextValue { + state: SimulationState; + results: SimulationResults; + setField: (field: keyof SimulationState, value: unknown) => void; + setPropertyType: (type: 'neuf' | 'ancien') => void; + addLoan: (loan: LoanInput) => void; + removeLoan: (id: string) => void; + updateLoan: (id: string, field: keyof LoanInput, value: unknown) => void; + toggleLoan: (id: string) => void; + loadState: (state: SimulationState) => void; + reset: () => void; +} + +const SimulationContext = createContext(null); + +// ─── Provider ─────────────────────────────────────────────────────────────── + +export function SimulationProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, defaultStateNeuf()); + + // Load state from URL hash on mount + useEffect(() => { + const hash = window.location.hash.slice(1); + if (hash) { + decodeState(hash).then((loaded) => { + if (loaded) { + // Re-generate loan IDs to avoid conflicts + const withIds = { + ...loaded, + loans: loaded.loans.map((l) => ({ ...l, id: generateLoanId() })), + }; + dispatch({ type: 'LOAD_STATE', state: withIds }); + } + }); + } + }, []); + + const results = useMemo(() => computeSimulation(state), [state]); + + const setField = useCallback( + (field: keyof SimulationState, value: unknown) => + dispatch({ type: 'SET_FIELD', field, value }), + [] + ); + + const setPropertyType = useCallback( + (type: 'neuf' | 'ancien') => dispatch({ type: 'SET_PROPERTY_TYPE', value: type }), + [] + ); + + const addLoan = useCallback( + (loan: LoanInput) => dispatch({ type: 'ADD_LOAN', loan }), + [] + ); + + const removeLoan = useCallback( + (id: string) => dispatch({ type: 'REMOVE_LOAN', id }), + [] + ); + + const updateLoan = useCallback( + (id: string, field: keyof LoanInput, value: unknown) => + dispatch({ type: 'UPDATE_LOAN', id, field, value }), + [] + ); + + const toggleLoan = useCallback( + (id: string) => dispatch({ type: 'TOGGLE_LOAN', id }), + [] + ); + + const loadState = useCallback( + (s: SimulationState) => dispatch({ type: 'LOAD_STATE', state: s }), + [] + ); + + const reset = useCallback(() => dispatch({ type: 'RESET' }), []); + + const value = useMemo( + () => ({ + state, + results, + setField, + setPropertyType, + addLoan, + removeLoan, + updateLoan, + toggleLoan, + loadState, + reset, + }), + [state, results, setField, setPropertyType, addLoan, removeLoan, updateLoan, toggleLoan, loadState, reset] + ); + + return ( + + {children} + + ); +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export function useSimulation(): SimulationContextValue { + const ctx = useContext(SimulationContext); + if (!ctx) { + throw new Error('useSimulation must be used within SimulationProvider'); + } + return ctx; +} diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts new file mode 100644 index 0000000..05a4a87 --- /dev/null +++ b/src/lib/sharing.ts @@ -0,0 +1,57 @@ +import type { SimulationState } from './types'; + +/** + * Encode simulation state into a shareable URL hash. + * Uses lz-string for compression. + */ +export async function encodeState(state: SimulationState): Promise { + const { compressToEncodedURIComponent } = await import('lz-string'); + const json = JSON.stringify(state); + return compressToEncodedURIComponent(json); +} + +/** + * Decode simulation state from a URL hash. + */ +export async function decodeState(hash: string): Promise { + try { + const { decompressFromEncodedURIComponent } = await import('lz-string'); + const json = decompressFromEncodedURIComponent(hash); + if (!json) return null; + return JSON.parse(json) as SimulationState; + } catch { + return null; + } +} + +/** + * Export yearly data as CSV. + */ +export function exportCSV(headers: string[], rows: (string | number)[][]): void { + const bom = '\uFEFF'; // UTF-8 BOM for Excel + const sep = ';'; + const lines = [ + headers.join(sep), + ...rows.map((row) => row.map((v) => (typeof v === 'number' ? v.toFixed(2) : v)).join(sep)), + ]; + const csv = bom + lines.join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `simulation-pret-immo-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +/** + * Copy text to clipboard. + */ +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +}