feat: base UI components, layout and global styles

This commit is contained in:
2026-02-22 20:00:38 +01:00
parent 7defeb87b3
commit bfe88b7ea8
3 changed files with 510 additions and 0 deletions

226
src/app/globals.css Normal file
View File

@@ -0,0 +1,226 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ─── Base ─────────────────────────────────────────────────────────────────── */
:root {
--color-positive: #059669;
--color-negative: #dc2626;
--color-interest: #f59e0b;
--color-capital: #2563eb;
--color-insurance: #8b5cf6;
--color-charges: #6b7280;
--color-rent: #ef4444;
--color-projected: #2563eb;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}
.dark body,
html.dark body {
@apply bg-slate-950 text-slate-100;
}
/* ─── Form Elements ────────────────────────────────────────────────────────── */
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
/* ─── Cards ────────────────────────────────────────────────────────────────── */
@layer components {
.card {
@apply bg-white rounded-xl border border-slate-200 shadow-sm
dark:bg-slate-900 dark:border-slate-800;
}
.card-header {
@apply flex items-center justify-between px-5 py-4 cursor-pointer select-none
border-b border-slate-100 hover:bg-slate-50/50 transition-colors
dark:border-slate-800 dark:hover:bg-slate-800/50;
}
.card-body {
@apply px-5 py-4;
}
/* Form field */
.form-field {
@apply flex flex-col gap-1;
}
.form-label {
@apply text-xs font-medium text-slate-500 uppercase tracking-wide
dark:text-slate-400;
}
.form-input {
@apply block w-full rounded-lg border-slate-300 text-sm
focus:border-blue-500 focus:ring-blue-500
placeholder:text-slate-400 transition-colors
dark:bg-slate-800 dark:border-slate-700 dark:text-slate-100
dark:placeholder:text-slate-500 dark:focus:border-blue-400 dark:focus:ring-blue-400;
}
.form-input-sm {
@apply form-input py-1.5 px-2.5 text-sm;
}
/* Metric cards */
.metric-card {
@apply bg-white rounded-xl border border-slate-200 p-4 shadow-sm
dark:bg-slate-900 dark:border-slate-800;
}
.metric-value {
@apply text-2xl font-bold text-slate-900 tabular-nums
dark:text-slate-100;
}
.metric-label {
@apply text-xs font-medium text-slate-500 mt-1
dark:text-slate-400;
}
/* Table */
.data-table {
@apply w-full text-sm;
}
.data-table thead th {
@apply px-3 py-2.5 text-left text-xs font-semibold text-slate-500
uppercase tracking-wider bg-slate-50 border-b border-slate-200
whitespace-nowrap
dark:bg-slate-800/50 dark:text-slate-400 dark:border-slate-700;
}
.data-table tbody td {
@apply px-3 py-2 tabular-nums border-b border-slate-100
dark:border-slate-800;
}
.data-table tbody tr:hover {
@apply bg-blue-50/40 dark:bg-blue-950/30;
}
/* Tab styles */
.tab-btn {
@apply px-4 py-2.5 text-sm font-medium rounded-lg transition-all
text-slate-500 hover:text-slate-700 hover:bg-slate-100
dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800;
}
.tab-btn-active {
@apply bg-white text-blue-700 shadow-sm border border-slate-200
dark:bg-slate-800 dark:text-blue-400 dark:border-slate-700;
}
/* Badge */
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full;
}
.badge-blue {
@apply badge bg-blue-50 text-blue-700
dark:bg-blue-950/50 dark:text-blue-400;
}
.badge-green {
@apply badge bg-emerald-50 text-emerald-700
dark:bg-emerald-950/50 dark:text-emerald-400;
}
.badge-amber {
@apply badge bg-amber-50 text-amber-700
dark:bg-amber-950/50 dark:text-amber-400;
}
.badge-red {
@apply badge bg-red-50 text-red-700
dark:bg-red-950/50 dark:text-red-400;
}
}
/* ─── Animations ───────────────────────────────────────────────────────────── */
@layer utilities {
.animate-collapsible-open {
animation: collapsible-open 0.25s ease-out;
}
.animate-collapsible-close {
animation: collapsible-close 0.2s ease-in forwards;
}
}
@keyframes collapsible-open {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
@keyframes collapsible-close {
from {
opacity: 1;
max-height: 1000px;
}
to {
opacity: 0;
max-height: 0;
}
}
/* ─── Print ────────────────────────────────────────────────────────────────── */
@media print {
.no-print {
display: none !important;
}
body {
background: white;
color: black;
}
html.dark body {
background: white;
color: black;
}
.card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #e2e8f0;
background: white;
}
}
/* ─── Dark mode utility overrides ──────────────────────────────────────────── */
html.dark select {
@apply bg-slate-800 border-slate-700 text-slate-100;
}
html.dark input[type='checkbox'] {
@apply bg-slate-800 border-slate-600;
}

43
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,43 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
export const metadata: Metadata = {
title: 'SimO — Simulateur de Prêt Immobilier',
description:
'Simulez votre prêt immobilier gratuitement : PTZ, Action Logement, Primo Boost, amortissement détaillé, comparaison loyer/crédit et projection de revente.',
keywords: 'simulateur, prêt immobilier, PTZ, Action Logement, amortissement, crédit immobilier',
openGraph: {
title: 'SimO — Simulateur de Prêt Immobilier',
description: 'Simulez votre prêt immobilier gratuitement avec tous les dispositifs aidés.',
type: 'website',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
// Inline script to prevent FOUC (flash of unstyled content) on dark mode
const themeScript = `
(function() {
try {
var t = localStorage.getItem('simo-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch(e) {}
})();
`;
return (
<html lang="fr" className={inter.variable} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className={inter.className}>{children}</body>
</html>
);
}

241
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,241 @@
'use client';
import React, { useState, type ReactNode } from 'react';
// ─── Collapsible Section ────────────────────────────────────────────────────
interface CollapsibleSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
badge?: ReactNode;
children: ReactNode;
}
export function CollapsibleSection({
title,
icon,
defaultOpen = true,
badge,
children,
}: CollapsibleSectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="card">
<button
className="card-header w-full"
onClick={() => setOpen(!open)}
aria-expanded={open}
>
<div className="flex items-center gap-2">
{icon && <span className="text-slate-400">{icon}</span>}
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h3>
{badge}
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${
open ? 'rotate-180' : ''
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && <div className="card-body animate-fade-in">{children}</div>}
</div>
);
}
// ─── Number Input ───────────────────────────────────────────────────────────
interface NumberInputProps {
label: string;
value: number;
onChange: (v: number) => void;
suffix?: string;
prefix?: string;
min?: number;
max?: number;
step?: number;
hint?: string;
className?: string;
}
export function NumberInput({
label,
value,
onChange,
suffix,
prefix,
min,
max,
step = 1,
hint,
className,
}: NumberInputProps) {
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
{prefix && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{prefix}
</span>
)}
<input
type="number"
className={`form-input-sm ${prefix ? 'pl-7' : ''} ${suffix ? 'pr-10' : ''}`}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={min}
max={max}
step={step}
/>
{suffix && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
{suffix}
</span>
)}
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Rate Input (special for percentages) ───────────────────────────────────
interface RateInputProps {
label: string;
value: number; // stored as decimal (0.0325 for 3.25%)
onChange: (v: number) => void;
hint?: string;
className?: string;
}
export function RateInput({ label, value, onChange, hint, className }: RateInputProps) {
const displayValue = Math.round(value * 10000) / 100; // 0.0325 → 3.25
return (
<div className={`form-field ${className ?? ''}`}>
<label className="form-label">{label}</label>
<div className="relative">
<input
type="number"
className="form-input-sm pr-8"
value={displayValue}
onChange={(e) => onChange((parseFloat(e.target.value) || 0) / 100)}
step={0.01}
min={0}
max={100}
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-400 pointer-events-none">
%
</span>
</div>
{hint && <p className="text-xs text-slate-400 mt-0.5">{hint}</p>}
</div>
);
}
// ─── Toggle ─────────────────────────────────────────────────────────────────
interface ToggleProps {
options: { value: string; label: string }[];
value: string;
onChange: (v: string) => void;
className?: string;
}
export function Toggle({ options, value, onChange, className }: ToggleProps) {
return (
<div className={`inline-flex rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5 ${className ?? ''}`}>
{options.map((opt) => (
<button
key={opt.value}
className={`px-4 py-1.5 text-sm font-medium rounded-md transition-all ${
value === opt.value
? 'bg-white dark:bg-slate-700 text-blue-700 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
// ─── Tabs ───────────────────────────────────────────────────────────────────
interface TabsProps {
tabs: { id: string; label: string; icon?: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
}
export function Tabs({ tabs, activeTab, onTabChange }: TabsProps) {
return (
<div className="flex gap-1 p-1 bg-slate-100 dark:bg-slate-800/50 rounded-xl overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-btn whitespace-nowrap flex items-center gap-1.5 ${
activeTab === tab.id ? 'tab-btn-active' : ''
}`}
onClick={() => onTabChange(tab.id)}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
);
}
// ─── Tooltip ────────────────────────────────────────────────────────────────
interface TooltipProps {
text: string;
children: ReactNode;
}
export function Tooltip({ text, children }: TooltipProps) {
return (
<span className="relative group">
{children}
<span
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
bg-slate-800 text-white text-xs rounded-lg whitespace-nowrap
opacity-0 invisible group-hover:opacity-100 group-hover:visible
transition-all duration-200 pointer-events-none z-50
after:content-[''] after:absolute after:top-full after:left-1/2
after:-translate-x-1/2 after:border-4 after:border-transparent
after:border-t-slate-800"
>
{text}
</span>
</span>
);
}
// ─── Empty State ────────────────────────────────────────────────────────────
interface EmptyStateProps {
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12 px-4">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{title}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}