Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,6 @@ feature.md

# Playwright MCP artefacts (captures et snapshots de session)
.playwright-mcp/

# Notes de travail locales (skill senior-engineer)
/tasks/
20 changes: 20 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@
to { opacity: 1; transform: translateY(0); }
}

/* Révélation au scroll pilotée par IntersectionObserver.
L'état masqué n'est appliqué que si le scripting est disponible : sans
JavaScript, le contenu reste visible au lieu de rester à opacity 0. */
.reveal-on-scroll {
transition: opacity 0.7s ease-out, transform 0.7s ease-out;
}
@media (scripting: enabled) {
.reveal-on-scroll:not([data-in-view="true"]) {
opacity: 0;
transform: translateY(24px);
}
}
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll {
transition: none;
opacity: 1;
transform: none;
}
}

.pulse-dot {
width: 8px; height: 8px;
border-radius: 50%;
Expand Down
122 changes: 122 additions & 0 deletions components/landing/feature-shader-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"use client";

import { Warp } from "@paper-design/shaders-react";
import { Check } from "lucide-react";
import { useInView } from "@/lib/hooks/use-in-view";
import { usePrefersReducedMotion } from "@/lib/hooks/use-prefers-reduced-motion";
import { useBrandShaderPalette } from "@/lib/hooks/use-brand-shader-palette";

/**
* Géométries du shader. Les couleurs ne sont pas figées ici : elles dérivent du
* token `--primary` via useBrandShaderPalette, pour suivre la charte et le thème.
*/
const SHADER_GEOMETRIES = [
{
proportion: 0.32,
softness: 0.9,
distortion: 0.16,
swirl: 0.65,
swirlIterations: 8,
shape: "checks" as const,
shapeScale: 0.09,
},
{
proportion: 0.42,
softness: 1.15,
distortion: 0.2,
swirl: 0.85,
swirlIterations: 11,
shape: "stripes" as const,
shapeScale: 0.12,
},
{
proportion: 0.36,
softness: 1,
distortion: 0.18,
swirl: 0.72,
swirlIterations: 10,
shape: "checks" as const,
shapeScale: 0.11,
},
] as const;

interface FeatureShaderCardProps {
title: string;
description: string;
bullets: string[];
icon: React.ReactNode;
/** Position dans la grille : choisit la déclinaison de shader et le délai d'apparition. */
index: number;
}

/**
* Carte de fonctionnalité sur fond de shader animé.
*
* Le shader n'est monté qu'une fois la carte visible, et remplacé par un
* dégradé statique si l'utilisateur a demandé un mouvement réduit — six canvas
* WebGL animés en permanence coûteraient inutilement en CPU et en batterie.
*/
export function FeatureShaderCard({
title,
description,
bullets,
icon,
index,
}: FeatureShaderCardProps) {
const { ref, isInView, hasBeenInView } = useInView<HTMLElement>();
const prefersReducedMotion = usePrefersReducedMotion();

const geometry = SHADER_GEOMETRIES[index % SHADER_GEOMETRIES.length];
const { colors, gradient } = useBrandShaderPalette(index);
// Le contexte WebGL est libéré dès que la carte s'éloigne du viewport : six
// canvas actifs en permanence saturent inutilement le GPU et la batterie.
const shouldRenderShader = isInView && !prefersReducedMotion;

return (
<article
ref={ref}
className="reveal-on-scroll group relative rounded-3xl overflow-hidden border border-border hover:-translate-y-1 hover:border-primary/40"
data-in-view={hasBeenInView}
style={{ transitionDelay: `${(index % 3) * 90}ms` }}
>
<div className="absolute inset-0" style={{ background: gradient }} aria-hidden="true">
{shouldRenderShader && (
<Warp
style={{ width: "100%", height: "100%" }}
proportion={geometry.proportion}
softness={geometry.softness}
distortion={geometry.distortion}
swirl={geometry.swirl}
swirlIterations={geometry.swirlIterations}
shape={geometry.shape}
shapeScale={geometry.shapeScale}
scale={1}
rotation={0}
speed={0.5}
colors={colors}
/>
)}
</div>

<div className="relative flex h-full flex-col p-8 lg:p-10 bg-card/85 backdrop-blur-[2px] transition-colors duration-500 group-hover:bg-card/80">
<div className="mb-7 w-12 h-12 rounded-2xl bg-primary/10 border border-primary/25 flex items-center justify-center text-primary">
{icon}
</div>

<h3 className="text-xl font-bold text-foreground mb-3">{title}</h3>
<p className="text-muted-foreground leading-relaxed flex-1">{description}</p>

{bullets.length > 0 && (
<ul className="mt-7 pt-6 border-t border-border space-y-2.5">
{bullets.map((bullet) => (
<li key={bullet} className="flex items-start gap-2.5 text-sm text-muted-foreground">
<Check className="w-4 h-4 mt-0.5 shrink-0 text-primary" strokeWidth={2.5} />
{bullet}
</li>
))}
</ul>
)}
</div>
</article>
);
}
75 changes: 9 additions & 66 deletions components/landing/features-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,19 @@ import {
Video,
LayoutTemplate,
CreditCard,
ShieldCheck,
Check,
type LucideIcon,
} from "lucide-react";
import { SectionLabel } from "@/components/landing/section-label";

type AccentTone = "primary" | "blue" | "emerald" | "amber";
import { FeatureShaderCard } from "@/components/landing/feature-shader-card";

interface FeatureCard {
key: string;
icon: LucideIcon;
title: string;
description: string;
bullets: string[];
accent: AccentTone;
wide?: boolean;
}

const ACCENT_CLASSNAMES: Record<AccentTone, string> = {
primary: "bg-primary/10 border-primary/20 text-primary",
blue: "bg-blue-500/10 border-blue-500/20 text-blue-500",
emerald: "bg-emerald-500/10 border-emerald-500/20 text-emerald-500",
amber: "bg-amber-500/10 border-amber-500/20 text-amber-500",
};

/**
* Grille bento des fonctionnalités : couvre l'ensemble du produit
* (agent IA, agenda, CRM, téléconsultation, site vitrine, paiements, sécurité).
Expand All @@ -45,57 +33,41 @@ export async function FeaturesSection() {
title: t("aiAssistantTitle"),
description: t("aiAssistantDesc"),
bullets: [t("bullets.aiWidget"), t("bullets.aiPrompts")],
accent: "primary",
wide: true,
},
{
key: "calendar",
icon: CalendarDays,
title: t("calendarTitle"),
description: t("calendarDesc"),
bullets: [t("bullets.calendarDragDrop"), t("bullets.calendarSync")],
accent: "blue",
},
{
key: "crm",
icon: Users,
title: t("crmTitle"),
description: t("crmDesc"),
bullets: [t("bullets.crmRecords"), t("bullets.crmNotifications")],
accent: "emerald",
},
{
key: "teleconsultation",
icon: Video,
title: t("teleconsultationTitle"),
description: t("teleconsultationDesc"),
bullets: [t("bullets.teleconsultationLink"), t("bullets.teleconsultationQuality")],
accent: "primary",
},
{
key: "websiteBuilder",
icon: LayoutTemplate,
title: t("websiteBuilderTitle"),
description: t("websiteBuilderDesc"),
bullets: [t("bullets.websiteTemplates"), t("bullets.websiteBranding")],
accent: "amber",
},
{
key: "payments",
icon: CreditCard,
title: t("paymentsTitle"),
description: t("paymentsDesc"),
bullets: [t("bullets.paymentsWebhooks"), t("bullets.paymentsApi")],
accent: "primary",
wide: true,
},
{
key: "security",
icon: ShieldCheck,
title: t("securityTitle"),
description: t("securityDesc"),
bullets: [],
accent: "blue",
},
];

Expand All @@ -110,48 +82,19 @@ export async function FeaturesSection() {
<p className="mt-5 text-lg text-muted-foreground leading-relaxed">{t("subtitle")}</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{cards.map((card, index) => {
const Icon = card.icon;

return (
<article
<FeatureShaderCard
key={card.key}
className={`group relative overflow-hidden bg-card rounded-3xl border border-border p-8 lg:p-10 transition-all duration-300 hover:border-primary/30 hover:-translate-y-1 fade-in-up ${
card.wide ? "md:col-span-2" : ""
}`}
style={{ animationDelay: `${index * 60}ms` }}
>
<div
className="absolute -top-24 -right-24 w-56 h-56 rounded-full bg-primary/5 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"
aria-hidden="true"
/>

<div
className={`relative w-12 h-12 rounded-2xl border flex items-center justify-center mb-7 ${ACCENT_CLASSNAMES[card.accent]}`}
>
<Icon className="w-6 h-6" strokeWidth={1.75} />
</div>

<h3 className="relative text-xl font-bold text-foreground mb-3">{card.title}</h3>
<p className="relative text-muted-foreground leading-relaxed max-w-xl">
{card.description}
</p>

{card.bullets.length > 0 && (
<ul className="relative mt-7 pt-6 border-t border-border space-y-2.5">
{card.bullets.map((bullet) => (
<li
key={bullet}
className="flex items-start gap-2.5 text-sm text-muted-foreground"
>
<Check className="w-4 h-4 mt-0.5 shrink-0 text-primary" strokeWidth={2.5} />
{bullet}
</li>
))}
</ul>
)}
</article>
index={index}
title={card.title}
description={card.description}
bullets={card.bullets}
icon={<Icon className="w-6 h-6" strokeWidth={1.75} />}
/>
);
})}
</div>
Expand Down
Loading
Loading