feat: simulation state management (React Context) and sharing/export
This commit is contained in:
192
src/contexts/SimulationContext.tsx
Normal file
192
src/contexts/SimulationContext.tsx
Normal file
@@ -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<SimulationContextValue | null>(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 (
|
||||||
|
<SimulationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</SimulationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hook ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useSimulation(): SimulationContextValue {
|
||||||
|
const ctx = useContext(SimulationContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSimulation must be used within SimulationProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
57
src/lib/sharing.ts
Normal file
57
src/lib/sharing.ts
Normal file
@@ -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<string> {
|
||||||
|
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<SimulationState | null> {
|
||||||
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user