ultisuite-client/components/landing/product/product-cross-platform-section.tsx
R3D347HR4Y efaaf16f60
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: update metadata and layout for new product pages
- Refactored metadata for contacts, administration, and Ulticards pages to utilize dynamic app names and descriptions.
- Introduced new product pages for Ultiai, Ultical, Ulticards, Ultidrive, Ultimail, and Ultimeet with appropriate metadata.
- Enhanced layout components to ensure consistent styling and functionality across new product sections.
- Updated various components to replace hardcoded labels with dynamic references to improve maintainability and consistency.
2026-06-19 22:11:42 +02:00

584 lines
17 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { ProductSectionHeading } from "@/components/landing/product/product-section-heading"
import type { ProductCrossPlatformSection as ProductCrossPlatformSectionData } from "@/components/landing/product/product-data"
import {
demoMailPreviewSrc,
type DemoMailPreviewLayout,
} from "@/lib/demo/demo-mail-preview"
import {
demoDriveLayoutPreviewSrc,
type DemoDriveLayoutPreview,
} from "@/lib/demo/demo-drive-layout-preview"
import { ScaledPreviewIframe } from "@/components/landing/product/scaled-preview-iframe"
import { cn } from "@/lib/utils"
export type ProductCrossPlatformDemoApp = "mail" | "drive" | "meet"
type DeviceLayout = DemoMailPreviewLayout | DemoDriveLayoutPreview
const STAGE_DURATION_MS = 4200
/** Hauteur fixe du socle — évite le saut de layout entre mobile / tablette / bureau. */
const SHOWCASE_HEIGHT_PX = 620
/** Padding bezel DeviceShell (`p-2`). */
const DEVICE_BEZEL_PX = 8
const MONITOR_STAND_PX = 20
type DeviceStage = {
id: DeviceLayout
label: string
platform: string
icon: string
/** Viewport logique rendu dans l'iframe (media queries Ultimail). */
viewportWidth: number
viewportHeight: number
/** Largeur du cadre affiché sur la page (px). La hauteur est dérivée du ratio. */
frameWidth: number
radius: number
notch?: boolean
monitor?: boolean
}
const DEVICE_STAGES: DeviceStage[] = [
{
id: "phone",
label: "Mobile",
platform: "iOS & Android",
icon: "mdi:cellphone",
viewportWidth: 390,
viewportHeight: 844,
frameWidth: 264,
radius: 40,
notch: true,
},
{
id: "tablet",
label: "Tablette",
platform: "iPad paysage",
icon: "mdi:tablet",
viewportWidth: 1024,
viewportHeight: 768,
frameWidth: 520,
radius: 24,
},
{
id: "desktop",
label: "Bureau",
platform: "Écran 4:3",
icon: "mdi:monitor",
viewportWidth: 1280,
viewportHeight: 960,
frameWidth: 640,
radius: 12,
monitor: true,
},
]
function stageScale(stage: DeviceStage) {
return stage.frameWidth / stage.viewportWidth
}
function statusBarHeight(stage: DeviceStage) {
if (!stage.notch) return 0
const scale = stageScale(stage)
return Math.max(24, Math.round(32 * scale))
}
/** Hauteur de la zone iframe — ratio identique au viewport (aucune coupe). */
function contentAreaHeight(stage: DeviceStage) {
return Math.round((stage.frameWidth * stage.viewportHeight) / stage.viewportWidth)
}
/** Hauteur totale du cadre = zone iframe + barre de statut éventuelle. */
function frameHeight(stage: DeviceStage) {
return contentAreaHeight(stage) + statusBarHeight(stage)
}
function IosBatteryIcon({ size }: { size: number }) {
const width = Math.round(size * 2.1)
return (
<svg
width={width}
height={size}
viewBox="0 0 25 12"
fill="none"
aria-hidden
className="shrink-0"
>
<rect
x="0.5"
y="0.5"
width="21"
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="1"
/>
<rect x="22.5" y="3.5" width="2" height="5" rx="0.75" fill="currentColor" opacity="0.35" />
<rect x="2" y="2.5" width="15.5" height="7" rx="1.25" fill="currentColor" />
</svg>
)
}
function DeviceStatusBar({ stage }: { stage: DeviceStage }) {
if (!stage.notch) return null
const scale = stageScale(stage)
const height = statusBarHeight(stage)
const fontSize = Math.max(9, Math.round(11 * scale))
const iconSize = Math.max(9, Math.round(11 * scale))
const cornerRadius = Math.max(stage.radius - 10, 10)
/** Inset coins arrondis — plus à droite (courbure visible). */
const padLeft = Math.max(22, Math.round(28 * scale))
const padRight = Math.max(36, Math.round(cornerRadius * 1.12))
return (
<div
className="relative z-20 flex shrink-0 items-center justify-between overflow-hidden bg-[#f7f8fc] text-[#1c1c1e] dark:bg-[#1c1c1e] dark:text-[#f2f2f7]"
style={{
height,
paddingLeft: padLeft,
paddingRight: padRight,
fontSize,
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
}}
aria-hidden
>
<span className="shrink-0 font-semibold leading-none tabular-nums tracking-tight">9:41</span>
<div
className="flex shrink-0 items-center"
style={{ gap: Math.max(1, Math.round(3 * scale)) }}
>
<Icon icon="mdi:signal-cellular-3" style={{ width: iconSize, height: iconSize }} />
<Icon icon="mdi:wifi" style={{ width: iconSize, height: iconSize }} />
<IosBatteryIcon size={iconSize} />
</div>
</div>
)
}
function deviceOuterSize(stage: DeviceStage) {
return {
width: stage.frameWidth + DEVICE_BEZEL_PX * 2,
height:
frameHeight(stage) +
DEVICE_BEZEL_PX * 2 +
(stage.monitor ? MONITOR_STAND_PX : 0),
}
}
function ScaledProductDemoIframe({
stage,
ready,
active,
demoApp,
}: {
stage: DeviceStage
ready: boolean
active: boolean
demoApp: ProductCrossPlatformDemoApp
}) {
const demoSrc =
demoApp === "drive"
? demoDriveLayoutPreviewSrc(stage.id)
: demoMailPreviewSrc(stage.id)
const title =
demoApp === "drive"
? `UltiDrive — vue ${stage.label}`
: `Ultimail — vue ${stage.label}`
const iframe = (
<ScaledPreviewIframe
src={demoSrc}
title={title}
viewportWidth={stage.viewportWidth}
viewportHeight={stage.viewportHeight}
fit="cover"
coverEpsilonPx={0}
ready={ready}
active={active}
placeholderClassName="bg-[var(--landing-bg)]"
/>
)
// Sans encoche (tablette / bureau) : l'iframe remplit directement la boîte
// écran en absolute inset-0 — même structure que la démo docs (qui est nette),
// sans chaîne flex qui introduirait des sous-pixels (bandes au bord).
if (!stage.notch) {
return (
<div
className="relative h-full w-full overflow-hidden bg-[var(--landing-bg)]"
aria-hidden={!active}
>
{iframe}
</div>
)
}
// Avec encoche (mobile) : barre de statut en haut, iframe en dessous.
return (
<div
className="relative h-full w-full overflow-hidden bg-black"
aria-hidden={!active}
>
<div className="absolute inset-x-0 top-0 z-20">
<DeviceStatusBar stage={stage} />
</div>
<div
className="absolute inset-x-0 bottom-0 overflow-hidden bg-[var(--landing-bg)]"
style={{ top: statusBarHeight(stage) }}
>
{iframe}
</div>
</div>
)
}
const MEET_PEOPLE = [
{ name: "Léa", color: "#34A853" },
{ name: "Vincent", color: "#4285F4" },
{ name: "Thomas", color: "#EA4335" },
{ name: "Camille", color: "#FBBC04" },
{ name: "Sofia", color: "#A142F4" },
{ name: "Karim", color: "#00ACC1" },
] as const
const MEET_CONTROLS = ["mdi:microphone", "mdi:video", "mdi:monitor-share", "mdi:message-outline"] as const
/** Visio placeholder statique (pas d'iframe) rendu dans le device frame pour UltiMeet. */
function MeetDevicePlaceholder({
stage,
accent,
}: {
stage: DeviceStage
accent: string
}) {
const cols = stage.id === "phone" ? 2 : 3
const people = stage.id === "phone" ? MEET_PEOPLE.slice(0, 4) : MEET_PEOPLE
const avatarSize =
stage.id === "desktop" ? "size-14" : stage.id === "tablet" ? "size-12" : "size-9"
const buttonSize = stage.id === "phone" ? "size-7" : "size-9"
const iconSize = stage.id === "phone" ? "size-4" : "size-5"
return (
<div className="absolute inset-0 flex flex-col bg-[#0d0f12] text-white">
<div className="flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium text-white/70">
<Icon icon="mdi:lock-check" className="size-3.5 shrink-0" style={{ color: accent }} aria-hidden />
<span className="truncate">Atelier Nord point hebdo</span>
<span className="ml-auto shrink-0 tabular-nums">12:47</span>
</div>
<div
className="grid flex-1 gap-1.5 px-2"
style={{
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gridAutoRows: "minmax(0, 1fr)",
}}
>
{people.map((person, index) => (
<div
key={person.name}
className={cn(
"relative flex items-center justify-center overflow-hidden rounded-lg bg-white/5",
index === 0 && "ring-2"
)}
style={index === 0 ? { boxShadow: `inset 0 0 0 2px ${accent}` } : undefined}
>
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle at 50% 38%, ${person.color}44, transparent 70%)`,
}}
aria-hidden
/>
<span
className={cn(
"relative flex items-center justify-center rounded-full font-semibold text-white",
avatarSize
)}
style={{ backgroundColor: person.color }}
aria-hidden
>
{person.name.charAt(0)}
</span>
<span className="absolute bottom-1 left-1 flex max-w-[75%] items-center gap-0.5 truncate rounded bg-black/55 px-1 py-0.5 text-[9px] font-medium">
<Icon
icon={index % 2 === 0 ? "mdi:microphone" : "mdi:microphone-off"}
className="size-2.5 shrink-0"
aria-hidden
/>
{person.name}
</span>
</div>
))}
</div>
<div className="flex items-center justify-center gap-2 px-3 py-3">
{MEET_CONTROLS.map((icon) => (
<span
key={icon}
className={cn("flex items-center justify-center rounded-full bg-white/10", buttonSize)}
aria-hidden
>
<Icon icon={icon} className={iconSize} />
</span>
))}
<span
className={cn("flex items-center justify-center rounded-full bg-[#EA4335]", buttonSize)}
aria-hidden
>
<Icon icon="mdi:phone-hangup" className={iconSize} />
</span>
</div>
</div>
)
}
function DeviceShell({
stage,
children,
}: {
stage: DeviceStage
children: React.ReactNode
}) {
if (stage.monitor) {
return (
<div className="flex flex-col items-center gap-3">
<div
className="overflow-hidden border border-[var(--landing-line)] bg-[#101114] p-2 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.45)]"
style={{ borderRadius: stage.radius + 4 }}
>
<div
className="relative overflow-hidden bg-[var(--landing-bg)]"
style={{
width: stage.frameWidth,
height: frameHeight(stage),
borderRadius: stage.radius,
}}
>
{children}
</div>
</div>
<div
className="h-2.5 rounded-full bg-[var(--landing-line)]"
style={{ width: stage.frameWidth * 0.28 }}
aria-hidden
/>
</div>
)
}
return (
<div
className="relative border border-[var(--landing-line)] bg-[#101114] p-2 shadow-[0_24px_60px_-24px_rgba(0,0,0,0.45)]"
style={{ borderRadius: stage.radius }}
>
{stage.notch ? (
<div
className="pointer-events-none absolute left-1/2 top-[0.75rem] z-30 h-[1.125rem] w-[4.75rem] -translate-x-1/2 rounded-full bg-black"
aria-hidden
/>
) : null}
<div
className="relative overflow-hidden bg-[var(--landing-bg)]"
style={{
width: stage.frameWidth,
height: frameHeight(stage),
borderRadius: Math.max(stage.radius - 10, 10),
}}
>
{children}
</div>
</div>
)
}
function MorphingDeviceShowcase({
accent,
demoApp,
}: {
accent: string
demoApp: ProductCrossPlatformDemoApp
}) {
const sectionRef = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
const [stageIndex, setStageIndex] = useState(0)
const [autoPlay, setAutoPlay] = useState(true)
const [reduceMotion, setReduceMotion] = useState(false)
const stage = DEVICE_STAGES[stageIndex]!
useEffect(() => {
setReduceMotion(
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
}, [])
useEffect(() => {
const node = sectionRef.current
if (!node) return
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setVisible(true)
observer.disconnect()
}
},
{ rootMargin: "120px 0px" }
)
observer.observe(node)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!visible || reduceMotion || !autoPlay) return
const timer = window.setInterval(() => {
setStageIndex((current) => (current + 1) % DEVICE_STAGES.length)
}, STAGE_DURATION_MS)
return () => window.clearInterval(timer)
}, [visible, reduceMotion, autoPlay])
return (
<div ref={sectionRef} className="flex w-full flex-col items-center gap-8">
<div
className="relative mx-auto w-full max-w-full"
style={{ height: SHOWCASE_HEIGHT_PX }}
>
{DEVICE_STAGES.map((item, index) => {
const active = index === stageIndex
const outer = deviceOuterSize(item)
return (
<div
key={item.id}
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]",
active
? "z-10 opacity-100 scale-100"
: "pointer-events-none z-0 scale-[0.97] opacity-0"
)}
style={{
width: outer.width,
height: outer.height,
}}
>
<DeviceShell stage={item}>
{demoApp === "meet" ? (
<MeetDevicePlaceholder stage={item} accent={accent} />
) : (
<ScaledProductDemoIframe
stage={item}
ready={visible}
active={active}
demoApp={demoApp}
/>
)}
</DeviceShell>
</div>
)
})}
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
{DEVICE_STAGES.map((item, index) => {
const active = index === stageIndex
return (
<button
key={item.id}
type="button"
onClick={() => {
setAutoPlay(false)
setStageIndex(index)
}}
className={cn(
"landing-cta h-9 px-4 text-sm",
active ? "landing-cta--primary" : "landing-cta--ghost"
)}
style={
active
? ({
"--landing-glow-a": accent,
"--landing-glow-b": accent,
"--landing-glow-c": accent,
} as React.CSSProperties)
: undefined
}
aria-pressed={active}
>
<Icon icon={item.icon} className="size-4" aria-hidden />
{item.label}
</button>
)
})}
</div>
<p className="text-center text-sm text-[var(--landing-muted)]">
<span className="font-medium text-[var(--landing-fg)]">{stage.platform}</span>
{" — "}
même interface, adaptée à la taille de l&apos;écran
</p>
</div>
)
}
export function ProductCrossPlatformSection({
section,
accent,
demoApp = "mail",
}: {
section: ProductCrossPlatformSectionData
accent: string
demoApp?: ProductCrossPlatformDemoApp
}) {
return (
<section id="cross-platform" className="scroll-mt-20 px-4 py-16 sm:px-6 sm:py-20">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12">
<ProductSectionHeading
eyebrow={section.eyebrow}
title={section.title}
description={section.description}
accent={accent}
/>
<div className="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-12">
<LandingReveal delay={0.05}>
<ul className="flex flex-col gap-3">
{section.features.map((feature) => (
<li
key={feature.title}
className="landing-glass flex gap-3 rounded-xl p-4"
>
<span
className="flex size-10 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: `${accent}14`,
color: accent,
}}
>
<Icon icon={feature.icon} className="size-5" aria-hidden />
</span>
<div className="min-w-0">
<h3 className="font-semibold tracking-tight">{feature.title}</h3>
<p className="mt-0.5 text-sm text-[var(--landing-muted)]">
{feature.description}
</p>
</div>
</li>
))}
</ul>
</LandingReveal>
<LandingReveal delay={0.12}>
<MorphingDeviceShowcase accent={accent} demoApp={demoApp} />
</LandingReveal>
</div>
</div>
</section>
)
}