feat: update metadata and layout for new product pages
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
364ef0ef77
commit
efaaf16f60
@ -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,
|
||||
|
||||
@ -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 },
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
14
app/suite/administration/page.tsx
Normal file
14
app/suite/administration/page.tsx
Normal 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
17
app/suite/ultiai/page.tsx
Normal 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} />
|
||||
}
|
||||
14
app/suite/ultical/page.tsx
Normal file
14
app/suite/ultical/page.tsx
Normal 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} />
|
||||
}
|
||||
14
app/suite/ulticards/page.tsx
Normal file
14
app/suite/ulticards/page.tsx
Normal 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} />
|
||||
}
|
||||
14
app/suite/ultidrive/page.tsx
Normal file
14
app/suite/ultidrive/page.tsx
Normal 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} />
|
||||
}
|
||||
14
app/suite/ultimail/page.tsx
Normal file
14
app/suite/ultimail/page.tsx
Normal 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} />
|
||||
}
|
||||
14
app/suite/ultimeet/page.tsx
Normal file
14
app/suite/ultimeet/page.tsx
Normal 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} />
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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 })}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />}
|
||||
|
||||
@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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
|
||||
|
||||
583
components/landing/product/product-cross-platform-section.tsx
Normal file
583
components/landing/product/product-cross-platform-section.tsx
Normal 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'é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>
|
||||
)
|
||||
}
|
||||
53
components/landing/product/product-cta.tsx
Normal file
53
components/landing/product/product-cta.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2023
components/landing/product/product-data.tsx
Normal file
2023
components/landing/product/product-data.tsx
Normal file
File diff suppressed because it is too large
Load Diff
69
components/landing/product/product-demo-frame.tsx
Normal file
69
components/landing/product/product-demo-frame.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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'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'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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'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'audit exportable (CSV/NDJSON).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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'alerte par défaut.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
components/landing/product/product-demos/admin-users-demo.tsx
Normal file
111
components/landing/product/product-demos/admin-users-demo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
109
components/landing/product/product-demos/ultiai-chat-demo.tsx
Normal file
109
components/landing/product/product-demos/ultiai-chat-demo.tsx
Normal 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'assistant lit le contexte et propose, rien n'est envoyé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
components/landing/product/product-demos/ultiai-tools-demo.tsx
Normal file
106
components/landing/product/product-demos/ultiai-tools-demo.tsx
Normal 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'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'outils s'active ou se coupe par compte.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'est envoyé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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'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'est réservé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'est partagé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'é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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'est envoyé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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" },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'écran, réactions et transcription IA, rien n'est enregistré.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
components/landing/product/product-demos/ultimeet-lobby-demo.tsx
Normal file
107
components/landing/product/product-demos/ultimeet-lobby-demo.tsx
Normal 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'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'entrer, aucun appareil activé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
components/landing/product/product-demos/ultimeet-room-demo.tsx
Normal file
160
components/landing/product/product-demos/ultimeet-room-demo.tsx
Normal 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'est diffusé.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
65
components/landing/product/product-feature-grid.tsx
Normal file
65
components/landing/product/product-feature-grid.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
100
components/landing/product/product-hero.tsx
Normal file
100
components/landing/product/product-hero.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
components/landing/product/product-highlights.tsx
Normal file
69
components/landing/product/product-highlights.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
components/landing/product/product-integrations.tsx
Normal file
87
components/landing/product/product-integrations.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
components/landing/product/product-interop-section.tsx
Normal file
123
components/landing/product/product-interop-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
208
components/landing/product/product-page-shell.tsx
Normal file
208
components/landing/product/product-page-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
components/landing/product/product-section-heading.tsx
Normal file
44
components/landing/product/product-section-heading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
256
components/landing/product/product-showcases.tsx
Normal file
256
components/landing/product/product-showcases.tsx
Normal 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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
139
components/landing/product/scaled-preview-iframe.tsx
Normal file
139
components/landing/product/scaled-preview-iframe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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.",
|
||||
},
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, """)
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
23
lib/demo/demo-drive-layout-preview-bootstrap.tsx
Normal file
23
lib/demo/demo-drive-layout-preview-bootstrap.tsx
Normal 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
|
||||
}
|
||||
13
lib/demo/demo-drive-layout-preview.ts
Normal file
13
lib/demo/demo-drive-layout-preview.ts
Normal 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}`
|
||||
}
|
||||
@ -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
|
||||
|
||||
31
lib/demo/demo-mail-preview-bootstrap.tsx
Normal file
31
lib/demo/demo-mail-preview-bootstrap.tsx
Normal 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
|
||||
}
|
||||
20
lib/demo/demo-mail-preview.ts
Normal file
20
lib/demo/demo-mail-preview.ts
Normal 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}`
|
||||
}
|
||||
@ -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.",
|
||||
},
|
||||
{
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 d’un message (fuseau navigateur). */
|
||||
export function formatMailPreviewDate(iso: string, now: Dayjs = dayjs()): string {
|
||||
/** Date seule pour l’en-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 l’aperç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 d’un 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
48
lib/native/http.ts
Normal 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)
|
||||
}
|
||||
@ -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}`
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
277
mobile/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -13,6 +13,7 @@ tauri = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
thiserror = "2"
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@ -8,6 +8,7 @@ const COMMANDS: &[&str] = &[
|
||||
"share_take_pending",
|
||||
"share_out",
|
||||
"app_open_url",
|
||||
"http_request",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
@ -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"]
|
||||
@ -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>
|
||||
|
||||
@ -12,4 +12,5 @@ permissions = [
|
||||
"allow-share-take-pending",
|
||||
"allow-share-out",
|
||||
"allow-app-open-url",
|
||||
"allow-http-request",
|
||||
]
|
||||
|
||||
@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user