feat: update metadata and layout for new product pages
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- 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.
This commit is contained in:
R3D347HR4Y 2026-06-19 22:11:42 +02:00
parent 364ef0ef77
commit efaaf16f60
320 changed files with 7489 additions and 1112 deletions

View File

@ -1,11 +1,7 @@
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "contacts",
absoluteTitle: true,
title: "Contacts - Ulti Suite",
})
export const metadata: Metadata = suitePageMetadata({ app: "contacts" })
export default function ContactsLayout({
children,

View File

@ -1,14 +1,14 @@
import { DemoContactsShell } from "@/components/demo/demo-contacts-shell"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
import { ULTICARDS_APP_NAME, suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "contacts",
title: "Démo Contacts",
title: `Démo ${ULTICARDS_APP_NAME}`,
absoluteTitle: true,
description:
"Essayez les contacts Ulti Suite sans compte — démo interactive, zéro rétention.",
`Essayez ${ULTICARDS_APP_NAME} sans compte — démo interactive, zéro rétention.`,
}),
robots: { index: false },
}

View File

@ -18,11 +18,11 @@ import {
useStartMigrationOAuth,
} from "@/lib/api/hooks/use-hosted-mail"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
const SERVICE_LABELS: Record<string, string> = {
mail: "Mail",
contacts: "Contacts",
contacts: ULTICARDS_APP_NAME,
calendar: ULTICAL_APP_NAME,
drive: "Drive",
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ADMINISTRATION_PRODUCT } from "@/components/landing/product/product-data"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "admin",
title: "Administration — Console souveraine UltiSuite",
description: ADMINISTRATION_PRODUCT.description,
})
export default function AdministrationProductPage() {
return <ProductPageShell data={ADMINISTRATION_PRODUCT} />
}

17
app/suite/ultiai/page.tsx Normal file
View File

@ -0,0 +1,17 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTIAI_PRODUCT } from "@/components/landing/product/product-data"
export const metadata: Metadata = {
title: { absolute: "UltiAI — Assistant IA souverain" },
description: ULTIAI_PRODUCT.description,
icons: {
icon: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
apple: [{ url: "/ultiai-mark.svg", type: "image/svg+xml" }],
shortcut: "/ultiai-mark.svg",
},
}
export default function UltiaiProductPage() {
return <ProductPageShell data={ULTIAI_PRODUCT} />
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTICAL_PRODUCT } from "@/components/landing/product/product-data"
import { ULTICAL_APP_NAME, suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "agenda",
title: `${ULTICAL_APP_NAME} — Calendrier partagé souverain`,
description: ULTICAL_PRODUCT.description,
})
export default function UlticalProductPage() {
return <ProductPageShell data={ULTICAL_PRODUCT} />
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTICARDS_PRODUCT } from "@/components/landing/product/product-data"
import { ULTICARDS_APP_NAME, suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "contacts",
title: `${ULTICARDS_APP_NAME} — Carnet d'adresses souverain`,
description: ULTICARDS_PRODUCT.description,
})
export default function UlticardsProductPage() {
return <ProductPageShell data={ULTICARDS_PRODUCT} />
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTIDRIVE_PRODUCT } from "@/components/landing/product/product-data"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "drive",
title: "UltiDrive — Stockage & documents souverains",
description: ULTIDRIVE_PRODUCT.description,
})
export default function UltidriveProductPage() {
return <ProductPageShell data={ULTIDRIVE_PRODUCT} />
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTIMAIL_PRODUCT } from "@/components/landing/product/product-data"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "mail",
title: "Ultimail — Messagerie unifiée souveraine",
description: ULTIMAIL_PRODUCT.description,
})
export default function UltimailProductPage() {
return <ProductPageShell data={ULTIMAIL_PRODUCT} />
}

View File

@ -0,0 +1,14 @@
import type { Metadata } from "next"
import { ProductPageShell } from "@/components/landing/product/product-page-shell"
import { ULTIMEET_PRODUCT } from "@/components/landing/product/product-data"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = suitePageMetadata({
app: "meet",
title: "UltiMeet — Visioconférence chiffrée souveraine",
description: ULTIMEET_PRODUCT.description,
})
export default function UltimeetProductPage() {
return <ProductPageShell data={ULTIMEET_PRODUCT} />
}

View File

@ -46,11 +46,11 @@ import {
useRetryMigrationFailedJobs,
useRetryMigrationJob,
} from "@/lib/api/hooks/use-hosted-mail"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
const SERVICE_LABELS: Record<string, string> = {
mail: "Mail",
contacts: "Contacts",
contacts: ULTICARDS_APP_NAME,
calendar: ULTICAL_APP_NAME,
drive: "Drive",
}

View File

@ -31,7 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils"
const SIMPLE_PLUGIN_IDS = new Set(["mail-automation", "contact-discovery", "public-share"])
@ -268,7 +268,7 @@ function NextcloudPluginCard() {
onChange={(calendar_enabled) => setNextcloud({ calendar_enabled })}
/>
<ServiceToggle
label="Contacts"
label={ULTICARDS_APP_NAME}
checked={nextcloud.contacts_enabled}
onChange={(contacts_enabled) => setNextcloud({ contacts_enabled })}
/>

View File

@ -25,7 +25,7 @@ export function DemoChrome({
"max-sm:pt-1"
)}
>
<span className="rounded-full border border-[var(--mail-border)] bg-[var(--mail-surface-elevated)]/95 px-3 py-1 text-[11px] font-semibold text-[var(--mail-text-muted)] shadow-sm backdrop-blur-sm">
<span className="rounded-full border border-[var(--mail-border)]/60 bg-[var(--mail-surface-elevated)]/75 px-3 py-1 text-[11px] font-semibold text-[var(--mail-text-muted)]/90 shadow-sm backdrop-blur-sm">
Démo interactive zéro rétention
</span>
</div>

View File

@ -5,6 +5,7 @@ import { DriveAppShell } from "@/components/drive/drive-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { DemoDriveProvider } from "@/lib/demo/demo-drive-context"
import { DemoDriveBootstrap } from "@/lib/demo/demo-drive-bootstrap"
import { DemoDriveLayoutPreviewBootstrap } from "@/lib/demo/demo-drive-layout-preview-bootstrap"
import { DEMO_DRIVE_ROUTE_ROOT } from "@/lib/demo/demo-drive-context"
import { useDemoDriveStore } from "@/lib/demo/demo-drive-store"
@ -12,6 +13,7 @@ export function DemoDriveShell({ children }: { children: ReactNode }) {
return (
<DemoDriveProvider onReset={() => useDemoDriveStore.getState().reset()}>
<DemoDriveBootstrap />
<DemoDriveLayoutPreviewBootstrap />
<DemoChrome>
<DriveAppShell routeRoot={DEMO_DRIVE_ROUTE_ROOT}>{children}</DriveAppShell>
</DemoChrome>

View File

@ -5,12 +5,14 @@ import { MailAppShell } from "@/app/mail/mail-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { DemoMailProvider } from "@/lib/demo/demo-mail-context"
import { DemoMailBootstrap } from "@/lib/demo/demo-mail-bootstrap"
import { DemoMailPreviewBootstrap } from "@/lib/demo/demo-mail-preview-bootstrap"
import { useDemoMailStore } from "@/lib/demo/demo-mail-store"
export function DemoMailShell({ children }: { children: ReactNode }) {
return (
<DemoMailProvider onReset={() => useDemoMailStore.getState().reset()}>
<DemoMailBootstrap />
<DemoMailPreviewBootstrap />
<DemoChrome>
<MailAppShell>{children}</MailAppShell>
</DemoChrome>

View File

@ -7,6 +7,7 @@ import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { ShareDialog } from "@/components/drive/share-dialog"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { useIsMobile } from "@/hooks/use-mobile"
import { getDemoDriveLayoutPreview } from "@/lib/demo/demo-drive-layout-preview"
import { DriveRouteRootProvider } from "@/lib/drive/drive-route-context"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
@ -18,17 +19,20 @@ export function DriveAppShell({
routeRoot?: string
}) {
const isMobile = useIsMobile()
const layoutPreview = getDemoDriveLayoutPreview()
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
const sidebarOpen = !sidebarCollapsed
useLayoutEffect(() => {
if (layoutPreview) return
if (!isMobile) setSidebarCollapsed(false)
}, [isMobile, setSidebarCollapsed])
}, [isMobile, layoutPreview, setSidebarCollapsed])
useEffect(() => {
if (layoutPreview) return
if (isMobile) setSidebarCollapsed(true)
}, [isMobile, setSidebarCollapsed])
}, [isMobile, layoutPreview, setSidebarCollapsed])
return (
<DriveRouteRootProvider routeRoot={routeRoot}>

View File

@ -48,9 +48,12 @@ const RSVP_SECONDARY =
export function CalendarInvitationPreview({
invitation,
className,
calendarAppName = "Agenda",
}: {
invitation: ParsedCalendarInvitation
className?: string
/** Nom de l'app calendrier affiché dans le bouton « Ouvrir dans … ». */
calendarAppName?: string
}) {
ensureVcLogosCollection()
@ -122,7 +125,7 @@ export function CalendarInvitationPreview({
href={`/agenda/day/${format(invitation.start, "yyyy-MM-dd")}`}
className={RSVP_SECONDARY}
>
Ouvrir dans Agenda
Ouvrir dans {calendarAppName}
</a>
<button
type="button"

View File

@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { CONTACTS_PANEL_TITLE_CLASS } from "@/lib/contacts-chrome-classes"
import { SUITE_APP_LOGO_LOCKUP_CLASS, SUITE_APP_LOGO_MARK_CLASS } from "@/lib/suite/suite-chrome-classes"
import { ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
const CONTACTS_MARK_SRC = suitePublicAsset("/contacts-mark.svg")
@ -38,7 +39,7 @@ export function ContactsPanelLogo({
draggable={false}
aria-hidden
/>
<span className={titleClassName}>Contacts</span>
<span className={titleClassName}>{ULTICARDS_APP_NAME}</span>
</button>
)
}

View File

@ -2,6 +2,7 @@
import { useEffect, useCallback } from "react"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { ContactsListView } from "./contacts-list-view"
import { ContactFormView } from "./contact-form-view"
@ -40,7 +41,7 @@ export function ContactsPanel() {
data-contacts-panel
className="w-[360px] sm:max-w-[360px] gap-0 border-border bg-mail-surface p-0 text-foreground"
>
<SheetTitle className="sr-only">Contacts</SheetTitle>
<SheetTitle className="sr-only">{ULTICARDS_APP_NAME}</SheetTitle>
{view === "list" && <ContactsListView />}
{view === "view" && <ContactDetailView contactId={activeContactId} />}
{view === "create" && <ContactFormView mode="create" />}

View File

@ -5,7 +5,10 @@ import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes"
import {
MAIL_LIST_MAIN_SCROLL_CLASS,
MAIL_LIST_ROW_DIVIDER_CLASS,
} from "@/lib/mail-chrome-classes"
import {
PULL_HOLD_HEIGHT,
REFRESH_SPIN_CLASS,
@ -21,15 +24,6 @@ import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-em
import type { EmailListSelection } from "@/components/gmail/email-list/hooks/use-email-list-selection"
import type { EmailListReading } from "@/components/gmail/email-list/hooks/use-email-list-reading"
const MAIN_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " +
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
type EmailListBodyProps = {
data: EmailListData
labels: EmailListLabels
@ -148,7 +142,7 @@ export function EmailListBody({
"max-sm:pb-16",
!splitView && isViewMode && openEmail
? "relative flex min-h-0 flex-1 flex-col overflow-hidden"
: MAIN_SCROLL_CLASS,
: MAIL_LIST_MAIN_SCROLL_CLASS,
"relative min-h-0 flex-1 overscroll-y-none"
)}
>

View File

@ -97,6 +97,11 @@ export function useEmailListReading(
// Guard: emailById/setReadOverrides get new refs after each messages refetch — without
// this guard, mark-read → invalidate → refetch → effect re-runs in a loop.
const readAppliedForMailRef = useRef<string | null>(null)
const splitViewScrollTargetRef = useRef({
openMailId: null as string | null,
listPage: 1,
hadOpenRow: false,
})
useEffect(() => {
if (!openMailId) {
@ -482,14 +487,38 @@ export function useEmailListReading(
)
useLayoutEffect(() => {
if (!splitView || !openMailId) return
if (!splitView || !openMailId) {
splitViewScrollTargetRef.current = {
openMailId: null,
listPage,
hadOpenRow: false,
}
return
}
const root = listViewportRef.current
const row = root?.querySelector<HTMLElement>(
`[data-email-row-id="${openMailId}"]`
)
const rowInList = row != null
const prev = splitViewScrollTargetRef.current
const openMailChanged = prev.openMailId !== openMailId
const pageChanged = prev.listPage !== listPage
const rowJustAppeared = rowInList && !prev.hadOpenRow
splitViewScrollTargetRef.current = {
openMailId,
listPage,
hadOpenRow: rowInList,
}
// Infinite scroll appends rows → listRowsDep changes; skip scroll unless the
// open mail changed, the page changed, or its row just entered the list.
if (!openMailChanged && !pageChanged && !rowJustAppeared) return
if (!row) return
const scrollActiveRowIntoView = () => {
const root = listViewportRef.current
if (!root) return
const row = root.querySelector<HTMLElement>(
`[data-email-row-id="${openMailId}"]`
)
if (!row) return
row.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
scrollActiveRowIntoView()

View File

@ -218,7 +218,7 @@ export function CollapsedMessage({
<ContactHoverCard displayName={senderName} email={senderAddr} className="min-w-0">
<span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard>
<div className="flex shrink-0 items-center gap-1">
<div className="flex shrink-0 items-start gap-1">
{attachmentCount > 0 ? (
<span
className="flex items-center gap-0.5 text-xs text-muted-foreground"

View File

@ -146,14 +146,14 @@ export function EmailViewMessageToolbar({
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
<div className="flex items-center gap-1">
<MailDateText
iso={dateIso}
variant="preview"
className="hidden text-xs text-muted-foreground sm:inline"
/>
<div className="flex shrink-0 items-start gap-1 self-start pt-0.5">
<MailDateText
iso={dateIso}
variant="preview"
className="text-xs text-muted-foreground"
/>
<div className="flex items-center gap-1">
{onToggleStar ? (
<button
type="button"
@ -295,11 +295,6 @@ export function EmailViewMessageToolbar({
</DropdownMenuContent>
</DropdownMenu>
</div>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-muted-foreground sm:hidden"
/>
</div>
</div>
</>

View File

@ -3,6 +3,8 @@
import { useEffect, useState } from "react"
import {
formatMailDate,
formatMailPreviewDatePrimary,
formatMailPreviewDateRelative,
type MailDateDisplayVariant,
} from "@/lib/mail-date"
import { cn } from "@/lib/utils"
@ -13,18 +15,46 @@ type MailDateTextProps = {
className?: string
}
type PreviewDateLines = {
primary: string
relative: string | null
}
/** Date mail formatée côté client (fuseau navigateur, évite mismatch SSR). */
export function MailDateText({ iso, variant, className }: MailDateTextProps) {
const [text, setText] = useState("\u00a0")
const [previewLines, setPreviewLines] = useState<PreviewDateLines | null>(null)
useEffect(() => {
if (!iso?.trim()) {
setText("—")
setPreviewLines(null)
return
}
if (variant === "preview") {
setPreviewLines({
primary: formatMailPreviewDatePrimary(iso),
relative: formatMailPreviewDateRelative(iso),
})
setText("\u00a0")
return
}
setPreviewLines(null)
setText(formatMailDate(iso, variant))
}, [iso, variant])
if (variant === "preview" && previewLines) {
return (
<span
className={cn("inline-flex flex-col items-end leading-tight", className)}
suppressHydrationWarning
>
<span>{previewLines.primary}</span>
{previewLines.relative ? <span>{previewLines.relative}</span> : null}
</span>
)
}
return (
<span className={cn(className)} suppressHydrationWarning>
{text}

View File

@ -5,6 +5,7 @@ import { Calendar, Users, CheckSquare, Plus, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useAiPanelStore } from "@/lib/ai/use-ai-panel"
import { ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils"
export function RightPanel() {
@ -48,7 +49,7 @@ export function RightPanel() {
panelOpen ? "bg-blue-100 text-[#1a73e8]" : "text-gray-600"
)}
onClick={togglePanel}
aria-label="Contacts"
aria-label={ULTICARDS_APP_NAME}
>
<Users className="h-4 w-4" />
</Button>

View File

@ -1,5 +1,6 @@
'use client'
import { ULTICARDS_APP_NAME } from '@/lib/suite/page-metadata'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -113,7 +114,7 @@ export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
{domains.includes('contacts') ? (
<div className="space-y-2 rounded-md border border-border/50 p-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">Contacts</p>
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{ULTICARDS_APP_NAME}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<Label className="text-[10px]">Nom</Label>

View File

@ -1,6 +1,44 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type LandingAppGradient = {
a: string
b: string
c: string
}
/** Dégradés de marque — CTA démo, hover cartes, halos. */
export const LANDING_BRAND_GRADIENTS = {
mail: { a: "#EA4335", b: "#f2783c", c: "#c5221f" },
drive: { a: "#34A853", b: "#1e8e3e", c: "#81c995" },
ultical: { a: "#FBBC04", b: "#f9ab00", c: "#fdd663" },
ulticards: { a: "#4285F4", b: "#1a73e8", c: "#669df6" },
ultidocs: { a: "#34c77b", b: "#1fb6c9", c: "#2dd4bf" },
ultiai: { a: "#f2783c", b: "#ea4335", c: "#ff9a56" },
admin: { a: "#7C3AED", b: "#6D28D9", c: "#A78BFA" },
ultimeet: { a: "#34A853", b: "#1e8e3e", c: "#81c995" },
} as const satisfies Record<string, LandingAppGradient>
export function landingAppGlowVars(gradient: LandingAppGradient) {
return {
"--landing-glow-a": gradient.a,
"--landing-glow-b": gradient.b,
"--landing-glow-c": gradient.c,
}
}
/** Onglets démo — dégradé + texte sombre si besoin (jaune). */
export const LANDING_DEMO_TAB_STYLES: Record<
string,
{ gradient: LandingAppGradient; primaryTextDark?: boolean }
> = {
mail: { gradient: LANDING_BRAND_GRADIENTS.mail },
drive: { gradient: LANDING_BRAND_GRADIENTS.drive },
agenda: { gradient: LANDING_BRAND_GRADIENTS.ultical, primaryTextDark: true },
contacts: { gradient: LANDING_BRAND_GRADIENTS.ulticards },
docs: { gradient: LANDING_BRAND_GRADIENTS.ultidocs },
}
export type LandingApp = {
name: string
tagline: string
@ -8,9 +46,12 @@ export type LandingApp = {
icon: string
iconDark?: string
href?: string
/** Page produit marketing (ex. /suite/ultimail). */
productHref?: string
/** Application annoncée, pas encore disponible. */
soon?: boolean
accent: string
gradient: LandingAppGradient
}
/** Onglet démo (#demo) associé à une app (dock hero, visiteur non connecté). */
@ -29,7 +70,9 @@ export const LANDING_APPS: LandingApp[] = [
"Boîte unifiée multi-comptes, libellés intelligents, règles, envoi programmé et tri IA.",
icon: suitePublicAsset("/ultimail-mark.svg"),
href: "/mail",
productHref: "/suite/ultimail",
accent: "#EA4335",
gradient: LANDING_BRAND_GRADIENTS.mail,
},
{
name: "UltiDrive",
@ -38,16 +81,20 @@ export const LANDING_APPS: LandingApp[] = [
"Stockage, partage par lien, documents texte, dessins et co-édition en temps réel.",
icon: suitePublicAsset("/ultidrive-mark.svg"),
href: "/drive",
accent: "#4285F4",
productHref: "/suite/ultidrive",
accent: "#34A853",
gradient: LANDING_BRAND_GRADIENTS.drive,
},
{
name: "Contacts",
name: ULTICARDS_APP_NAME,
tagline: "Carnet d'adresses",
description:
"Contacts unifiés sur toute la suite, synchronisés avec la messagerie et le partage.",
icon: suitePublicAsset("/contacts-mark.svg"),
href: "/contacts",
productHref: "/suite/ulticards",
accent: "#4285F4",
gradient: LANDING_BRAND_GRADIENTS.ulticards,
},
{
name: "UltiAI",
@ -56,7 +103,9 @@ export const LANDING_APPS: LandingApp[] = [
"Assistant connecté à vos mails et fichiers, fournisseurs OpenAI-compatibles, quotas maîtrisés.",
icon: suitePublicAsset("/ultiai-mark.svg"),
href: "/chat",
productHref: "/suite/ultiai",
accent: "#f2783c",
gradient: LANDING_BRAND_GRADIENTS.ultiai,
},
{
name: "Administration",
@ -64,8 +113,10 @@ export const LANDING_APPS: LandingApp[] = [
description:
"Gestion de l'organisation, SSO, déploiement, quotas IA et réglages centralisés.",
icon: suitePublicAsset("/admin-mark.svg"),
href: "/admin/settings",
accent: "#5a6172",
href: "/admin",
productHref: "/suite/administration",
accent: "#7C3AED",
gradient: LANDING_BRAND_GRADIENTS.admin,
},
{
name: ULTICAL_APP_NAME,
@ -75,7 +126,9 @@ export const LANDING_APPS: LandingApp[] = [
icon: suitePublicAsset("/agenda-mark.svg"),
iconDark: suitePublicAsset("/agenda-mark-dark.svg"),
href: "/agenda",
accent: "#34c77b",
productHref: "/suite/ultical",
accent: "#FBBC04",
gradient: LANDING_BRAND_GRADIENTS.ultical,
},
{
name: "UltiMeet",
@ -84,7 +137,9 @@ export const LANDING_APPS: LandingApp[] = [
"Réunions vidéo chiffrées dans le navigateur, auto-hébergées via Jitsi et liées à UltiCal.",
icon: suitePublicAsset("/ultimeet-mark.svg"),
href: "/meet",
productHref: "/suite/ultimeet",
accent: "#34A853",
gradient: LANDING_BRAND_GRADIENTS.ultimeet,
},
{
name: "Photos",
@ -94,6 +149,7 @@ export const LANDING_APPS: LandingApp[] = [
icon: suitePublicAsset("/photos-mark.svg"),
soon: true,
accent: "#FBBC04",
gradient: LANDING_BRAND_GRADIENTS.ultical,
},
]

View File

@ -3,7 +3,11 @@
import { useEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import {
LANDING_DEMO_TAB_STYLES,
landingAppGlowVars,
} from "@/components/landing/landing-data"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { cn } from "@/lib/utils"
type DemoTab = {
@ -42,7 +46,7 @@ const DEMO_TABS: DemoTab[] = [
},
{
id: "contacts",
label: "Contacts",
label: ULTICARDS_APP_NAME,
icon: "mdi:account-group-outline",
src: "/demo/contacts",
fakeUrl: "suite.votre-domaine.fr/contacts",
@ -133,23 +137,33 @@ export function LandingDemoSection({
<LandingReveal delay={0.1} className="flex flex-col gap-4">
{/* Onglets */}
<div className="flex flex-wrap items-center justify-center gap-2">
{DEMO_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"landing-cta h-10 px-5 text-sm",
tab.id === activeTab
? "landing-cta--primary"
: "landing-cta--ghost"
)}
aria-pressed={tab.id === activeTab}
>
<Icon icon={tab.icon} className="size-4.5" aria-hidden />
{tab.label}
</button>
))}
{DEMO_TABS.map((tab) => {
const selected = tab.id === activeTab
const tabStyle = LANDING_DEMO_TAB_STYLES[tab.id]
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
"landing-cta h-10 px-5 text-sm",
selected ? "landing-cta--primary" : "landing-cta--ghost"
)}
style={
selected && tabStyle
? ({
...landingAppGlowVars(tabStyle.gradient),
...(tabStyle.primaryTextDark ? { color: "#1a1a1a" } : {}),
} as React.CSSProperties)
: undefined
}
aria-pressed={selected}
>
<Icon icon={tab.icon} className="size-4.5" aria-hidden />
{tab.label}
</button>
)
})}
</div>
{/* Fenêtre virtuelle */}

View File

@ -1,5 +1,6 @@
"use client"
import { ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
@ -7,6 +8,7 @@ import {
LANDING_APPS,
LANDING_FEATURES,
LANDING_INTEGRATIONS,
landingAppGlowVars,
} from "@/components/landing/landing-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
@ -63,6 +65,7 @@ export function LandingAppsSection() {
"landing-glass landing-halo-card flex h-full flex-col gap-3 rounded-2xl p-5",
app.soon && "opacity-75"
)}
style={landingAppGlowVars(app.gradient) as React.CSSProperties}
>
<div className="flex items-center justify-between">
<span
@ -125,7 +128,7 @@ export function LandingAppsSection() {
return (
<LandingReveal as="li" key={app.name} delay={(index % 4) * 0.07}>
{app.href && !app.soon ? (
<Link href={app.href} className="group block h-full rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
<Link href={app.productHref ?? app.href} className="group block h-full rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50">
{card}
</Link>
) : (
@ -364,7 +367,7 @@ export function LandingFooter() {
UltiDrive
</Link>
<Link href="/contacts" className="transition-colors hover:text-[var(--landing-fg)]">
Contacts
{ULTICARDS_APP_NAME}
</Link>
<Link href="/chat" className="transition-colors hover:text-[var(--landing-fg)]">
UltiAI

View File

@ -0,0 +1,583 @@
"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>
)
}

View File

@ -0,0 +1,53 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import type { ProductPageData } from "@/components/landing/product/product-data"
export function ProductCta({
section,
accent,
}: {
section: ProductPageData["ctaSection"]
accent: string
}) {
return (
<section className="px-4 pb-10 pt-10 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center gap-6 text-center">
<LandingReveal className="flex flex-col items-center gap-6">
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-5xl">
{section.title}
</h2>
<p className="max-w-xl text-balance text-base text-[var(--landing-muted)]">
{section.description}
</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href={section.ctas.primary.href}
className="landing-cta landing-cta--primary h-12 px-7 text-base"
style={
{
"--landing-glow-a": accent,
"--landing-glow-b": accent,
"--landing-glow-c": accent,
} as React.CSSProperties
}
>
{section.ctas.primary.label}
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link>
{section.ctas.secondary ? (
<Link
href={section.ctas.secondary.href}
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
{section.ctas.secondary.label}
</Link>
) : null}
</div>
</LandingReveal>
</div>
</section>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { cn } from "@/lib/utils"
type ProductDemoFrameProps = {
fakeUrl: string
hint?: string
fullscreenHref?: string
heightClass?: string
/** Rapport largeur/hauteur de la zone contenu (ex. 1440/900). Prioritaire sur heightClass. */
aspectRatio?: number
children: React.ReactNode
className?: string
}
export function ProductDemoFrame({
fakeUrl,
hint,
fullscreenHref,
heightClass = "h-[22rem] sm:h-[26rem] lg:h-[28rem]",
aspectRatio,
children,
className,
}: ProductDemoFrameProps) {
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-1.5 px-3 py-2.5">
<span className="size-2.5 rounded-full bg-[#ff5f57]" aria-hidden />
<span className="size-2.5 rounded-full bg-[#febc2e]" aria-hidden />
<span className="size-2.5 rounded-full bg-[#28c840]" aria-hidden />
<div className="ml-3 flex h-7 min-w-0 flex-1 items-center gap-2 rounded-full bg-[var(--landing-chip)] px-3 text-xs text-[var(--landing-muted)]">
<Icon icon="mdi:lock-outline" className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{fakeUrl}</span>
</div>
{fullscreenHref ? (
<Link
href={fullscreenHref}
target="_blank"
rel="noopener noreferrer"
className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-[var(--landing-muted)] transition-colors hover:bg-[var(--landing-chip)] hover:text-[var(--landing-fg)]"
title="Ouvrir la démo en plein écran"
>
<Icon icon="mdi:open-in-new" className="size-4" aria-hidden />
<span className="hidden sm:inline">Plein écran</span>
</Link>
) : null}
</div>
<div
className={cn(
"relative w-full overflow-hidden border-t border-[var(--landing-line)] bg-[var(--landing-bg)]",
aspectRatio ? undefined : heightClass
)}
style={aspectRatio ? { aspectRatio } : undefined}
>
{children}
</div>
</div>
{hint ? (
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
{hint}
</p>
) : null}
</div>
)
}

View File

@ -0,0 +1,96 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#7C3AED"
type Provider = {
name: string
type: string
icon: string
brandLogo?: boolean
enabled: boolean
status: "synced" | "pending"
}
const PROVIDERS: Provider[] = [
{ name: "Google Workspace", type: "OAuth", icon: "logos:google-icon", brandLogo: true, enabled: true, status: "synced" },
{ name: "Azure AD / Entra", type: "SAML", icon: "logos:microsoft-icon", brandLogo: true, enabled: true, status: "synced" },
{ name: "Active Directory", type: "LDAP", icon: "mdi:server-network", enabled: true, status: "pending" },
{ name: "Okta", type: "SAML", icon: "logos:okta-icon", brandLogo: true, enabled: false, status: "pending" },
]
const RESTRICTIONS = ["acme.com", "filiale.fr", "ulti-users", "ulti-admins"]
/** Aperçu statique des fournisseurs d'identité (SSO) gérés via Authentik. */
export function AdminIdentityDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:account-key-outline" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Fournisseurs d&apos;identité</span>
<span className="ml-auto text-[11px] font-medium text-[var(--landing-muted)]">SSO · Authentik</span>
</div>
<ul className="divide-y divide-[var(--landing-line)]">
{PROVIDERS.map((p) => (
<li key={p.name} className="flex items-center gap-3 px-4 py-2.5">
<span
className={
p.brandLogo
? "flex size-8 shrink-0 items-center justify-center rounded-lg bg-white shadow-sm ring-1 ring-[var(--landing-line)]"
: "flex size-8 shrink-0 items-center justify-center rounded-lg"
}
style={p.brandLogo ? undefined : { backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<Icon icon={p.icon} className={p.brandLogo ? "size-5" : "size-4"} aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--landing-fg)]">{p.name}</p>
<p className="text-[11px] text-[var(--landing-muted)]">{p.type}</p>
</div>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
style={
p.status === "synced"
? { backgroundColor: `${ACCENT}1f`, color: ACCENT }
: { backgroundColor: "var(--landing-chip)", color: "var(--landing-muted)" }
}
>
{p.status === "synced" ? "Synchronisé" : "En attente"}
</span>
<Icon
icon={p.enabled ? "mdi:toggle-switch" : "mdi:toggle-switch-off-outline"}
className="size-6 shrink-0"
style={{ color: p.enabled ? ACCENT : "var(--landing-muted)" }}
aria-hidden
/>
</li>
))}
</ul>
<div className="border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-4">
<p className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:shield-account-outline" className="size-3.5" aria-hidden />
Restrictions d&apos;accès & groupes par défaut
</p>
<div className="flex flex-wrap gap-1.5">
{RESTRICTIONS.map((r) => (
<span
key={r}
className="rounded-full border border-[var(--landing-line)] bg-[var(--landing-chip)]/50 px-2 py-0.5 text-xs text-[var(--landing-fg)]"
>
{r}
</span>
))}
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique OAuth, SAML et LDAP/AD, provisioning des groupes et domaines autorisés.
</p>
</div>
)
}

View File

@ -0,0 +1,102 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#7C3AED"
type ServiceProgress = {
service: string
icon: string
imported: number
total: number
}
const SERVICES: ServiceProgress[] = [
{ service: "Mail", icon: "mdi:email-outline", imported: 12840, total: 13200 },
{ service: "Drive", icon: "mdi:folder-outline", imported: 3420, total: 3900 },
{ service: "Contacts", icon: "mdi:card-account-details-outline", imported: 512, total: 512 },
{ service: "Agenda", icon: "mdi:calendar-outline", imported: 286, total: 310 },
]
/** Aperçu statique d'un projet de migration Google Workspace / Microsoft 365. */
export function AdminMigrationDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:swap-horizontal-bold" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Migration ACME 2026</span>
<span
className="ml-auto flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<span className="size-1.5 rounded-full" style={{ backgroundColor: ACCENT }} aria-hidden />
En cours
</span>
</div>
<div className="flex items-center gap-3 border-b border-[var(--landing-line)] px-4 py-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white shadow-sm ring-1 ring-[var(--landing-line)]">
<Icon icon="logos:google-workspace" className="size-5" aria-hidden />
</span>
<Icon icon="mdi:arrow-right" className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white shadow-sm ring-1 ring-[var(--landing-line)]">
<Icon icon="logos:microsoft-icon" className="size-5" aria-hidden />
</span>
<div className="min-w-0 text-xs text-[var(--landing-muted)]">
<p className="truncate font-medium text-[var(--landing-fg)]">Google Workspace + Microsoft 365</p>
<p className="truncate">OAuth · Google DWD · MS app-only</p>
</div>
</div>
<div className="space-y-2.5 p-4">
{SERVICES.map((s) => {
const pct = Math.round((s.imported / s.total) * 100)
return (
<div key={s.service}>
<div className="mb-1 flex items-center gap-2 text-xs">
<Icon icon={s.icon} className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="font-medium text-[var(--landing-fg)]">{s.service}</span>
<span className="ml-auto tabular-nums text-[var(--landing-muted)]">
{s.imported.toLocaleString("fr-FR")} / {s.total.toLocaleString("fr-FR")}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--landing-chip)]">
<div
className="h-full rounded-full transition-all"
style={{ width: `${pct}%`, backgroundColor: ACCENT }}
/>
</div>
</div>
)
})}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 px-4 py-3">
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
Bascule MX
</span>
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={{ backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<Icon icon="mdi:check-circle" className="size-3.5" aria-hidden />
TXT
</span>
<span
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={{ backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<Icon icon="mdi:check-circle" className="size-3.5" aria-hidden />
MX
</span>
<span className="ml-auto text-[11px] text-[var(--landing-muted)]">DNS vérifié · prêt au cutover</span>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique import, suivi des jobs, audit par utilisateur et bascule MX progressive.
</p>
</div>
)
}

View File

@ -0,0 +1,86 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#7C3AED"
type Policy = {
label: string
icon: string
enabled: boolean
}
const POLICIES: Policy[] = [
{ label: "2FA obligatoire (admins)", icon: "mdi:cellphone-key", enabled: true },
{ label: "Clés de sécurité WebAuthn", icon: "mdi:key-chain-variant", enabled: true },
{ label: "Partage externe restreint", icon: "mdi:link-lock", enabled: true },
{ label: "Analyse antivirus à l'upload", icon: "mdi:shield-bug-outline", enabled: true },
{ label: "Rétention corbeille 30 j", icon: "mdi:delete-clock-outline", enabled: false },
]
const AUDIT = [
{ actor: "alice@acme.com", action: "user.role.update", icon: "mdi:account-edit-outline" },
{ actor: "system", action: "migration.cutover", icon: "mdi:swap-horizontal" },
{ actor: "bob@acme.com", action: "share.link.create", icon: "mdi:link-variant" },
]
/** Aperçu statique des politiques de sécurité et du journal d'audit. */
export function AdminPoliciesDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:shield-lock-outline" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Politiques de sécurité</span>
</div>
<ul className="divide-y divide-[var(--landing-line)]">
{POLICIES.map((p) => (
<li key={p.label} className="flex items-center gap-3 px-4 py-2.5">
<span
className="flex size-8 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: p.enabled ? `${ACCENT}1f` : "var(--landing-chip)",
color: p.enabled ? ACCENT : "var(--landing-muted)",
}}
>
<Icon icon={p.icon} className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--landing-fg)]">
{p.label}
</span>
<Icon
icon={p.enabled ? "mdi:toggle-switch" : "mdi:toggle-switch-off-outline"}
className="size-6 shrink-0"
style={{ color: p.enabled ? ACCENT : "var(--landing-muted)" }}
aria-hidden
/>
</li>
))}
</ul>
<div className="border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-4">
<p className="mb-2.5 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:clipboard-text-clock-outline" className="size-3.5" aria-hidden />
Journal d&apos;audit
</p>
<ul className="flex flex-col gap-1.5">
{AUDIT.map((a, i) => (
<li key={i} className="flex items-center gap-2.5 text-sm">
<Icon icon={a.icon} className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<code className="font-mono text-[13px] font-medium text-[var(--landing-fg)]">{a.action}</code>
<span className="ml-auto truncate font-mono text-[11px] text-[var(--landing-muted)]">
{a.actor}
</span>
</li>
))}
</ul>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique 2FA, politiques fichiers et journal d&apos;audit exportable (CSV/NDJSON).
</p>
</div>
)
}

View File

@ -0,0 +1,80 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#7C3AED"
type StorageQuota = {
label: string
icon: string
usedGib: number
totalGib: number
}
const STORAGE: StorageQuota[] = [
{ label: "Mail", icon: "mdi:email-outline", usedGib: 24, totalGib: 30 },
{ label: "Drive", icon: "mdi:folder-outline", usedGib: 78, totalGib: 100 },
{ label: "Photos", icon: "mdi:image-outline", usedGib: 9, totalGib: 30 },
]
/** Aperçu statique des quotas de stockage et de coût IA appliqués par défaut. */
export function AdminQuotasDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:gauge" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Quotas par défaut</span>
<span className="ml-auto text-[11px] font-medium text-[var(--landing-muted)]">Organisation</span>
</div>
<div className="space-y-3 p-4">
{STORAGE.map((q) => {
const pct = Math.round((q.usedGib / q.totalGib) * 100)
const warn = pct >= 90
return (
<div key={q.label}>
<div className="mb-1 flex items-center gap-2 text-xs">
<Icon icon={q.icon} className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="font-medium text-[var(--landing-fg)]">{q.label}</span>
<span className="ml-auto tabular-nums text-[var(--landing-muted)]">
{q.usedGib} / {q.totalGib} Go
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--landing-chip)]">
<div
className="h-full rounded-full transition-all"
style={{ width: `${pct}%`, backgroundColor: warn ? "#E0245E" : ACCENT }}
/>
</div>
</div>
)
})}
</div>
<div className="grid grid-cols-2 gap-2 border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-4">
<div className="rounded-xl border border-[var(--landing-line)] bg-[var(--landing-chip)]/30 p-3">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:robot-outline" className="size-3.5" aria-hidden />
Coût IA / jour
</p>
<p className="mt-1 text-lg font-semibold text-[var(--landing-fg)]">2,50 </p>
<p className="text-[11px] text-[var(--landing-muted)]">par utilisateur · clés org</p>
</div>
<div className="rounded-xl border border-[var(--landing-line)] bg-[var(--landing-chip)]/30 p-3">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:web" className="size-3.5" aria-hidden />
Recherches web
</p>
<p className="mt-1 text-lg font-semibold text-[var(--landing-fg)]">50 / jour</p>
<p className="text-[11px] text-[var(--landing-muted)]">tokens API & webhooks limités</p>
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique quotas stockage, plafonds de coût IA et seuils d&apos;alerte par défaut.
</p>
</div>
)
}

View File

@ -0,0 +1,111 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#7C3AED"
type Member = {
initials: string
name: string
email: string
role: string
roleIcon: string
groups: string[]
}
const MEMBERS: Member[] = [
{
initials: "AD",
name: "Alice Dupont",
email: "alice@acme.com",
role: "Admin",
roleIcon: "mdi:shield-crown-outline",
groups: ["Direction", "IT"],
},
{
initials: "BM",
name: "Bob Martin",
email: "bob@acme.com",
role: "Utilisateur",
roleIcon: "mdi:account-outline",
groups: ["Ventes"],
},
{
initials: "CL",
name: "Chloé Leroy",
email: "chloe@acme.com",
role: "Utilisateur",
roleIcon: "mdi:account-outline",
groups: ["Marketing"],
},
{
initials: "PR",
name: "Prestataire X",
email: "ext@partenaire.io",
role: "Invité",
roleIcon: "mdi:account-clock-outline",
groups: ["Externes"],
},
]
/** Aperçu statique de l'annuaire utilisateurs avec rôles et groupes. */
export function AdminUsersDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:account-group-outline" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Utilisateurs & groupes</span>
<span className="ml-auto flex items-center gap-1 rounded-full bg-[var(--landing-chip)] px-2 py-0.5 text-[11px] font-medium text-[var(--landing-muted)]">
<Icon icon="mdi:filter-variant" className="size-3" aria-hidden />
Tous les groupes
</span>
</div>
<ul className="divide-y divide-[var(--landing-line)]">
{MEMBERS.map((m) => (
<li key={m.email} className="flex items-center gap-3 px-4 py-2.5">
<span
className="flex size-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: ACCENT }}
aria-hidden
>
{m.initials}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-[var(--landing-fg)]">{m.name}</p>
<p className="truncate text-[11px] text-[var(--landing-muted)]">{m.email}</p>
</div>
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
{m.groups.map((g) => (
<span
key={g}
className="rounded-full border border-[var(--landing-line)] bg-[var(--landing-chip)]/50 px-1.5 py-0.5 text-[10px] text-[var(--landing-muted)]"
>
{g}
</span>
))}
</div>
<span
className="flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<Icon icon={m.roleIcon} className="size-3.5" aria-hidden />
{m.role}
</span>
</li>
))}
</ul>
<div className="flex items-center gap-2 border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 px-4 py-3 text-[11px] text-[var(--landing-muted)]">
<Icon icon="mdi:cursor-default-click-outline" className="size-3.5" aria-hidden />
Actions en masse · invitation · rôle · quota · ajout à un groupe
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique rôles admin/utilisateur/invité, groupes et actions en lot.
</p>
</div>
)
}

View File

@ -0,0 +1,42 @@
"use client"
import { useLayoutEffect, type ReactNode } from "react"
import { DEMO_USER } from "@/components/demo/demo-mail-data"
import { DEMO_MAIL_ACCOUNT_ID } from "@/lib/demo/demo-mail-api-data"
import { DemoMailProvider } from "@/lib/demo/demo-mail-context"
import { ComposeProvider } from "@/lib/compose-context"
import { ScheduledMailProvider } from "@/lib/scheduled-mail-context"
import { useAccountStore } from "@/lib/stores/account-store"
import { useComposeIdentitiesStore } from "@/lib/stores/compose-identities-store"
function ProductMailDemoBootstrap() {
useLayoutEffect(() => {
useAccountStore.getState().setActiveAccountId(DEMO_MAIL_ACCOUNT_ID)
useComposeIdentitiesStore.getState().hydrateFromApi([
{
id: "product-demo-compose-identity",
accountId: DEMO_MAIL_ACCOUNT_ID,
name: DEMO_USER.name,
email: DEMO_USER.email,
defaultSignatureId: null,
signatureHtml: null,
isDefault: true,
},
])
}, [])
return null
}
/** Providers minimaux pour embarquer le compositeur mail en démo produit. */
export function ProductMailDemoShell({ children }: { children: ReactNode }) {
return (
<DemoMailProvider onReset={() => {}}>
<ProductMailDemoBootstrap />
<ComposeProvider>
<ScheduledMailProvider>
<div className="h-full w-full bg-transparent text-foreground">{children}</div>
</ScheduledMailProvider>
</ComposeProvider>
</DemoMailProvider>
)
}

View File

@ -0,0 +1,109 @@
"use client"
import { Icon } from "@iconify/react"
import { cn } from "@/lib/utils"
const ACCENT = "#F59E0B"
type ChatMessage = {
role: "user" | "assistant"
text: string
tool?: { icon: string; label: string }
}
const MESSAGES: ChatMessage[] = [
{
role: "user",
text: "Résume le fil « Atelier Nord » et prépare une réponse pour décaler à jeudi.",
},
{
role: "assistant",
tool: { icon: "mdi:email-search-outline", label: "Recherche dans Ultimail" },
text: "3 messages trouvés. Léa propose mardi 14h, Vincent a un conflit. En résumé : validation du devis OK, reste à caler la date.",
},
{
role: "assistant",
tool: { icon: "mdi:file-document-edit-outline", label: "Brouillon de réponse" },
text: "Brouillon prêt : « Bonjour Léa, jeudi 14h vous conviendrait-il ? Je joins le lien UltiMeet. »",
},
]
/** Aperçu statique du chat UltiAI avec appels d'outils et streaming. */
export function UltiaiChatDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong flex h-[22rem] flex-col overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)] sm:h-[26rem]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<img
src="/ultiai-mark.svg"
alt=""
className="size-6 object-contain"
aria-hidden
/>
<span className="text-sm font-semibold tracking-tight">UltiAI</span>
<span
className="ml-auto flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium"
style={{ backgroundColor: `${ACCENT}1f`, color: ACCENT }}
>
<Icon icon="mdi:circle" className="size-2" aria-hidden />
Contexte mail actif
</span>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-hidden p-4">
{MESSAGES.map((message, index) => (
<div
key={index}
className={cn(
"flex flex-col gap-1.5",
message.role === "user" ? "items-end" : "items-start"
)}
>
{message.tool ? (
<span className="flex items-center gap-1.5 rounded-full border border-[var(--landing-line)] bg-[var(--landing-chip)] px-2.5 py-1 text-[11px] font-medium text-[var(--landing-muted)]">
<Icon icon={message.tool.icon} className="size-3.5" style={{ color: ACCENT }} aria-hidden />
{message.tool.label}
</span>
) : null}
<div
className={cn(
"max-w-[85%] rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",
message.role === "user"
? "rounded-br-md text-[#202124]"
: "rounded-bl-md bg-[var(--landing-chip)] text-[var(--landing-fg)]"
)}
style={message.role === "user" ? { backgroundColor: ACCENT } : undefined}
>
{message.text}
</div>
</div>
))}
<div className="flex items-center gap-1.5 self-start rounded-2xl rounded-bl-md bg-[var(--landing-chip)] px-3.5 py-3">
<span className="size-1.5 animate-pulse rounded-full bg-[var(--landing-muted)]" />
<span className="size-1.5 animate-pulse rounded-full bg-[var(--landing-muted)] [animation-delay:120ms]" />
<span className="size-1.5 animate-pulse rounded-full bg-[var(--landing-muted)] [animation-delay:240ms]" />
</div>
</div>
<div className="border-t border-[var(--landing-line)] p-3">
<div className="flex items-center gap-2 rounded-full border border-[var(--landing-line)] bg-[var(--landing-bg)] px-3.5 py-2">
<span className="flex-1 truncate text-sm text-[var(--landing-muted)]">
Demandez quelque chose à UltiAI
</span>
<span
className="flex size-7 items-center justify-center rounded-full text-[#202124]"
style={{ backgroundColor: ACCENT }}
>
<Icon icon="mdi:arrow-up" className="size-4" aria-hidden />
</span>
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique l&apos;assistant lit le contexte et propose, rien n&apos;est envoyé.
</p>
</div>
)
}

View File

@ -0,0 +1,106 @@
"use client"
import { Icon } from "@iconify/react"
import { ULTIAI_TOOL_GROUPS } from "@/lib/ai/ultiai-tool-groups"
import { cn } from "@/lib/utils"
const ACCENT = "#F59E0B"
const TOOL_ICONS: Record<string, string> = {
mail: "mdi:email-outline",
drive: "mdi:folder-outline",
contacts: "mdi:card-account-details-outline",
agenda: "mdi:calendar-outline",
search: "mdi:magnify",
web_search: "mdi:web",
}
// Groupes désactivés dans l'aperçu pour illustrer les permissions fines.
const DISABLED_GROUPS = new Set(["web_search"])
const TRACE = [
{ icon: "mdi:email-search-outline", label: "mail.search", detail: "« facture » · 3 résultats" },
{ icon: "mdi:label-outline", label: "mail.addLabel", detail: "Comptabilité" },
{ icon: "mdi:folder-move-outline", label: "drive.move", detail: "→ /Factures/2026" },
]
/** Aperçu statique des groupes d'outils MCP et d'une trace d'exécution. */
export function UltiaiToolsDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:tools" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Outils MCP exposés</span>
<span className="ml-auto text-[11px] font-medium text-[var(--landing-muted)]">
X-Ulti-Enabled-Tools
</span>
</div>
<div className="grid grid-cols-2 gap-2 p-4">
{ULTIAI_TOOL_GROUPS.map((group) => {
const enabled = !DISABLED_GROUPS.has(group.id)
return (
<div
key={group.id}
className={cn(
"flex items-center gap-2.5 rounded-xl border px-3 py-2.5 transition-colors",
enabled
? "border-[var(--landing-line)] bg-[var(--landing-chip)]/40"
: "border-dashed border-[var(--landing-line)] opacity-50"
)}
>
<span
className="flex size-8 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: enabled ? `${ACCENT}1f` : "transparent",
color: enabled ? ACCENT : "var(--landing-muted)",
}}
>
<Icon icon={TOOL_ICONS[group.id] ?? "mdi:tools"} className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label}
</span>
<Icon
icon={enabled ? "mdi:toggle-switch" : "mdi:toggle-switch-off-outline"}
className="size-5 shrink-0"
style={{ color: enabled ? ACCENT : "var(--landing-muted)" }}
aria-hidden
/>
</div>
)
})}
</div>
<div className="border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-4">
<p className="mb-2.5 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:console" className="size-3.5" aria-hidden />
Trace d&apos;exécution
</p>
<ul className="flex flex-col gap-1.5">
{TRACE.map((step, index) => (
<li key={index} className="flex items-center gap-2.5 text-sm">
<Icon
icon="mdi:check-circle"
className="size-4 shrink-0"
style={{ color: ACCENT }}
aria-hidden
/>
<Icon icon={step.icon} className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<code className="font-mono text-[13px] font-medium text-[var(--landing-fg)]">
{step.label}
</code>
<span className="truncate text-xs text-[var(--landing-muted)]">{step.detail}</span>
</li>
))}
</ul>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique chaque groupe d&apos;outils s&apos;active ou se coupe par compte.
</p>
</div>
)
}

View File

@ -0,0 +1,97 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#F59E0B"
type TriageItem = {
sender: string
subject: string
label: string
labelColor: string
reason: string
}
const ITEMS: TriageItem[] = [
{
sender: "Stripe",
subject: "Votre reçu de paiement — 149 €",
label: "Comptabilité",
labelColor: "#34A853",
reason: "Reçu de paiement → archivé et étiqueté.",
},
{
sender: "Léa Fontaine",
subject: "Re: Atelier Nord — proposition de date",
label: "À traiter",
labelColor: "#EA4335",
reason: "Demande de réponse → priorité haute.",
},
{
sender: "Newsletter Produit",
subject: "Les nouveautés de juin sont là",
label: "Veille",
labelColor: "#4285F4",
reason: "Contenu informatif → boîte secondaire.",
},
]
/** Aperçu statique du tri IA : règle LLM qui classe et explique son choix. */
export function UltiaiTriageDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:creation-outline" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Règle de tri IA</span>
</div>
<div className="border-b border-[var(--landing-line)] bg-[var(--landing-chip)]/30 px-4 py-3">
<p className="text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
Prompt de la règle
</p>
<p className="mt-1 text-sm leading-relaxed text-[var(--landing-fg)]">
« Classe chaque mail entrant selon le contexte de mon activité : comptabilité,
à traiter ou veille. »
</p>
</div>
<ul className="flex flex-col divide-y divide-[var(--landing-line)]">
{ITEMS.map((item) => (
<li key={item.subject} className="flex items-start gap-3 px-4 py-3">
<span
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
style={{ backgroundColor: item.labelColor }}
aria-hidden
>
{item.sender.charAt(0)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-[var(--landing-fg)]">
{item.sender}
</span>
<span
className="ml-auto shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{ backgroundColor: `${item.labelColor}1f`, color: item.labelColor }}
>
{item.label}
</span>
</div>
<p className="truncate text-sm text-[var(--landing-muted)]">{item.subject}</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--landing-muted)]">
<Icon icon="mdi:robot-outline" className="size-3.5 shrink-0" style={{ color: ACCENT }} aria-hidden />
{item.reason}
</p>
</div>
</li>
))}
</ul>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique le LLM classe à la réception selon votre prompt.
</p>
</div>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
import { ScaledPreviewIframe } from "@/components/landing/product/scaled-preview-iframe"
// Viewport logique « grand écran » : l'agenda est rendu plus large puis réduit
// pour fit dans le cadre — dézoome un peu le contenu (plus de calendrier visible).
const AGENDA_DEMO_VIEWPORT_WIDTH = 1040
const AGENDA_DEMO_VIEWPORT_HEIGHT = 700
const AGENDA_DEMO_ASPECT_RATIO =
AGENDA_DEMO_VIEWPORT_WIDTH / AGENDA_DEMO_VIEWPORT_HEIGHT
export function UlticalAgendaDemo() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.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()
}, [])
return (
<div ref={ref}>
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/agenda"
fullscreenHref="/demo/agenda"
hint="Démo interactive — naviguez, créez et déplacez des événements. Zéro rétention."
aspectRatio={AGENDA_DEMO_ASPECT_RATIO}
>
<div className="absolute inset-0 bg-[var(--landing-bg)]">
<ScaledPreviewIframe
src="/demo/agenda"
title="Démo calendrier UltiCal"
viewportWidth={AGENDA_DEMO_VIEWPORT_WIDTH}
viewportHeight={AGENDA_DEMO_VIEWPORT_HEIGHT}
fit="cover"
ready={visible}
/>
</div>
</ProductDemoFrame>
</div>
)
}

View File

@ -0,0 +1,48 @@
"use client"
import { useMemo } from "react"
import { Icon } from "@iconify/react"
import { addDays, addHours, setHours, setMinutes, startOfDay } from "date-fns"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import type { ParsedCalendarInvitation } from "@/lib/calendar-invitation"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
function buildDemoInvitation(): ParsedCalendarInvitation {
const start = setMinutes(setHours(addDays(startOfDay(new Date()), 2), 14), 0)
return {
summary: "Appel client — Atelier Nord",
start,
end: addHours(start, 1),
organizer: { name: "Léa Fontaine", email: "lea.fontaine@atelier-nord.fr" },
attendees: [
{ name: "Camille Visiteur", email: "camille@demo.ulti" },
{ name: "Vincent Morel", email: "vincent.morel@gmail.com" },
{ name: "Thomas Giraud", email: "thomas.giraud@proton.me" },
],
location: "UltiMeet",
description:
"Présentation UltiCal + intégration UltiMeet pour leur équipe.",
conferenceProvider: "ultimeet",
}
}
/** Aperçu statique d'une invitation détectée dans le mail — réutilise le composant réel. */
export function UlticalInvitationDemo() {
const invitation = useMemo(buildDemoInvitation, [])
return (
<div className="flex flex-col gap-3">
<div className="flex min-h-[22rem] items-center justify-center rounded-2xl bg-[var(--landing-chip)]/35 px-3 py-6 sm:min-h-[26rem] sm:px-4 sm:py-8">
<CalendarInvitationPreview
invitation={invitation}
calendarAppName={ULTICAL_APP_NAME}
className="w-full max-w-[520px] shadow-[0_24px_60px_-20px_rgba(30,40,90,0.35)]"
/>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Invitation détectée dans Ultimail RSVP en un clic, rien n&apos;est envoyé.
</p>
</div>
)
}

View File

@ -0,0 +1,135 @@
"use client"
import { Icon } from "@iconify/react"
import { addDays, format, startOfDay } from "date-fns"
import { fr } from "date-fns/locale"
import { cn } from "@/lib/utils"
const ACCENT = "#FBBC04"
const SLOTS = ["09:00", "09:30", "10:00", "11:30", "14:00", "15:30"] as const
const SELECTED_SLOT = "10:00"
function buildDays() {
const base = startOfDay(new Date())
return Array.from({ length: 5 }, (_, index) => {
const date = addDays(base, index + 1)
return {
key: format(date, "yyyy-MM-dd"),
weekday: format(date, "EEE", { locale: fr }),
day: format(date, "d", { locale: fr }),
busy: index === 2,
}
})
}
/** Page de réservation type Calendly — aperçu statique sur les disponibilités réelles. */
export function UlticalSchedulingDemo() {
const days = buildDays()
const selectedDay = days.find((day) => !day.busy) ?? days[0]!
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="flex flex-col gap-4 border-b border-[var(--landing-line)] p-5 sm:border-b-0 sm:border-r">
<div className="flex items-center gap-2 text-xs font-medium text-[var(--landing-muted)]">
<img
src="/agenda-mark.svg"
alt=""
className="size-5 object-contain"
aria-hidden
/>
Camille Visiteur
</div>
<h3 className="text-lg font-semibold tracking-tight">
Rendez-vous découverte
</h3>
<ul className="flex flex-col gap-2.5 text-sm text-[var(--landing-muted)]">
<li className="flex items-center gap-2.5">
<Icon icon="mdi:clock-outline" className="size-4 shrink-0" aria-hidden />
30 minutes
</li>
<li className="flex items-center gap-2.5">
<Icon icon="mdi:video-outline" className="size-4 shrink-0" aria-hidden />
Visio UltiMeet (lien généré)
</li>
<li className="flex items-center gap-2.5">
<Icon icon="mdi:earth" className="size-4 shrink-0" aria-hidden />
Europe/Paris (GMT+2)
</li>
</ul>
<p className="mt-auto text-xs leading-relaxed text-[var(--landing-muted)]">
Les créneaux occupés sont masqués automatiquement d&apos;après votre
agenda CalDAV aucune double réservation.
</p>
</div>
<div className="flex flex-col gap-4 p-5">
<p className="text-sm font-medium text-[var(--landing-fg)]">
Choisissez un créneau
</p>
<div className="grid grid-cols-5 gap-1.5">
{days.map((day) => {
const active = day.key === selectedDay.key
return (
<div
key={day.key}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg border px-1 py-2 text-center transition-colors",
active
? "border-transparent text-[#202124]"
: "border-[var(--landing-line)] text-[var(--landing-fg)]",
day.busy && "opacity-40"
)}
style={active ? { backgroundColor: ACCENT } : undefined}
>
<span className="text-[10px] uppercase">{day.weekday}</span>
<span className="text-sm font-semibold tabular-nums">{day.day}</span>
</div>
)
})}
</div>
<div className="grid grid-cols-3 gap-2">
{SLOTS.map((slot) => {
const selected = slot === SELECTED_SLOT
return (
<button
key={slot}
type="button"
tabIndex={-1}
className={cn(
"h-9 rounded-lg border text-sm font-medium tabular-nums transition-colors",
selected
? "border-transparent text-[#202124]"
: "border-[var(--landing-line)] text-[var(--landing-fg)] hover:bg-[var(--landing-chip)]"
)}
style={selected ? { backgroundColor: ACCENT } : undefined}
>
{slot}
</button>
)
})}
</div>
<button
type="button"
tabIndex={-1}
className="mt-1 flex h-10 items-center justify-center gap-2 rounded-full text-sm font-semibold text-[#202124]"
style={{ backgroundColor: ACCENT }}
>
Confirmer le rendez-vous
<Icon icon="mdi:arrow-right" className="size-4" aria-hidden />
</button>
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Page de réservation publique disponibilités free/busy, rien n&apos;est réservé.
</p>
</div>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
import { ScaledPreviewIframe } from "@/components/landing/product/scaled-preview-iframe"
// Viewport logique « grand écran » : l'annuaire est rendu plus large puis réduit
// pour fit dans le cadre — dézoome un peu le contenu (plus de fiches visibles).
const DIRECTORY_DEMO_VIEWPORT_WIDTH = 1040
const DIRECTORY_DEMO_VIEWPORT_HEIGHT = 700
const DIRECTORY_DEMO_ASPECT_RATIO =
DIRECTORY_DEMO_VIEWPORT_WIDTH / DIRECTORY_DEMO_VIEWPORT_HEIGHT
export function UlticardsDirectoryDemo() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.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()
}, [])
return (
<div ref={ref}>
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/contacts"
fullscreenHref="/demo/contacts"
hint="Démo interactive — parcourez, créez et éditez des fiches. Zéro rétention."
aspectRatio={DIRECTORY_DEMO_ASPECT_RATIO}
>
<div className="absolute inset-0 bg-[var(--landing-bg)]">
<ScaledPreviewIframe
src="/demo/contacts"
title="Démo carnet d'adresses UltiCards"
viewportWidth={DIRECTORY_DEMO_VIEWPORT_WIDTH}
viewportHeight={DIRECTORY_DEMO_VIEWPORT_HEIGHT}
fit="cover"
ready={visible}
/>
</div>
</ProductDemoFrame>
</div>
)
}

View File

@ -0,0 +1,134 @@
"use client"
import { Icon } from "@iconify/react"
import { cn } from "@/lib/utils"
const ACCENT = "#4285F4"
type DiscoveredProfile = {
name: string
email: string
messages: number
enriched: string
status: "suggested" | "filtered"
reason: string
}
const PROFILES: DiscoveredProfile[] = [
{
name: "Léa Fontaine",
email: "lea.fontaine@atelier-nord.fr",
messages: 18,
enriched: "Directrice · Atelier Nord",
status: "suggested",
reason: "Société & poste extraits de la signature",
},
{
name: "Vincent Morel",
email: "vincent.morel@gmail.com",
messages: 7,
enriched: "+33 6 12 34 56 78",
status: "suggested",
reason: "Téléphone détecté dans les échanges",
},
{
name: "Newsletter Produit",
email: "news@produit.io",
messages: 42,
enriched: "Liste de diffusion",
status: "filtered",
reason: "Écarté automatiquement (mailing-list)",
},
]
/** Aperçu statique de la découverte de contacts depuis le mail + enrichissement IA. */
export function UlticardsDiscoveryDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:account-search-outline" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Découverte de contacts</span>
<span className="ml-auto flex items-center gap-1.5 text-[11px] font-medium text-[var(--landing-muted)]">
<Icon icon="mdi:email-sync-outline" className="size-3.5" aria-hidden />
3 comptes scannés
</span>
</div>
<div className="border-b border-[var(--landing-line)] bg-[var(--landing-chip)]/30 px-4 py-2.5">
<div className="flex items-center justify-between text-[11px] font-medium text-[var(--landing-muted)]">
<span className="flex items-center gap-1.5">
<Icon icon="mdi:radar" className="size-3.5" style={{ color: ACCENT }} aria-hidden />
Analyse des messages
</span>
<span className="tabular-nums">1 248 / 1 248</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--landing-line)]">
<span className="block h-full w-full rounded-full" style={{ backgroundColor: ACCENT }} />
</div>
</div>
<ul className="flex flex-col divide-y divide-[var(--landing-line)]">
{PROFILES.map((profile) => {
const filtered = profile.status === "filtered"
return (
<li
key={profile.email}
className={cn("flex items-start gap-3 px-4 py-3", filtered && "opacity-60")}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: filtered ? "var(--landing-muted)" : ACCENT }}
aria-hidden
>
{profile.name.charAt(0)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-[var(--landing-fg)]">
{profile.name}
</span>
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-[var(--landing-muted)]">
{profile.messages} messages
</span>
</div>
<p className="truncate text-xs text-[var(--landing-muted)]">{profile.email}</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-[var(--landing-muted)]">
<Icon
icon={filtered ? "mdi:filter-remove-outline" : "mdi:auto-fix"}
className="size-3.5 shrink-0"
style={{ color: filtered ? "var(--landing-muted)" : ACCENT }}
aria-hidden
/>
<span className="font-medium text-[var(--landing-fg)]">{profile.enriched}</span>
<span aria-hidden>·</span>
{profile.reason}
</p>
</div>
{filtered ? (
<Icon
icon="mdi:close-circle-outline"
className="mt-1 size-5 shrink-0 text-[var(--landing-muted)]"
aria-hidden
/>
) : (
<span
className="mt-0.5 flex h-7 shrink-0 items-center gap-1 rounded-full px-2.5 text-[11px] font-semibold text-white"
style={{ backgroundColor: ACCENT }}
>
<Icon icon="mdi:plus" className="size-3.5" aria-hidden />
Ajouter
</span>
)}
</li>
)
})}
</ul>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique la découverte tourne sur votre infrastructure, rien n&apos;est partagé.
</p>
</div>
)
}

View File

@ -0,0 +1,131 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#4285F4"
type SourceCard = {
origin: string
originIcon: string
fields: { icon: string; value: string; kept: boolean }[]
}
const SOURCES: SourceCard[] = [
{
origin: "Importé de Google",
originIcon: "logos:google-icon",
fields: [
{ icon: "mdi:email-outline", value: "marc.dubois@example.com", kept: true },
{ icon: "mdi:phone-outline", value: "—", kept: false },
{ icon: "mdi:office-building-outline", value: "Studio Lumen", kept: true },
],
},
{
origin: "Découvert dans le mail",
originIcon: "mdi:email-search-outline",
fields: [
{ icon: "mdi:email-outline", value: "m.dubois@studio-lumen.fr", kept: true },
{ icon: "mdi:phone-outline", value: "+33 6 98 76 54 32", kept: true },
{ icon: "mdi:office-building-outline", value: "—", kept: false },
],
},
]
const MERGED = [
{ icon: "mdi:email-outline", value: "marc.dubois@example.com" },
{ icon: "mdi:email-outline", value: "m.dubois@studio-lumen.fr" },
{ icon: "mdi:phone-outline", value: "+33 6 98 76 54 32" },
{ icon: "mdi:office-building-outline", value: "Studio Lumen" },
]
/** Aperçu statique de la fusion de doublons — deux fiches sources combinées en une. */
export function UlticardsMergeDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<Icon icon="mdi:call-merge" className="size-5" style={{ color: ACCENT }} aria-hidden />
<span className="text-sm font-semibold tracking-tight">Doublon détecté</span>
<span className="ml-auto rounded-full bg-[var(--landing-chip)] px-2 py-0.5 text-[11px] font-medium text-[var(--landing-muted)]">
Marc Dubois
</span>
</div>
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2">
{SOURCES.map((card) => (
<div
key={card.origin}
className="rounded-xl border border-dashed border-[var(--landing-line)] bg-[var(--landing-chip)]/30 p-3"
>
<p className="mb-2 flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon={card.originIcon} className="size-3.5" aria-hidden />
{card.origin}
</p>
<ul className="flex flex-col gap-1.5">
{card.fields.map((field, index) => (
<li
key={index}
className="flex items-center gap-2 text-xs"
style={{ opacity: field.kept ? 1 : 0.4 }}
>
<Icon icon={field.icon} className="size-3.5 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="truncate text-[var(--landing-fg)]">{field.value}</span>
{field.kept ? (
<Icon
icon="mdi:check-circle"
className="ml-auto size-3.5 shrink-0"
style={{ color: ACCENT }}
aria-hidden
/>
) : null}
</li>
))}
</ul>
</div>
))}
</div>
<div className="flex items-center justify-center gap-2 pb-1 text-[var(--landing-muted)]">
<Icon icon="mdi:arrow-down" className="size-4" aria-hidden />
<span className="text-[11px] font-medium uppercase tracking-wide">Fiche fusionnée</span>
<Icon icon="mdi:arrow-down" className="size-4" aria-hidden />
</div>
<div className="border-t border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-4">
<div className="flex items-center gap-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-full text-base font-semibold text-white"
style={{ backgroundColor: ACCENT }}
aria-hidden
>
M
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--landing-fg)]">Marc Dubois</p>
<p className="truncate text-xs text-[var(--landing-muted)]">Studio Lumen</p>
</div>
<span
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-full px-3 text-xs font-semibold text-white"
style={{ backgroundColor: ACCENT }}
>
<Icon icon="mdi:call-merge" className="size-4" aria-hidden />
Fusionner
</span>
</div>
<ul className="mt-3 grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{MERGED.map((field, index) => (
<li key={index} className="flex items-center gap-2 text-xs">
<Icon icon={field.icon} className="size-3.5 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="truncate text-[var(--landing-fg)]">{field.value}</span>
</li>
))}
</ul>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique import vCard, CSV ou Google, puis fusion en gardant le meilleur de chaque fiche.
</p>
</div>
)
}

View File

@ -0,0 +1,66 @@
"use client"
import { useLayoutEffect, useRef, useState } from "react"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
import { ScaledPreviewIframe } from "@/components/landing/product/scaled-preview-iframe"
// Viewport logique réduit : l'explorateur est rendu plus petit puis agrandi
// dans le cadre — zoom ~25 % par rapport à la référence 1440×900.
export const ULTIDRIVE_BROWSER_DEMO_VIEWPORT_WIDTH = 1080
export const ULTIDRIVE_BROWSER_DEMO_VIEWPORT_HEIGHT = 675
export const ULTIDRIVE_BROWSER_DEMO_ASPECT_RATIO =
ULTIDRIVE_BROWSER_DEMO_VIEWPORT_WIDTH / ULTIDRIVE_BROWSER_DEMO_VIEWPORT_HEIGHT
function isInDemoViewport(node: HTMLElement, margin = 120): boolean {
const rect = node.getBoundingClientRect()
return rect.top < window.innerHeight + margin && rect.bottom > -margin
}
export function UltidriveBrowserDemo() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useLayoutEffect(() => {
const node = ref.current
if (!node) return
if (isInDemoViewport(node)) {
setVisible(true)
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()
}, [])
return (
<div ref={ref}>
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/drive"
fullscreenHref="/demo/drive"
hint="Démo interactive — parcourez, uploadez, prévisualisez. Zéro rétention."
aspectRatio={ULTIDRIVE_BROWSER_DEMO_ASPECT_RATIO}
>
<div className="absolute inset-0 bg-[var(--landing-bg)]">
<ScaledPreviewIframe
src="/demo/drive"
title="Démo explorateur UltiDrive"
viewportWidth={ULTIDRIVE_BROWSER_DEMO_VIEWPORT_WIDTH}
viewportHeight={ULTIDRIVE_BROWSER_DEMO_VIEWPORT_HEIGHT}
fit="cover"
ready={visible}
/>
</div>
</ProductDemoFrame>
</div>
)
}

View File

@ -0,0 +1,55 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
import { ScaledPreviewIframe } from "@/components/landing/product/scaled-preview-iframe"
// Viewport logique « grand écran » réduit de 20 % (1440→1200) pour zoomer le
// contenu de +20 % sans coupe. Le ratio (1.6) reste celui du parent.
export const ULTIDOCS_DEMO_VIEWPORT_WIDTH = 1200
export const ULTIDOCS_DEMO_VIEWPORT_HEIGHT = 750
export const ULTIDOCS_DEMO_ASPECT_RATIO =
ULTIDOCS_DEMO_VIEWPORT_WIDTH / ULTIDOCS_DEMO_VIEWPORT_HEIGHT
export function UltidriveDocsDemo() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.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()
}, [])
return (
<div ref={ref}>
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/drive/docs/demo/edit"
fullscreenHref="/demo/docs"
hint="Éditeur UltiDocs réel — mise en forme, tableaux et images. Rien n'est sauvegardé."
aspectRatio={ULTIDOCS_DEMO_ASPECT_RATIO}
>
<div className="absolute inset-0 bg-[var(--landing-bg)]">
<ScaledPreviewIframe
src="/demo/docs"
title="Démo éditeur UltiDocs"
viewportWidth={ULTIDOCS_DEMO_VIEWPORT_WIDTH}
viewportHeight={ULTIDOCS_DEMO_VIEWPORT_HEIGHT}
fit="cover"
ready={visible}
/>
</div>
</ProductDemoFrame>
</div>
)
}

View File

@ -0,0 +1,16 @@
"use client"
import { Icon } from "@iconify/react"
import { UltidriveSharePreview } from "@/components/landing/product/product-demos/ultidrive-share-preview"
export function UltidriveShareDemo() {
return (
<div className="flex flex-col gap-3">
<UltidriveSharePreview />
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Partage par lien, utilisateurs et groupes permissions héritées du dossier parent.
</p>
</div>
)
}

View File

@ -0,0 +1,166 @@
"use client"
import {
Building2,
Globe,
Link2,
Shield,
Trash2,
UserRound,
} from "lucide-react"
import {
DRIVE_BTN_GHOST,
DRIVE_BTN_PRIMARY,
DRIVE_DIALOG_CONTENT,
DRIVE_DIALOG_FOOTER,
DRIVE_DIALOG_HEADER,
DRIVE_FIELD_CLASS,
DRIVE_TEXT_PRIMARY,
DRIVE_TEXT_SECONDARY,
DRIVE_TEXT_TITLE,
} from "@/lib/drive/drive-dialog-styles"
import { cn } from "@/lib/utils"
const EXISTING_SHARES = [
{
id: "alice",
label: "Alice Martin",
role: "Éditeur",
icon: UserRound,
},
{
id: "team",
label: "Équipe Produit",
role: "Lecteur",
icon: Building2,
},
{
id: "link",
label: "Lien public",
role: "Lecteur",
icon: Link2,
password: true,
},
] as const
/** Modale de partage flottante — aperçu statique, sans overlay ni fenêtre navigateur. */
export function UltidriveSharePreview() {
return (
<div className="flex min-h-[22rem] items-center justify-center rounded-2xl bg-[var(--landing-chip)]/35 py-6 sm:min-h-[26rem] sm:py-8">
<div
className={cn(
"w-full max-w-[480px] shadow-[0_24px_60px_-20px_rgba(30,40,90,0.35)]",
DRIVE_DIALOG_CONTENT,
"mx-3 rounded-xl sm:mx-4"
)}
role="dialog"
aria-labelledby="ultidrive-share-preview-title"
>
<div className={cn(DRIVE_DIALOG_HEADER, "pb-4")}>
<h3
id="ultidrive-share-preview-title"
className={cn("text-base font-medium", DRIVE_TEXT_TITLE)}
>
Partager « Roadmap Q3 Produit »
</h3>
</div>
<div className="max-h-[min(52vh,380px)] space-y-5 overflow-y-auto px-6 py-4">
<div className="space-y-2">
<input
type="email"
readOnly
value=""
placeholder="Ajouter des personnes par e-mail"
className={cn(DRIVE_FIELD_CLASS, "h-10 w-full px-3")}
aria-label="Ajouter des personnes par e-mail"
/>
</div>
<div className="space-y-3">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Personnes</p>
<ul className="space-y-0.5">
{EXISTING_SHARES.map((share) => {
const ShareIcon = share.icon
return (
<li
key={share.id}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-[#f1f3f4] dark:hover:bg-[#3c4043]/50"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[#e8f0fe] text-[#1967d2] dark:bg-[#1a377a]/50 dark:text-[#8ab4f8]">
<ShareIcon className="h-3.5 w-3.5" aria-hidden />
</div>
<p className={cn("min-w-0 flex-1 truncate text-sm", DRIVE_TEXT_PRIMARY)}>
{share.label}
</p>
{"password" in share && share.password ? (
<Shield
className="h-3.5 w-3.5 shrink-0 text-[#5f6368] dark:text-[#9aa0a6]"
aria-label="Mot de passe"
/>
) : null}
<span className="inline-flex shrink-0 items-center rounded-full bg-[#e8eaed] px-2 py-0.5 text-[11px] font-medium text-[#3c4043] dark:bg-[#3c4043] dark:text-[#e8eaed]">
{share.role}
</span>
<button
type="button"
className={cn(DRIVE_BTN_GHOST, "size-7 opacity-60")}
aria-label="Supprimer le partage"
tabIndex={-1}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
)
})}
</ul>
</div>
<div className="space-y-2 border-t border-[#e8eaed] pt-4 dark:border-[#3c4043]">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Accès général</p>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#e6f4ea] text-[#188038] dark:bg-[#1e3a2f]/50 dark:text-[#81c995]">
<Globe className="h-4 w-4" aria-hidden />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className={cn("text-sm font-medium", DRIVE_TEXT_PRIMARY)}>Lien public</p>
<p className={cn("text-xs leading-relaxed", DRIVE_TEXT_SECONDARY)}>
Toute personne disposant du lien peut consulter l&apos;élément.
</p>
</div>
<span className="inline-flex h-8 shrink-0 items-center rounded-lg bg-[#f1f3f4] px-2.5 text-xs font-medium text-[#3c4043] dark:bg-[#35363a] dark:text-[#e8eaed]">
Lecteur
</span>
</div>
</div>
</div>
<div
className={cn(
DRIVE_DIALOG_FOOTER,
"flex flex-row flex-wrap items-center justify-between gap-3 sm:gap-4"
)}
>
<button
type="button"
className="inline-flex h-9 shrink-0 items-center gap-2 rounded-full border border-[#dadce0] bg-white px-4 text-sm font-medium text-[#1a73e8] dark:border-[#5f6368]/40 dark:bg-transparent dark:text-[#8ab4f8]"
tabIndex={-1}
>
<Link2 className="h-4 w-4" aria-hidden />
Copier le lien
</button>
<button
type="button"
className={cn(
DRIVE_BTN_PRIMARY,
"inline-flex h-9 shrink-0 items-center rounded-full px-6 text-sm"
)}
tabIndex={-1}
>
Terminé
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
"use client"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
import { WorkflowFlowPreview } from "@/components/landing/product/product-demos/workflow-flow-preview"
export function UltimailAutomationDemo() {
return (
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/mail/settings/automation"
hint="Flux no-code réel — déclencheurs, conditions et actions."
heightClass="h-[15rem] sm:h-[17rem]"
>
<div className="absolute inset-0 overflow-hidden bg-background">
<WorkflowFlowPreview />
</div>
</ProductDemoFrame>
)
}

View File

@ -0,0 +1,74 @@
"use client"
import { useLayoutEffect, useRef } from "react"
import { Icon } from "@iconify/react"
import { ComposeWindow } from "@/components/gmail/compose/compose-window"
import { ProductMailDemoShell } from "@/components/landing/product/product-demos/product-mail-demo-shell"
import {
type ComposeOpenPreset,
useComposeActions,
useComposeWindows,
} from "@/lib/compose-context"
const COMPOSE_PRESET: ComposeOpenPreset = {
from: {
name: "Camille Visiteur",
email: "camille@demo.ulti",
defaultSignatureId: null,
},
to: [{ name: "Alice Martin", email: "alice.martin@yahoo.fr" }],
subject: "Proposition de rendez-vous — mardi 14h",
bodyHtml:
"<p>Bonjour Alice,</p><p>Suite à notre échange, je vous propose un créneau <strong>mardi à 14h</strong> pour finaliser le projet. Dites-moi si cela vous convient.</p><p>— Cordialement,<br>Jean</p>",
autoInsertSignature: false,
focusToOnMount: false,
focusBodyOnMount: false,
placement: "dock",
}
function ProductComposeDemoInner() {
const { openComposeWithInitial } = useComposeActions()
const { composeWindows } = useComposeWindows()
const seeded = useRef(false)
useLayoutEffect(() => {
if (seeded.current) return
seeded.current = true
openComposeWithInitial(COMPOSE_PRESET)
}, [openComposeWithInitial])
useLayoutEffect(() => {
if (!seeded.current || composeWindows.length > 0) return
openComposeWithInitial(COMPOSE_PRESET)
}, [composeWindows.length, openComposeWithInitial])
const compose = composeWindows[0]
if (!compose) {
return (
<div className="flex min-h-[34rem] items-center justify-center text-sm text-[var(--landing-muted)] sm:min-h-[36rem]">
Chargement du compositeur
</div>
)
}
return (
<div className="flex min-h-[34rem] items-end justify-center overflow-visible px-2 pb-6 pt-4 sm:min-h-[36rem] sm:px-4">
<ComposeWindow compose={compose} />
</div>
)
}
export function UltimailComposeDemo() {
return (
<div className="flex flex-col gap-3">
<ProductMailDemoShell>
<ProductComposeDemoInner />
</ProductMailDemoShell>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Compositeur réel mise en forme, PJ et envoi programmé. Rien n&apos;est envoyé.
</p>
</div>
)
}

View File

@ -0,0 +1,57 @@
import type { RuleEditorState } from "@/lib/mail-automation/types"
/** Règle pré-remplie pour la démo produit Ultimail (éditeur no-code). */
export function createUltimailProductDemoRuleState(): RuleEditorState {
const startId = "product-demo-start"
const condId = "product-demo-cond"
const actionsId = "product-demo-actions"
const endId = "product-demo-end"
return {
name: "Tri factures → Comptabilité",
priority: 10,
is_active: true,
rule_kind: "rule",
workflow: {
version: 1,
kind: "rule",
triggers: {
operator: "or",
groups: [
{
operator: "and",
items: [{ type: "message_received" }],
},
],
},
variables: [],
nodes: [
{ id: startId, type: "start", position: { x: 40, y: 160 }, data: {} },
{
id: condId,
type: "condition",
position: { x: 240, y: 140 },
data: { field: "subject", operator: "contains", value: "facture" },
},
{
id: actionsId,
type: "actions",
position: { x: 480, y: 120 },
data: {
actions: [
{ type: "label", value: "Comptabilité" },
{ type: "archive", value: "" },
],
},
},
{ id: endId, type: "end", position: { x: 720, y: 160 }, data: {} },
],
edges: [
{ id: "product-demo-e1", source: startId, target: condId },
{ id: "product-demo-e2", source: condId, target: actionsId, sourceHandle: "true" },
{ id: "product-demo-e3", source: actionsId, target: endId },
{ id: "product-demo-e4", source: condId, target: endId, sourceHandle: "false" },
],
},
}
}

View File

@ -0,0 +1,48 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { ProductDemoFrame } from "@/components/landing/product/product-demo-frame"
export function UltimailInboxDemo() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const node = ref.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()
}, [])
return (
<div ref={ref}>
<ProductDemoFrame
fakeUrl="suite.votre-domaine.fr/mail/inbox"
fullscreenHref="/demo/mail/inbox"
hint="Démo interactive — lisez, archivez, répondez. Zéro rétention."
>
{visible ? (
<iframe
src="/demo/mail/inbox"
title="Démo boîte mail Ultimail"
loading="lazy"
className="absolute inset-0 h-full w-full"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-[var(--landing-muted)]">
Chargement de la démo
</div>
)}
</ProductDemoFrame>
</div>
)
}

View File

@ -0,0 +1,110 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#34A853"
const MESSAGES = [
{ name: "Léa Fontaine", color: "#34A853", text: "Je partage le planning du sprint." },
{ name: "Vincent Morel", color: "#4285F4", text: "Parfait, on valide la date de jeudi ?" },
] as const
const REACTIONS = ["👍", "🎉", "👏", "❤️"] as const
const TRANSCRIPT = [
{ speaker: "Léa", text: "On cale la démo client pour jeudi 14 h." },
{ speaker: "Vincent", text: "Je prépare les slides d'ici mercredi." },
] as const
/** Aperçu statique des outils en réunion UltiMeet — partage d'écran, chat, transcription IA. */
export function UltimeetCollabDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<div className="relative flex min-h-[12rem] flex-col border-b border-[var(--landing-line)] bg-[var(--landing-bg)] sm:border-b-0 sm:border-r">
<div className="flex items-center gap-2 px-3 py-2 text-[11px] font-medium text-[var(--landing-muted)]">
<span className="flex items-center gap-1.5 rounded-full bg-[#EA4335]/15 px-2 py-0.5 text-[#EA4335]">
<span className="size-1.5 rounded-full bg-[#EA4335]" aria-hidden />
REC
</span>
<span className="flex items-center gap-1">
<Icon icon="mdi:monitor-share" className="size-3.5" style={{ color: ACCENT }} aria-hidden />
Léa partage son écran
</span>
</div>
<div className="relative mx-3 mb-3 flex-1 overflow-hidden rounded-xl border border-[var(--landing-line)] bg-[var(--landing-chip)]/40">
<div className="flex flex-col gap-2 p-3">
<div className="h-2.5 w-1/3 rounded-full bg-[var(--landing-line)]" />
<div className="h-2 w-3/4 rounded-full bg-[var(--landing-line)]/70" />
<div className="h-2 w-2/3 rounded-full bg-[var(--landing-line)]/70" />
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="h-10 rounded-lg" style={{ backgroundColor: `${ACCENT}33` }} />
<div className="h-10 rounded-lg bg-[#4285F4]/25" />
<div className="h-10 rounded-lg bg-[#FBBC04]/25" />
</div>
</div>
<div className="pointer-events-none absolute bottom-2 right-2 flex items-end gap-1 text-lg">
{REACTIONS.slice(0, 2).map((emoji) => (
<span key={emoji} aria-hidden>
{emoji}
</span>
))}
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2 border-b border-[var(--landing-line)] px-3 py-2 text-xs font-semibold">
<Icon icon="mdi:message-text-outline" className="size-4" style={{ color: ACCENT }} aria-hidden />
Chat
<span className="ml-auto flex gap-1">
{REACTIONS.map((emoji) => (
<span key={emoji} className="text-sm" aria-hidden>
{emoji}
</span>
))}
</span>
</div>
<ul className="flex flex-1 flex-col gap-2.5 px-3 py-3">
{MESSAGES.map((message) => (
<li key={message.text} className="flex gap-2">
<span
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
style={{ backgroundColor: message.color }}
aria-hidden
>
{message.name.charAt(0)}
</span>
<div className="min-w-0">
<p className="text-[11px] font-medium text-[var(--landing-muted)]">{message.name}</p>
<p className="text-xs text-[var(--landing-fg)]">{message.text}</p>
</div>
</li>
))}
</ul>
<div className="border-t border-[var(--landing-line)] bg-[var(--landing-chip)]/30 px-3 py-2.5">
<p className="mb-1.5 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-[var(--landing-muted)]">
<img src="/ultiai-mark.svg" alt="" className="size-3.5 object-contain" aria-hidden />
Transcription en direct · UltiAI
</p>
<ul className="flex flex-col gap-1">
{TRANSCRIPT.map((line) => (
<li key={line.text} className="text-[11px] leading-snug text-[var(--landing-muted)]">
<span className="font-semibold text-[var(--landing-fg)]">{line.speaker} :</span> {line.text}
</li>
))}
</ul>
</div>
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique partage d&apos;écran, réactions et transcription IA, rien n&apos;est enregistré.
</p>
</div>
)
}

View File

@ -0,0 +1,107 @@
"use client"
import { Icon } from "@iconify/react"
const ACCENT = "#34A853"
const DEVICES = [
{ icon: "mdi:microphone", label: "MacBook Pro · Micro" },
{ icon: "mdi:video", label: "Caméra FaceTime HD" },
{ icon: "mdi:volume-high", label: "Haut-parleurs système" },
] as const
const WAITING = [
{ name: "Léa Fontaine", color: "#34A853" },
{ name: "Vincent Morel", color: "#4285F4" },
] as const
/** Aperçu statique du lobby UltiMeet — réglages caméra/micro avant de rejoindre. */
export function UltimeetLobbyDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div className="relative flex min-h-[12rem] items-center justify-center border-b border-[var(--landing-line)] bg-[var(--landing-bg)] p-4 sm:border-b-0 sm:border-r">
<div
className="absolute inset-3 rounded-xl"
style={{
background: `radial-gradient(circle at 50% 40%, ${ACCENT}26, transparent 70%)`,
}}
aria-hidden
/>
<span
className="z-10 flex size-16 items-center justify-center rounded-full text-xl font-semibold text-white"
style={{ backgroundColor: ACCENT }}
aria-hidden
>
CV
</span>
<div className="absolute bottom-4 left-4 flex items-center gap-1.5 rounded-md bg-black/55 px-2 py-1 text-[11px] font-medium text-white">
<Icon icon="mdi:microphone" className="size-3" aria-hidden />
Camille Visiteur
</div>
<div className="absolute bottom-4 right-4 flex gap-1.5">
<span className="flex size-8 items-center justify-center rounded-full bg-black/55 text-white" aria-hidden>
<Icon icon="mdi:microphone" className="size-4" />
</span>
<span className="flex size-8 items-center justify-center rounded-full bg-black/55 text-white" aria-hidden>
<Icon icon="mdi:blur" className="size-4" />
</span>
</div>
</div>
<div className="flex flex-col gap-3 p-5">
<div className="flex items-center gap-2 text-xs font-medium text-[var(--landing-muted)]">
<img src="/agenda-mark.svg" alt="" className="size-4 object-contain" aria-hidden />
Lien généré depuis UltiCal
</div>
<h3 className="text-base font-semibold tracking-tight">Atelier Nord point hebdo</h3>
<ul className="flex flex-col gap-1.5">
{DEVICES.map((device) => (
<li
key={device.label}
className="flex items-center gap-2.5 rounded-lg border border-[var(--landing-line)] px-2.5 py-2 text-xs text-[var(--landing-fg)]"
>
<Icon icon={device.icon} className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
<span className="min-w-0 flex-1 truncate">{device.label}</span>
<Icon icon="mdi:chevron-down" className="size-4 shrink-0 text-[var(--landing-muted)]" aria-hidden />
</li>
))}
</ul>
<div className="flex items-center gap-2 rounded-lg bg-[var(--landing-chip)]/40 px-2.5 py-2 text-xs text-[var(--landing-muted)]">
<div className="flex -space-x-1.5">
{WAITING.map((person) => (
<span
key={person.name}
className="flex size-5 items-center justify-center rounded-full border border-[var(--landing-bg)] text-[9px] font-semibold text-white"
style={{ backgroundColor: person.color }}
aria-hidden
>
{person.name.charAt(0)}
</span>
))}
</div>
2 personnes déjà dans la salle d&apos;attente
</div>
<button
type="button"
tabIndex={-1}
className="mt-auto flex h-10 items-center justify-center gap-2 rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: ACCENT }}
>
<Icon icon="mdi:video" className="size-4" aria-hidden />
Demander à rejoindre
</button>
</div>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique testez vos périphériques avant d&apos;entrer, aucun appareil activé.
</p>
</div>
)
}

View File

@ -0,0 +1,160 @@
"use client"
import { Icon } from "@iconify/react"
import { cn } from "@/lib/utils"
const ACCENT = "#34A853"
type Participant = {
name: string
color: string
muted: boolean
cameraOff?: boolean
speaking?: boolean
}
const PARTICIPANTS: Participant[] = [
{ name: "Léa Fontaine", color: "#34A853", muted: false, speaking: true },
{ name: "Vincent Morel", color: "#4285F4", muted: true },
{ name: "Thomas Giraud", color: "#EA4335", muted: false, cameraOff: true },
{ name: "Camille Visiteur", color: "#FBBC04", muted: true },
{ name: "Sofia Rossi", color: "#A142F4", muted: false },
{ name: "Karim Benali", color: "#00ACC1", muted: true, cameraOff: true },
]
function initials(name: string) {
return name
.split(" ")
.map((part) => part.charAt(0))
.slice(0, 2)
.join("")
}
function ParticipantTile({ participant }: { participant: Participant }) {
return (
<div
className={cn(
"relative flex items-center justify-center overflow-hidden rounded-xl bg-[var(--landing-chip)]/50",
participant.speaking && "ring-2"
)}
style={
participant.speaking
? ({ boxShadow: `inset 0 0 0 2px ${ACCENT}` } as React.CSSProperties)
: undefined
}
>
{participant.cameraOff ? (
<span
className="flex size-12 items-center justify-center rounded-full text-base font-semibold text-white"
style={{ backgroundColor: participant.color }}
aria-hidden
>
{initials(participant.name)}
</span>
) : (
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle at 50% 35%, ${participant.color}33, transparent 70%)`,
}}
aria-hidden
>
<span
className="absolute left-1/2 top-[38%] flex size-12 -translate-x-1/2 items-center justify-center rounded-full text-base font-semibold text-white"
style={{ backgroundColor: participant.color }}
>
{initials(participant.name)}
</span>
</div>
)}
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded-md bg-black/55 px-1.5 py-0.5 text-[11px] font-medium text-white">
<Icon
icon={participant.muted ? "mdi:microphone-off" : "mdi:microphone"}
className={cn("size-3", participant.muted && "text-[#EA4335]")}
aria-hidden
/>
<span className="max-w-[7rem] truncate">{participant.name}</span>
</div>
{participant.speaking ? (
<span
className="absolute right-1.5 top-1.5 flex size-5 items-center justify-center rounded-full"
style={{ backgroundColor: ACCENT }}
aria-hidden
>
<Icon icon="mdi:volume-high" className="size-3 text-white" />
</span>
) : null}
</div>
)
}
const CONTROLS: { icon: string; label: string; primary?: boolean }[] = [
{ icon: "mdi:microphone", label: "Micro" },
{ icon: "mdi:video", label: "Caméra" },
{ icon: "mdi:monitor-share", label: "Partager", primary: true },
{ icon: "mdi:hand-back-left-outline", label: "Main" },
{ icon: "mdi:message-outline", label: "Chat" },
]
/** Aperçu statique d'une salle de réunion UltiMeet — grille de participants et contrôles. */
export function UltimeetRoomDemo() {
return (
<div className="flex flex-col gap-3">
<div className="landing-glass-strong overflow-hidden rounded-2xl shadow-[0_32px_70px_-36px_rgba(30,40,90,0.45)]">
<div className="flex items-center gap-2.5 border-b border-[var(--landing-line)] px-4 py-3">
<img src="/ultimeet-mark.svg" alt="" className="size-5 object-contain" aria-hidden />
<span className="text-sm font-semibold tracking-tight">Atelier Nord point hebdo</span>
<span className="ml-auto flex items-center gap-1 text-[11px] font-medium text-[var(--landing-muted)]">
<Icon icon="mdi:lock-check" className="size-3.5" style={{ color: ACCENT }} aria-hidden />
Chiffré
</span>
<span className="flex items-center gap-1 text-[11px] font-medium tabular-nums text-[var(--landing-muted)]">
<Icon icon="mdi:clock-outline" className="size-3.5" aria-hidden />
12:47
</span>
</div>
<div className="grid grid-cols-2 gap-2 bg-[var(--landing-bg)] p-3 [grid-auto-rows:9rem] sm:grid-cols-3 sm:[grid-auto-rows:9.5rem]">
{PARTICIPANTS.map((participant) => (
<ParticipantTile key={participant.name} participant={participant} />
))}
</div>
<div className="flex items-center justify-center gap-2 border-t border-[var(--landing-line)] bg-[var(--landing-chip)]/30 px-4 py-3">
{CONTROLS.map((control) => (
<button
key={control.label}
type="button"
tabIndex={-1}
aria-label={control.label}
className={cn(
"flex size-10 items-center justify-center rounded-full border transition-colors",
control.primary
? "border-transparent text-white"
: "border-[var(--landing-line)] text-[var(--landing-fg)] hover:bg-[var(--landing-chip)]"
)}
style={control.primary ? { backgroundColor: ACCENT } : undefined}
>
<Icon icon={control.icon} className="size-5" aria-hidden />
</button>
))}
<button
type="button"
tabIndex={-1}
aria-label="Quitter"
className="ml-1 flex h-10 items-center gap-1.5 rounded-full bg-[#EA4335] px-4 text-sm font-semibold text-white"
>
<Icon icon="mdi:phone-hangup" className="size-5" aria-hidden />
<span className="hidden sm:inline">Quitter</span>
</button>
</div>
</div>
<p className="flex items-start gap-2 text-sm text-[var(--landing-muted)]">
<Icon icon="mdi:incognito" className="mt-0.5 size-4 shrink-0" aria-hidden />
Aperçu statique visio WebRTC chiffrée, rien n&apos;est diffusé.
</p>
</div>
)
}

View File

@ -0,0 +1,66 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import {
ReactFlow,
ReactFlowProvider,
Background,
useReactFlow,
useNodesState,
useEdgesState,
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { workflowNodeTypes } from "@/components/gmail/settings/automation/workflow-nodes"
import {
workflowEdgesToFlow,
workflowNodesToFlow,
} from "@/lib/mail-automation/workflow-flow"
import { createUltimailProductDemoRuleState } from "@/components/landing/product/product-demos/ultimail-demo-workflow"
function WorkflowFlowPreviewCanvas() {
const { fitView } = useReactFlow()
const demoState = useMemo(() => createUltimailProductDemoRuleState(), [])
const initialNodes = useMemo(
() => workflowNodesToFlow(demoState.workflow.nodes),
[demoState]
)
const initialEdges = useMemo(
() => workflowEdgesToFlow(demoState.workflow.edges),
[demoState]
)
const [nodes] = useNodesState(initialNodes)
const [edges] = useEdgesState(initialEdges)
useEffect(() => {
fitView({ padding: 0.18, duration: 0 })
}, [fitView])
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={workflowNodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag={false}
zoomOnScroll={false}
preventScrolling={false}
proOptions={{ hideAttribution: true }}
>
<Background gap={16} />
</ReactFlow>
)
}
/** Canvas ReactFlow seul — aperçu no-code sans panneaux latéraux. */
export function WorkflowFlowPreview() {
return (
<ReactFlowProvider>
<div className="h-full w-full">
<WorkflowFlowPreviewCanvas />
</div>
</ReactFlowProvider>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { ProductSectionHeading } from "@/components/landing/product/product-section-heading"
import type { ProductFeatureGroup } from "@/components/landing/product/product-data"
import { cn } from "@/lib/utils"
export function ProductFeatureGrid({
groups,
accent,
}: {
groups: ProductFeatureGroup[]
accent: string
}) {
return (
<>
{groups.map((group) => (
<section
key={group.eyebrow}
id={group.eyebrow.toLowerCase().replace(/\s+/g, "-")}
className="scroll-mt-20 px-4 py-20 sm:px-6"
>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12">
<ProductSectionHeading
eyebrow={group.eyebrow}
title={group.title}
description={group.description}
accent={accent}
/>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{group.features.map((feature, index) => (
<LandingReveal
as="li"
key={feature.title}
delay={(index % 4) * 0.07}
className={cn(feature.wide && "sm:col-span-2")}
>
<div className="landing-glass landing-halo-card flex h-full flex-col gap-3 rounded-2xl p-6">
<span
className="flex size-11 items-center justify-center rounded-xl"
style={{
backgroundColor: `${accent}14`,
color: accent,
}}
>
<Icon icon={feature.icon} className="size-6" aria-hidden />
</span>
<h3 className="text-lg font-semibold tracking-tight">
{feature.title}
</h3>
<p className="text-sm leading-relaxed text-[var(--landing-muted)]">
{feature.description}
</p>
</div>
</LandingReveal>
))}
</ul>
</div>
</section>
))}
</>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import type { ProductPageData } from "@/components/landing/product/product-data"
export function ProductHero({ data }: { data: ProductPageData }) {
return (
<section className="relative px-4 pb-10 pt-14 sm:px-6 sm:pt-20">
<div className="mx-auto flex w-full max-w-5xl flex-col items-center gap-8 text-center">
<LandingReveal>
<span
className="landing-glass inline-flex items-center gap-2.5 rounded-full px-4 py-1.5 text-xs font-medium sm:text-sm"
style={{ color: data.accent }}
>
<img
src={data.icon}
alt=""
className="size-4 object-contain"
draggable={false}
aria-hidden
/>
{data.heroEyebrow}
</span>
</LandingReveal>
<LandingReveal delay={0.08}>
<div className="flex flex-col items-center gap-5">
<span
className="flex size-20 items-center justify-center rounded-2xl sm:size-24"
style={{
backgroundColor: `${data.accent}18`,
boxShadow: `0 0 60px -12px ${data.accent}55`,
}}
>
<img
src={data.icon}
alt=""
className="size-12 object-contain sm:size-14"
draggable={false}
aria-hidden
/>
</span>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--landing-muted)]">
{data.tagline}
</p>
<h1 className="text-balance text-4xl font-bold leading-[1.06] tracking-tight sm:text-6xl lg:text-7xl">
{data.heroTitle}
<br />
<span
className="landing-gradient-text"
style={
{
"--landing-glow-a": data.accent,
"--landing-glow-b": data.accent,
"--landing-glow-c": data.accent,
} as React.CSSProperties
}
>
{data.heroTitleAccent}
</span>
</h1>
</div>
</LandingReveal>
<LandingReveal delay={0.16}>
<p className="mx-auto max-w-2xl text-balance text-base leading-relaxed text-[var(--landing-muted)] sm:text-lg">
{data.description}
</p>
</LandingReveal>
<LandingReveal delay={0.24} className="flex flex-wrap items-center justify-center gap-3">
<Link
href={data.ctas.primary.href}
className="landing-cta landing-cta--primary h-12 px-7 text-base"
style={
{
"--landing-glow-a": data.accent,
"--landing-glow-b": data.accent,
"--landing-glow-c": data.accent,
} as React.CSSProperties
}
>
{data.ctas.primary.label}
<Icon icon="mdi:arrow-right" className="size-5" aria-hidden />
</Link>
{data.ctas.secondary ? (
<Link
href={data.ctas.secondary.href}
className="landing-cta landing-cta--ghost h-12 px-7 text-base"
>
{data.ctas.secondary.label}
</Link>
) : null}
</LandingReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,69 @@
"use client"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import type { ProductPageData } from "@/components/landing/product/product-data"
export function ProductHighlights({
section,
accent,
}: {
section: ProductPageData["highlightsSection"]
accent: string
}) {
return (
<section id="pourquoi" className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto w-full max-w-6xl">
<LandingReveal>
<div className="landing-glass-strong relative overflow-hidden rounded-3xl px-6 py-12 sm:px-12 sm:py-16">
<div
className="pointer-events-none absolute inset-0 opacity-60"
style={{
background: `radial-gradient(60% 90% at 15% 0%, color-mix(in oklab, ${accent} 22%, transparent), transparent 70%), radial-gradient(50% 80% at 90% 100%, color-mix(in oklab, ${accent} 14%, transparent), transparent 70%)`,
}}
aria-hidden
/>
<div className="relative flex flex-col gap-10">
<div className="flex max-w-2xl flex-col gap-4">
<span
className="inline-flex w-fit items-center gap-2 rounded-full px-3.5 py-1 text-xs font-semibold uppercase tracking-wider"
style={{
backgroundColor: `${accent}1a`,
color: accent,
}}
>
<Icon icon="mdi:lightning-bolt-outline" className="size-4" aria-hidden />
{section.eyebrow}
</span>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
{section.title}
</h2>
<p className="text-base leading-relaxed text-[var(--landing-muted)]">
{section.description}
</p>
</div>
<dl className="grid grid-cols-2 gap-6 lg:grid-cols-4">
{section.highlights.map((stat, index) => (
<LandingReveal key={stat.label} delay={index * 0.08}>
<div
className="flex flex-col gap-1 border-l-2 pl-4"
style={{ borderColor: accent }}
>
<dt className="order-2 text-sm text-[var(--landing-muted)]">
{stat.label}
</dt>
<dd className="order-1 text-3xl font-bold tracking-tight sm:text-4xl">
{stat.value}
</dd>
</div>
</LandingReveal>
))}
</dl>
</div>
</div>
</LandingReveal>
</div>
</section>
)
}

View File

@ -0,0 +1,87 @@
"use client"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { ProductSectionHeading } from "@/components/landing/product/product-section-heading"
import type { ProductIntegration } from "@/components/landing/product/product-data"
export function ProductIntegrations({
integrations,
accent,
}: {
integrations: ProductIntegration[]
accent: string
}) {
return (
<section id="integrations" className="scroll-mt-20 px-4 py-20 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12">
<ProductSectionHeading
eyebrow="Écosystème"
title={
<>
Connecté aux apps{" "}
<span className="landing-gradient-text">de la suite</span>
</>
}
description="Ultimail s'intègre nativement aux autres applications UltiSuite — même identité, mêmes contacts, même stockage."
accent={accent}
/>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{integrations.map((app, index) => {
const card = (
<div className="landing-glass landing-halo-card flex h-full flex-col gap-4 rounded-2xl p-6">
<div className="flex items-center justify-between">
<span
className="flex size-12 items-center justify-center rounded-xl"
style={{ backgroundColor: `${app.accent}1a` }}
>
<img
src={app.icon}
alt=""
className="size-7 object-contain"
draggable={false}
aria-hidden
/>
</span>
{app.href ? (
<Icon
icon="mdi:arrow-top-right"
className="size-4 text-[var(--landing-muted)] transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
aria-hidden
/>
) : null}
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--landing-muted)]">
{app.tagline}
</p>
<h3 className="text-lg font-semibold tracking-tight">
{app.name}
</h3>
</div>
<p className="text-sm text-[var(--landing-muted)]">{app.description}</p>
</div>
)
return (
<LandingReveal as="li" key={app.name} delay={index * 0.08}>
{app.href ? (
<Link
href={app.href}
className="group block h-full rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
{card}
</Link>
) : (
card
)}
</LandingReveal>
)
})}
</ul>
</div>
</section>
)
}

View File

@ -0,0 +1,123 @@
"use client"
import { Icon } from "@iconify/react"
import { LandingReveal } from "@/components/landing/landing-reveal"
import { ProductSectionHeading } from "@/components/landing/product/product-section-heading"
import type { ProductInteropSection as ProductInteropSectionData } from "@/components/landing/product/product-data"
import { cn } from "@/lib/utils"
export function ProductInteropSection({
section,
accent,
}: {
section: ProductInteropSectionData
accent: string
}) {
return (
<section id="interoperabilite" 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 gap-4 sm:grid-cols-2">
{section.providers.map((provider, index) => (
<LandingReveal key={provider.name} delay={index * 0.05}>
<article className="landing-glass flex h-full flex-col gap-4 rounded-2xl p-5">
<div className="flex items-center gap-3">
<span
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
provider.brandLogo
? "bg-white shadow-sm ring-1 ring-[var(--landing-line)]"
: undefined
)}
style={
provider.brandLogo
? undefined
: {
backgroundColor: `${provider.accent}14`,
color: provider.accent,
}
}
>
<Icon
icon={provider.icon}
className={provider.brandLogo ? "size-7" : "size-6"}
aria-hidden
/>
</span>
<div className="min-w-0">
<h3 className="font-semibold tracking-tight">{provider.name}</h3>
<p className="text-sm text-[var(--landing-muted)]">{provider.tagline}</p>
</div>
</div>
<div className="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2">
<div
className={cn(
"rounded-xl border border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-3.5"
)}
>
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:account-outline" className="size-3.5" aria-hidden />
Personnel
</p>
<p className="mt-2 text-sm leading-relaxed text-[var(--landing-fg)]">
{provider.personal}
</p>
</div>
<div
className={cn(
"rounded-xl border border-[var(--landing-line)] bg-[var(--landing-bg)]/60 p-3.5"
)}
>
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-[var(--landing-muted)]">
<Icon icon="mdi:domain" className="size-3.5" aria-hidden />
Entreprise
</p>
<p className="mt-2 text-sm leading-relaxed text-[var(--landing-fg)]">
{provider.enterprise}
</p>
</div>
</div>
</article>
</LandingReveal>
))}
</div>
{section.features.length > 0 ? (
<LandingReveal delay={0.1}>
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{section.features.map((feature) => (
<li
key={feature.title}
className="landing-glass flex gap-3 rounded-xl p-4"
>
<span
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
style={{
backgroundColor: `${accent}14`,
color: accent,
}}
>
<Icon icon={feature.icon} className="size-4" aria-hidden />
</span>
<div className="min-w-0">
<h4 className="text-sm font-semibold tracking-tight">{feature.title}</h4>
<p className="mt-0.5 text-xs text-[var(--landing-muted)]">
{feature.description}
</p>
</div>
</li>
))}
</ul>
</LandingReveal>
) : null}
</div>
</section>
)
}

View File

@ -0,0 +1,208 @@
"use client"
import { useRef, useState } from "react"
import Link from "next/link"
import { Icon } from "@iconify/react"
import { ProductCrossPlatformSection } from "@/components/landing/product/product-cross-platform-section"
import { ProductCta } from "@/components/landing/product/product-cta"
import { ProductFeatureGrid } from "@/components/landing/product/product-feature-grid"
import { ProductInteropSection } from "@/components/landing/product/product-interop-section"
import { ProductShowcases } from "@/components/landing/product/product-showcases"
import { ProductHero } from "@/components/landing/product/product-hero"
import { ProductHighlights } from "@/components/landing/product/product-highlights"
import { ProductIntegrations } from "@/components/landing/product/product-integrations"
import type { ProductPageData } from "@/components/landing/product/product-data"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
import { cn } from "@/lib/utils"
function ProductHeader({
data,
scrolled,
}: {
data: ProductPageData
scrolled: boolean
}) {
const identity = useChromeIdentity()
return (
<header
className={cn(
"sticky top-0 z-40 transition-[background-color,border-color,box-shadow,backdrop-filter] duration-300",
scrolled
? "landing-glass-strong shadow-[0_8px_30px_-18px_rgba(0,0,0,0.35)]"
: "border-b border-transparent"
)}
>
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<Link
href="/"
className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1.5 text-sm font-medium text-[var(--landing-muted)] transition-colors hover:bg-[var(--landing-chip)] hover:text-[var(--landing-fg)]"
>
<Icon icon="mdi:arrow-left" className="size-4" aria-hidden />
<span className="hidden sm:inline">UltiSuite</span>
</Link>
<span className="text-[var(--landing-line)]" aria-hidden>
/
</span>
<Link
href="/"
className="flex min-w-0 items-center gap-2 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<img
src={data.icon}
alt=""
className="size-7 shrink-0 object-contain"
draggable={false}
aria-hidden
/>
<span className="truncate text-lg font-semibold tracking-tight">
{data.name}
</span>
</Link>
</div>
<div className="flex shrink-0 items-center gap-2">
{identity ? (
<Link
href={data.ctas.primary.href}
className="landing-cta landing-cta--primary h-9 px-4 text-sm"
style={
{
"--landing-glow-a": data.accent,
"--landing-glow-b": data.accent,
"--landing-glow-c": data.accent,
} as React.CSSProperties
}
>
{data.ctas.primary.label}
<Icon icon="mdi:arrow-right" className="size-4" aria-hidden />
</Link>
) : (
<>
<a
href={getAuthentikEnrollmentUrl()}
className="landing-cta landing-cta--ghost hidden h-9 px-4 text-sm sm:inline-flex"
>
Créer un compte
</a>
<Link
href="/login"
className="landing-cta landing-cta--primary h-9 px-4 text-sm"
style={
{
"--landing-glow-a": data.accent,
"--landing-glow-b": data.accent,
"--landing-glow-c": data.accent,
} as React.CSSProperties
}
>
Se connecter
</Link>
</>
)}
</div>
</div>
</header>
)
}
function ProductFooter({ data }: { data: ProductPageData }) {
return (
<footer className="border-t border-[var(--landing-line)] px-4 py-8 sm:px-6">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-4 text-sm text-[var(--landing-muted)] sm:flex-row">
<div className="flex items-center gap-2.5">
<img
src={data.icon}
alt=""
className="h-5 w-5 object-contain"
draggable={false}
aria-hidden
/>
<span className="font-semibold text-[var(--landing-fg)]">
{data.name}
</span>
<span aria-hidden>·</span>
<span>{data.tagline} UltiSuite</span>
</div>
<nav className="flex items-center gap-4" aria-label="Liens">
<Link href="/" className="transition-colors hover:text-[var(--landing-fg)]">
Accueil
</Link>
<Link
href={data.ctas.primary.href}
className="transition-colors hover:text-[var(--landing-fg)]"
>
Ouvrir l'app
</Link>
{data.ctas.secondary ? (
<Link
href={data.ctas.secondary.href}
className="transition-colors hover:text-[var(--landing-fg)]"
>
Démo
</Link>
) : null}
</nav>
</div>
</footer>
)
}
export function ProductPageShell({ data }: { data: ProductPageData }) {
const scrollRef = useRef<HTMLDivElement>(null)
const [scrolled, setScrolled] = useState(false)
const accentStyle = {
"--landing-glow-a": data.accent,
"--landing-glow-b": data.accent,
"--landing-glow-c": data.accent,
"--landing-chip": `${data.accent}1a`,
"--landing-chip-fg": data.accent,
} as React.CSSProperties
return (
<div
ref={scrollRef}
className="landing-root relative h-dvh overflow-y-auto overflow-x-hidden"
style={accentStyle}
onScroll={() => {
const top = scrollRef.current?.scrollTop ?? 0
setScrolled((prev) => (top > 8 ? true : top <= 2 ? false : prev))
}}
>
<div className="landing-backdrop" aria-hidden>
<div className="landing-orb landing-orb--a" />
<div className="landing-orb landing-orb--b" />
<div className="landing-orb landing-orb--c" />
</div>
<div className="relative z-10 flex min-h-full flex-col">
<ProductHeader data={data} scrolled={scrolled} />
<main className="flex-1">
<ProductHero data={data} />
<ProductHighlights section={data.highlightsSection} accent={data.accent} />
{data.showcases.length > 0 ? (
<ProductShowcases showcases={data.showcases} accent={data.accent} />
) : data.featureGroups ? (
<ProductFeatureGrid groups={data.featureGroups} accent={data.accent} />
) : null}
{data.crossPlatformSection ? (
<ProductCrossPlatformSection
section={data.crossPlatformSection}
accent={data.accent}
demoApp={data.crossPlatformDemo}
/>
) : null}
{data.interopSection ? (
<ProductInteropSection section={data.interopSection} accent={data.accent} />
) : null}
<ProductIntegrations integrations={data.integrations} accent={data.accent} />
<ProductCta section={data.ctaSection} accent={data.accent} />
</main>
<ProductFooter data={data} />
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { LandingReveal } from "@/components/landing/landing-reveal"
export function ProductSectionHeading({
eyebrow,
title,
description,
accent,
}: {
eyebrow: string
title: ReactNode
description?: string
accent?: string
}) {
return (
<LandingReveal className="mx-auto flex max-w-2xl flex-col items-center gap-4 text-center">
<span
className={cn(
"rounded-full px-3.5 py-1 text-xs font-semibold uppercase tracking-wider",
!accent && "bg-[var(--landing-chip)] text-[var(--landing-chip-fg)]"
)}
style={
accent
? {
backgroundColor: `${accent}1a`,
color: accent,
}
: undefined
}
>
{eyebrow}
</span>
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
{title}
</h2>
{description ? (
<p className="text-balance text-base leading-relaxed text-[var(--landing-muted)]">
{description}
</p>
) : null}
</LandingReveal>
)
}

View File

@ -0,0 +1,256 @@
"use client"
import dynamic from "next/dynamic"
import type { ComponentType } 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 { ProductShowcase, ProductShowcaseDemo } from "@/components/landing/product/product-data"
import { cn } from "@/lib/utils"
function DemoLoading() {
return (
<div className="flex min-h-[22rem] items-center justify-center text-sm text-[var(--landing-muted)] sm:min-h-[26rem]">
Chargement de la démo
</div>
)
}
// Code splitting : chaque démo est chargée à la demande, côté client uniquement,
// pour qu'une page produit n'embarque que ses propres aperçus (et leurs deps lourdes).
// `next/dynamic` exige un objet littéral pour les options (pas une variable partagée).
const DEMO_COMPONENTS: Record<ProductShowcaseDemo, ComponentType> = {
"ultimail-inbox": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimail-inbox-demo").then(
(m) => m.UltimailInboxDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultimail-compose": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimail-compose-demo").then(
(m) => m.UltimailComposeDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultimail-automation": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimail-automation-demo").then(
(m) => m.UltimailAutomationDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultidrive-browser": dynamic(
() =>
import("@/components/landing/product/product-demos/ultidrive-browser-demo").then(
(m) => m.UltidriveBrowserDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultidrive-docs": dynamic(
() =>
import("@/components/landing/product/product-demos/ultidrive-docs-demo").then(
(m) => m.UltidriveDocsDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultidrive-share": dynamic(
() =>
import("@/components/landing/product/product-demos/ultidrive-share-demo").then(
(m) => m.UltidriveShareDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultical-agenda": dynamic(
() =>
import("@/components/landing/product/product-demos/ultical-agenda-demo").then(
(m) => m.UlticalAgendaDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultical-invitations": dynamic(
() =>
import("@/components/landing/product/product-demos/ultical-invitation-demo").then(
(m) => m.UlticalInvitationDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultical-scheduling": dynamic(
() =>
import("@/components/landing/product/product-demos/ultical-scheduling-demo").then(
(m) => m.UlticalSchedulingDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultiai-chat": dynamic(
() =>
import("@/components/landing/product/product-demos/ultiai-chat-demo").then(
(m) => m.UltiaiChatDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultiai-tools": dynamic(
() =>
import("@/components/landing/product/product-demos/ultiai-tools-demo").then(
(m) => m.UltiaiToolsDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultiai-triage": dynamic(
() =>
import("@/components/landing/product/product-demos/ultiai-triage-demo").then(
(m) => m.UltiaiTriageDemo
),
{ loading: DemoLoading, ssr: false }
),
"ulticards-directory": dynamic(
() =>
import("@/components/landing/product/product-demos/ulticards-directory-demo").then(
(m) => m.UlticardsDirectoryDemo
),
{ loading: DemoLoading, ssr: false }
),
"ulticards-discovery": dynamic(
() =>
import("@/components/landing/product/product-demos/ulticards-discovery-demo").then(
(m) => m.UlticardsDiscoveryDemo
),
{ loading: DemoLoading, ssr: false }
),
"ulticards-merge": dynamic(
() =>
import("@/components/landing/product/product-demos/ulticards-merge-demo").then(
(m) => m.UlticardsMergeDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultimeet-room": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimeet-room-demo").then(
(m) => m.UltimeetRoomDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultimeet-lobby": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimeet-lobby-demo").then(
(m) => m.UltimeetLobbyDemo
),
{ loading: DemoLoading, ssr: false }
),
"ultimeet-collab": dynamic(
() =>
import("@/components/landing/product/product-demos/ultimeet-collab-demo").then(
(m) => m.UltimeetCollabDemo
),
{ loading: DemoLoading, ssr: false }
),
"admin-migration": dynamic(
() =>
import("@/components/landing/product/product-demos/admin-migration-demo").then(
(m) => m.AdminMigrationDemo
),
{ loading: DemoLoading, ssr: false }
),
"admin-identity": dynamic(
() =>
import("@/components/landing/product/product-demos/admin-identity-demo").then(
(m) => m.AdminIdentityDemo
),
{ loading: DemoLoading, ssr: false }
),
"admin-users": dynamic(
() =>
import("@/components/landing/product/product-demos/admin-users-demo").then(
(m) => m.AdminUsersDemo
),
{ loading: DemoLoading, ssr: false }
),
"admin-quotas": dynamic(
() =>
import("@/components/landing/product/product-demos/admin-quotas-demo").then(
(m) => m.AdminQuotasDemo
),
{ loading: DemoLoading, ssr: false }
),
"admin-policies": dynamic(
() =>
import("@/components/landing/product/product-demos/admin-policies-demo").then(
(m) => m.AdminPoliciesDemo
),
{ loading: DemoLoading, ssr: false }
),
}
export function ProductShowcases({
showcases,
accent,
}: {
showcases: ProductShowcase[]
accent: string
}) {
return (
<>
{showcases.map((showcase, index) => {
const Demo = DEMO_COMPONENTS[showcase.demo]
const reverse = showcase.reverse ?? index % 2 === 1
return (
<section
key={showcase.id}
id={showcase.id}
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-10 lg:gap-12">
<ProductSectionHeading
eyebrow={showcase.eyebrow}
title={showcase.title}
description={showcase.description}
accent={accent}
/>
<div
className={cn(
"grid grid-cols-1 items-center gap-8 lg:grid-cols-2 lg:gap-10",
reverse && "lg:[&>*:first-child]:order-2 lg:[&>*:last-child]:order-1"
)}
>
<LandingReveal delay={0.05}>
<ul className="flex flex-col gap-3">
{showcase.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}>
<Demo />
</LandingReveal>
</div>
</div>
</section>
)
})}
</>
)
}

View File

@ -0,0 +1,139 @@
"use client"
import { useLayoutEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
export type ScaledPreviewFit = "contain" | "cover"
export type ScaledPreviewIframeProps = {
src: string
title: string
viewportWidth: number
viewportHeight: number
ready?: boolean
active?: boolean
/**
* contain = letterbox si le ratio parent ratio viewport ;
* cover = remplit (recadrage éventuel, seulement les coins si ratios proches).
* Idéalement le parent porte un aspect-ratio = viewportWidth/viewportHeight,
* auquel cas contain et cover sont équivalents (aucune coupe).
*/
fit?: ScaledPreviewFit
/** Débord de sécurité (px) en mode cover — évite les hairlines aux bords/coins. */
coverEpsilonPx?: number
maxScale?: number
className?: string
placeholderClassName?: string
}
/**
* Iframe à viewport logique fixe (rendu « grand écran »), affichée à la taille
* du parent. Le `transform: scale` est porté par un <div> wrapper dimensionné
* au viewport ; l'iframe est en `absolute inset-0` et remplit ce wrapper à
* 100 % (WebKit scale mal une iframe directement). Aucun aspect-ratio n'est
* posé sur l'iframe : c'est au(x) parent(s) redimensionnable(s) de le porter.
*/
export function ScaledPreviewIframe({
src,
title,
viewportWidth,
viewportHeight,
ready = true,
active = true,
fit = "cover",
coverEpsilonPx = 1,
maxScale,
className,
placeholderClassName,
}: ScaledPreviewIframeProps) {
const containerRef = useRef<HTMLDivElement>(null)
/** null = pas encore mesuré (évite un iframe à scale par défaut au 1er paint). */
const [scale, setScale] = useState<number | null>(null)
useLayoutEffect(() => {
if (!ready) {
setScale(null)
return
}
const node = containerRef.current
if (!node) return
const update = () => {
// offsetWidth/Height = taille de layout SANS les transforms des ancêtres.
const width = node.offsetWidth
const height = node.offsetHeight
if (width <= 0 || height <= 0) return
let next: number
if (fit === "cover") {
const eps = coverEpsilonPx
next = Math.max(
(width + eps) / viewportWidth,
(height + eps) / viewportHeight
)
} else {
next = Math.min(width / viewportWidth, height / viewportHeight)
}
if (maxScale !== undefined) next = Math.min(next, maxScale)
setScale(next)
}
update()
const raf1 = requestAnimationFrame(() => {
update()
requestAnimationFrame(update)
})
const observer = new ResizeObserver(update)
observer.observe(node)
window.addEventListener("load", update, { once: true })
return () => {
cancelAnimationFrame(raf1)
observer.disconnect()
window.removeEventListener("load", update)
}
}, [ready, viewportWidth, viewportHeight, fit, coverEpsilonPx, maxScale])
return (
<div
ref={containerRef}
className={cn("relative h-full w-full overflow-hidden", className)}
>
{ready && scale !== null ? (
<div
className="absolute left-1/2 top-1/2"
style={{
width: viewportWidth,
height: viewportHeight,
transform: `translate(-50%, -50%) scale(${scale})`,
transformOrigin: "center center",
}}
>
<iframe
src={src}
title={title}
loading="lazy"
scrolling="no"
tabIndex={active ? 0 : -1}
className={cn(
"absolute inset-0 h-full w-full border-0",
active ? "pointer-events-auto" : "pointer-events-none"
)}
/>
</div>
) : (
<div
className={cn(
"flex h-full w-full items-center justify-center text-xs text-[var(--landing-muted)]",
placeholderClassName
)}
>
Chargement
</div>
)}
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useLayoutEffect, useState } from "react"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { readLgMatches } from "@/hooks/use-lg-breakpoint"
import { MD_MIN_PX } from "@/hooks/use-md-breakpoint"
import { getDemoMailPreviewLayout } from "@/lib/demo/demo-mail-preview"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { ReadingPaneMode } from "@/lib/mail-settings/types"
@ -13,6 +14,14 @@ export function readMailSplitViewMatches(
readingPane: ReadingPaneMode = "none"
): boolean {
if (typeof window === "undefined") return false
const preview = getDemoMailPreviewLayout()
if (preview === "tablet") {
return window.matchMedia(MD_MQ).matches
}
if (preview === "desktop") return false
if (preview === "phone") return false
if (!window.matchMedia(MD_MQ).matches) return false
const coarse = readCoarsePointerMatches()

View File

@ -1,4 +1,4 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
export type UltiAiToolGroup = {
id: string
@ -20,7 +20,7 @@ export const ULTIAI_TOOL_GROUPS: UltiAiToolGroup[] = [
},
{
id: "contacts",
label: "Contacts",
label: ULTICARDS_APP_NAME,
description: "Carnets d'adresses et fiches contacts.",
},
{

View File

@ -13,6 +13,7 @@ import { createPkcePair, randomString } from "@/lib/auth/pkce"
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
import { getRuntimeConfig } from "@/lib/runtime-config"
import { isTauriRuntime } from "@/lib/platform"
import { runtimeFetch } from "@/lib/native/http"
import { listen } from "@/lib/native/bridge"
import {
clearSession,
@ -87,7 +88,7 @@ async function exchangeCode(code: string, verifier: string): Promise<NativeSessi
redirect_uri: cfg.oidc.redirectUri,
code_verifier: verifier,
})
const res = await fetch(cfg.oidc.tokenEndpoint, {
const res = await runtimeFetch(cfg.oidc.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
@ -213,7 +214,7 @@ export async function nativeRefresh(): Promise<NativeSession | null> {
refresh_token: session.refreshToken,
})
try {
const res = await fetch(cfg.oidc.tokenEndpoint, {
const res = await runtimeFetch(cfg.oidc.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,

View File

@ -3,6 +3,7 @@ const AUTH_PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
/** Routes without session enforcement (login, public shares, interactive demos). */
export function isAuthPublicPath(pathname: string): boolean {
if (pathname === "/") return true
if (pathname === "/suite" || pathname.startsWith("/suite/")) return true
if (pathname.startsWith("/drive/s/")) return true
if (pathname === "/demo" || pathname.startsWith("/demo/")) return true
if (pathname.startsWith("/onboard/")) return true

View File

@ -1,5 +1,6 @@
import { fullContactDisplayName } from "./types"
import type { FullContact } from "./types"
import { ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
function escapeHtml(s: string): string {
return s
@ -9,7 +10,7 @@ function escapeHtml(s: string): string {
.replace(/"/g, "&quot;")
}
export function printContacts(contacts: FullContact[], title = "Contacts"): void {
export function printContacts(contacts: FullContact[], title = ULTICARDS_APP_NAME): void {
const rows = contacts
.map((c) => {
const name = escapeHtml(

View File

@ -0,0 +1,23 @@
"use client"
import { useLayoutEffect } from "react"
import { getDemoDriveLayoutPreview } from "@/lib/demo/demo-drive-layout-preview"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
/** Sidebar drive pour iframes marketing (?preview=). */
export function DemoDriveLayoutPreviewBootstrap() {
useLayoutEffect(() => {
const preview = getDemoDriveLayoutPreview()
if (!preview) return
if (preview === "phone") {
useDriveUIStore.getState().setSidebarCollapsed(true)
}
if (preview === "tablet" || preview === "desktop") {
useDriveUIStore.getState().setSidebarCollapsed(false)
}
}, [])
return null
}

View File

@ -0,0 +1,13 @@
export type DemoDriveLayoutPreview = "phone" | "tablet" | "desktop"
export function getDemoDriveLayoutPreview(): DemoDriveLayoutPreview | null {
if (typeof window === "undefined") return null
if (!window.location.pathname.includes("/demo/drive")) return null
const param = new URLSearchParams(window.location.search).get("preview")
if (param === "phone" || param === "tablet" || param === "desktop") return param
return null
}
export function demoDriveLayoutPreviewSrc(layout: DemoDriveLayoutPreview): string {
return `/demo/drive?preview=${layout}`
}

View File

@ -31,7 +31,7 @@ const DEMO_TEXT_BY_PATH: Record<string, string> = {
## Nouveautés
- UltiCal : visio UltiMeet intégrée aux événements
- UltiDrive : favoris et corbeille unifiés
- Contacts : fusion et labels personnalisés
- UltiCards : fusion et labels personnalisés
## Corrections
- Sync calendrier CalDAV sur événements récurrents

View File

@ -0,0 +1,31 @@
"use client"
import { useLayoutEffect } from "react"
import { getDemoMailPreviewLayout } from "@/lib/demo/demo-mail-preview"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
/** Applique reading pane / sidebar pour les iframes marketing (?preview=). */
export function DemoMailPreviewBootstrap() {
useLayoutEffect(() => {
const preview = getDemoMailPreviewLayout()
if (!preview) return
if (preview === "tablet") {
useMailSettingsStore.getState().setReadingPane("right")
useMailUiStore.getState().setSidebarCollapsed(true)
}
if (preview === "desktop") {
useMailSettingsStore.getState().setReadingPane("none")
useMailUiStore.getState().setSidebarCollapsed(false)
}
if (preview === "phone") {
useMailSettingsStore.getState().setReadingPane("none")
useMailUiStore.getState().setSidebarCollapsed(true)
}
}, [])
return null
}

View File

@ -0,0 +1,20 @@
export type DemoMailPreviewLayout = "phone" | "tablet" | "desktop"
export function getDemoMailPreviewLayout(): DemoMailPreviewLayout | null {
if (typeof window === "undefined") return null
if (!window.location.pathname.includes("/demo/mail")) return null
const param = new URLSearchParams(window.location.search).get("preview")
if (param === "phone" || param === "tablet" || param === "desktop") return param
return null
}
export function demoMailPreviewSrc(
layout: DemoMailPreviewLayout,
messageId = "m1"
): string {
const preview = `preview=${layout}`
if (layout === "phone") {
return `/demo/mail/inbox?${preview}`
}
return `/demo/mail/inbox/message/${encodeURIComponent(messageId)}?${preview}`
}

View File

@ -1,4 +1,4 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
export type ApiTokenPermissionGroup = "mail" | "drive" | "contacts" | "agenda" | "automation"
@ -51,7 +51,7 @@ export const API_TOKEN_PERMISSION_GROUPS: {
},
{
id: "contacts",
label: "Contacts",
label: ULTICARDS_APP_NAME,
description: "Annuaire, libellés et propriétés des contacts.",
},
{

View File

@ -5,14 +5,14 @@ import type {
TriggerOrGroup,
TriggerType,
} from './types'
import { ULTICAL_APP_NAME } from '@/lib/suite/page-metadata'
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from '@/lib/suite/page-metadata'
export type AutomationDomain = 'mail' | 'drive' | 'contacts' | 'agenda'
export const AUTOMATION_DOMAIN_LABELS: Record<AutomationDomain, string> = {
mail: 'Mail',
drive: 'Drive',
contacts: 'Contacts',
contacts: ULTICARDS_APP_NAME,
agenda: ULTICAL_APP_NAME,
}

View File

@ -26,7 +26,7 @@ export interface AutomationTrigger {
account_id?: string
/** Chemin Drive (filtre optionnel sur les déclencheurs Drive) */
folder_path?: string
/** Libellé contact (filtre optionnel sur les déclencheurs Contacts) */
/** Libellé contact (filtre optionnel sur les déclencheurs UltiCards) */
contact_label?: string
/** Agenda cible (filtre optionnel sur les déclencheurs Agenda) */
calendar_id?: string

View File

@ -197,6 +197,17 @@ export const MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS = cn(
export const MAIL_LIST_ROW_DIVIDER_CLASS = "divide-y divide-mail-list-divider"
/** Liste mail — zone scroll principale (gutter + thumb style Gmail). */
export const MAIL_LIST_MAIN_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " +
"[scrollbar-color:#9aa0a6_#ffffff] dark:[scrollbar-color:#5f6368_var(--mail-surface)] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white dark:[&::-webkit-scrollbar]:bg-mail-surface " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white dark:[&::-webkit-scrollbar-track]:bg-mail-surface [&::-webkit-scrollbar-track]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
"dark:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] dark:hover:[&::-webkit-scrollbar-thumb]:bg-[#80868b] " +
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white dark:[&::-webkit-scrollbar-corner]:bg-mail-surface"
/** Recherche — champ et panneau avancé. */
export const MAIL_SEARCH_INPUT_WRAP_CLASS = cn(
"relative flex min-w-0 flex-1 items-center rounded-full border border-border",

View File

@ -35,9 +35,20 @@ export type MailDateDisplayVariant = "list" | "preview" | "previewShort" | "deta
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000
function formatMailPreviewRelative(d: Dayjs, now: Dayjs): string | null {
if (d.isAfter(now)) return null
const msAgo = now.valueOf() - d.valueOf()
const withinTwoWeeks = msAgo >= 0 && msAgo < TWO_WEEKS_MS
if (d.isSame(now, "day") || withinTwoWeeks) {
return `(${d.fromNow()})`
}
return null
}
function relativeSuffix(d: Dayjs, now: Dayjs): string {
if (d.isAfter(now)) return ""
return ` (${d.fromNow()})`
const relative = formatMailPreviewRelative(d, now)
return relative ? ` ${relative}` : ""
}
/** Colonne date de la liste (fuseau navigateur). */
@ -55,8 +66,8 @@ export function formatMailListDate(iso: string, now: Dayjs = dayjs()): string {
return d.format("L")
}
/** En-tête / aperçu dun message (fuseau navigateur). */
export function formatMailPreviewDate(iso: string, now: Dayjs = dayjs()): string {
/** Date seule pour len-tête / aperçu (sans relatif). */
export function formatMailPreviewDatePrimary(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return "—"
@ -64,22 +75,36 @@ export function formatMailPreviewDate(iso: string, now: Dayjs = dayjs()): string
const time = d.format("LT")
if (d.isSame(now, "day")) {
return `${time}${relativeSuffix(d, now)}`
return time
}
const msAgo = now.valueOf() - d.valueOf()
const withinTwoWeeks = msAgo >= 0 && msAgo < TWO_WEEKS_MS
const datePart = d.format("ddd D MMM")
if (withinTwoWeeks) {
return `${datePart} ${time}${relativeSuffix(d, now)}`
}
if (d.isSame(now, "year")) {
return `${datePart} ${time}`
}
return `${d.format("ddd D MMM YYYY")} ${time}`
}
/** Relatif « (il y a …) » pour laperçu, ou null si absent. */
export function formatMailPreviewDateRelative(
iso: string,
now: Dayjs = dayjs()
): string | null {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return null
return formatMailPreviewRelative(d, now)
}
/** En-tête / aperçu dun message (fuseau navigateur). */
export function formatMailPreviewDate(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return "—"
return `${formatMailPreviewDatePrimary(iso, now)}${relativeSuffix(d, now)}`
}
/** Citations, impression, panneau « détails » (sans relatif). */
export function formatMailDetailDate(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()

48
lib/native/http.ts Normal file
View File

@ -0,0 +1,48 @@
/**
* HTTP for native shells. WebView fetch() is blocked by CORS on Authentik
* (/auth/* has no Access-Control-Allow-Origin). Rust reqwest bypasses that.
*/
import { invoke } from "@/lib/native/bridge"
import { isTauriRuntime } from "@/lib/platform"
type NativeHttpResponse = {
status: number
body: string
}
function headersRecord(init?: RequestInit): Record<string, string> {
if (!init?.headers) return {}
const out: Record<string, string> = {}
const h = new Headers(init.headers)
h.forEach((value, key) => {
out[key] = value
})
return out
}
async function nativeHttpRequest(
url: string,
init?: RequestInit
): Promise<Response | null> {
const result = await invoke<NativeHttpResponse>("plugin:ulti-core|http_request", {
url,
method: init?.method ?? "GET",
headers: headersRecord(init),
body: typeof init?.body === "string" ? init.body : undefined,
})
if (!result) return null
return new Response(result.body, { status: result.status })
}
/** fetch() on web; native HTTP in Tauri (no WebView CORS fallback). */
export async function runtimeFetch(
url: string,
init?: RequestInit
): Promise<Response> {
if (isTauriRuntime()) {
const res = await nativeHttpRequest(url, init)
if (res) return res
throw new TypeError(`Native HTTP failed: ${url}`)
}
return fetch(url, init)
}

View File

@ -1,6 +1,7 @@
"use client"
import { IS_MOBILE_BUILD, SUITE_APP, appScheme, useNativeRuntime } from "@/lib/platform"
import { runtimeFetch } from "@/lib/native/http"
import type { OidcRuntimeConfig, RuntimeConfig } from "./types"
export type { RuntimeConfig, OidcRuntimeConfig } from "./types"
@ -170,7 +171,7 @@ export async function deriveRuntimeConfig(
let doc: OidcDiscoveryDoc
try {
const res = await fetch(discoveryUrl, { headers: { Accept: "application/json" } })
const res = await runtimeFetch(discoveryUrl, { headers: { Accept: "application/json" } })
if (!res.ok) {
throw new RuntimeConfigError(
`Découverte OIDC échouée (${res.status}) sur ${origin}`

View File

@ -1,4 +1,4 @@
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
export type FavoriteApp = {
@ -32,7 +32,7 @@ export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
href: "/drive",
},
{
name: "Contacts",
name: ULTICARDS_APP_NAME,
icon: suitePublicAsset("/contacts-mark.svg"),
href: "/contacts",
},

View File

@ -10,12 +10,15 @@ export const SUITE_TITLE_SEP = " - "
/** Display name of the calendar app (`/agenda`). */
export const ULTICAL_APP_NAME = "UltiCal"
/** Display name of the contacts app (`/contacts`). */
export const ULTICARDS_APP_NAME = "UltiCards"
export const MAIL_INBOX_DOCUMENT_TITLE = `Boîte mail${SUITE_TITLE_SEP}Ultimail`
const DESCRIPTIONS: Record<SuiteApp, string> = {
mail: "Client mail Ultimail — suite souveraine",
drive: "Stockage de fichiers UltiDrive — suite UltiSuite",
contacts: "Carnet d'adresses — UltiSuite",
contacts: `Carnet d'adresses — ${ULTICARDS_APP_NAME}`,
agenda: "Calendrier partagé, invitations et disponibilités — UltiSuite",
meet: "Visioconférence chiffrée intégrée à la suite — UltiMeet",
admin: "Console d'administration — UltiSuite",
@ -26,7 +29,7 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
const APP_LABELS: Record<SuiteApp, string> = {
mail: "Ultimail",
drive: "UltiDrive",
contacts: "UltiSuite",
contacts: ULTICARDS_APP_NAME,
agenda: ULTICAL_APP_NAME,
meet: "UltiMeet",
admin: "Administration",

View File

@ -1,5 +1,5 @@
import { isDriveAppPath } from "@/lib/suite/drive-route"
import { ULTICAL_APP_NAME } from "@/lib/suite/page-metadata"
import { ULTICAL_APP_NAME, ULTICARDS_APP_NAME } from "@/lib/suite/page-metadata"
export type SuiteSplashApp = "mail" | "drive" | "agenda" | "contacts"
@ -39,10 +39,10 @@ export const SUITE_SPLASH_CONFIG: Record<SuiteSplashApp, SuiteSplashConfig> = {
spinMark: true,
},
contacts: {
pill: "CONTACTS",
pill: "ULTICARDS",
mark: "/contacts-mark.svg",
subtitle: "Chargement de vos contacts...",
ariaLabel: "Chargement des contacts",
ariaLabel: `Chargement d'${ULTICARDS_APP_NAME}`,
spinMark: true,
},
}

277
mobile/Cargo.lock generated
View File

@ -453,6 +453,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@ -909,6 +915,15 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@ -1279,8 +1294,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -1290,9 +1307,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1456,6 +1475,25 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -1566,6 +1604,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@ -1576,6 +1615,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@ -2028,6 +2083,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
@ -2752,6 +2813,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -2877,6 +2993,47 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-core",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
]
[[package]]
name = "reqwest"
version = "0.13.4"
@ -2911,6 +3068,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
@ -2949,12 +3120,53 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -3181,6 +3393,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.21.0"
@ -3389,6 +3613,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@ -3541,7 +3771,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest",
"reqwest 0.13.4",
"serde",
"serde_json",
"serde_repr",
@ -3955,6 +4185,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@ -4222,6 +4462,7 @@ dependencies = [
"keyring",
"log",
"open",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
@ -4363,6 +4604,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@ -4599,6 +4846,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
@ -4655,6 +4912,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@ -4896,6 +5162,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"

View File

@ -30,6 +30,7 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "charset", "http2"] }
ulti-core = { path = "crates/ulti-core" }
# Smaller, faster mobile binaries.

View File

@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Contacts",
"productName": "UltiCards",
"version": "0.1.0",
"identifier": "space.ulti.contacts",
"build": {
@ -13,7 +13,7 @@
"windows": [
{
"label": "main",
"title": "Contacts",
"title": "UltiCards",
"url": "/contacts/",
"width": 420,
"height": 900

View File

@ -13,6 +13,7 @@ tauri = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
reqwest = { workspace = true }
thiserror = "2"
[build-dependencies]

View File

@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[
"share_take_pending",
"share_out",
"app_open_url",
"http_request",
];
fn main() {

View File

@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-http-request"
description = "Enables the http_request command without any pre-configured scope."
commands.allow = ["http_request"]
[[permission]]
identifier = "deny-http-request"
description = "Denies the http_request command without any pre-configured scope."
commands.deny = ["http_request"]

View File

@ -13,6 +13,7 @@ Default permissions for the ulti-core plugin (all suite commands).
- `allow-share-take-pending`
- `allow-share-out`
- `allow-app-open-url`
- `allow-http-request`
## Permission Table
@ -78,6 +79,32 @@ Denies the contacts_fetch command without any pre-configured scope.
<tr>
<td>
`ulti-core:allow-http-request`
</td>
<td>
Enables the http_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`ulti-core:deny-http-request`
</td>
<td>
Denies the http_request command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`ulti-core:allow-push-register`
</td>

View File

@ -12,4 +12,5 @@ permissions = [
"allow-share-take-pending",
"allow-share-out",
"allow-app-open-url",
"allow-http-request",
]

View File

@ -318,6 +318,18 @@
"const": "deny-contacts-fetch",
"markdownDescription": "Denies the contacts_fetch command without any pre-configured scope."
},
{
"description": "Enables the http_request command without any pre-configured scope.",
"type": "string",
"const": "allow-http-request",
"markdownDescription": "Enables the http_request command without any pre-configured scope."
},
{
"description": "Denies the http_request command without any pre-configured scope.",
"type": "string",
"const": "deny-http-request",
"markdownDescription": "Denies the http_request command without any pre-configured scope."
},
{
"description": "Enables the push_register command without any pre-configured scope.",
"type": "string",
@ -403,10 +415,10 @@
"markdownDescription": "Denies the store_set command without any pre-configured scope."
},
{
"description": "Default permissions for the ulti-core plugin (all suite commands).\n#### This default permission set includes:\n\n- `allow-store-set`\n- `allow-store-get`\n- `allow-store-delete`\n- `allow-store-clear`\n- `allow-push-register`\n- `allow-contacts-fetch`\n- `allow-share-take-pending`\n- `allow-share-out`\n- `allow-app-open-url`",
"description": "Default permissions for the ulti-core plugin (all suite commands).\n#### This default permission set includes:\n\n- `allow-store-set`\n- `allow-store-get`\n- `allow-store-delete`\n- `allow-store-clear`\n- `allow-push-register`\n- `allow-contacts-fetch`\n- `allow-share-take-pending`\n- `allow-share-out`\n- `allow-app-open-url`\n- `allow-http-request`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the ulti-core plugin (all suite commands).\n#### This default permission set includes:\n\n- `allow-store-set`\n- `allow-store-get`\n- `allow-store-delete`\n- `allow-store-clear`\n- `allow-push-register`\n- `allow-contacts-fetch`\n- `allow-share-take-pending`\n- `allow-share-out`\n- `allow-app-open-url`"
"markdownDescription": "Default permissions for the ulti-core plugin (all suite commands).\n#### This default permission set includes:\n\n- `allow-store-set`\n- `allow-store-get`\n- `allow-store-delete`\n- `allow-store-clear`\n- `allow-push-register`\n- `allow-contacts-fetch`\n- `allow-share-take-pending`\n- `allow-share-out`\n- `allow-app-open-url`\n- `allow-http-request`"
}
]
}

View File

@ -12,6 +12,7 @@
//! require native (Kotlin/Swift) code return a clear, structured error and are
//! wired in the per-platform plugin layer documented in `mobile/README.md`.
use std::collections::HashMap;
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
@ -176,6 +177,54 @@ pub fn enqueue_share<R: Runtime>(app: &tauri::AppHandle<R>, payload: SharePayloa
}
}
// ---------------------------------------------------------------------------
// Native HTTP (bypasses WebView CORS for OIDC discovery / token exchange)
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct HttpRequestArgs {
url: String,
#[serde(default = "default_http_method")]
method: String,
#[serde(default)]
headers: HashMap<String, String>,
body: Option<String>,
}
fn default_http_method() -> String {
"GET".to_string()
}
#[derive(Debug, Serialize)]
pub struct HttpResponseBody {
status: u16,
body: String,
}
#[tauri::command]
async fn http_request(args: HttpRequestArgs) -> Result<HttpResponseBody, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let method = reqwest::Method::from_bytes(args.method.as_bytes())
.map_err(|e| e.to_string())?;
let mut req = client.request(method, &args.url);
for (key, value) in args.headers {
req = req.header(key, value);
}
if let Some(body) = args.body {
req = req.body(body);
}
let res = req.send().await.map_err(|e| e.to_string())?;
let status = res.status().as_u16();
let body = res.text().await.map_err(|e| e.to_string())?;
Ok(HttpResponseBody { status, body })
}
// ---------------------------------------------------------------------------
// Inter-app / external URL opening
// ---------------------------------------------------------------------------
@ -207,7 +256,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
contacts_fetch,
share_take_pending,
share_out,
app_open_url
app_open_url,
http_request
])
.setup(|app, _api| {
app.manage(ShareQueue::default());

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More