feat: base UI components, layout and global styles
This commit is contained in:
226
src/app/globals.css
Normal file
226
src/app/globals.css
Normal 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
43
src/app/layout.tsx
Normal 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
241
src/components/ui.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user