diff --git a/apps/admin/.env.example b/apps/admin/.env.example
index 8946981b..0451108b 100644
--- a/apps/admin/.env.example
+++ b/apps/admin/.env.example
@@ -18,6 +18,11 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo
# and production.
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099
+# Origin of the public site (https://host, without path): where the pages of the B2B partners and
+# the link of the stores live, written in the links and QR codes of A-10. Locally,
+# http://localhost:3000 if the site runs there. Empty: A-10 says it is not set, and gives no link.
+NEXT_PUBLIC_SITE_URL=
+
# Dashboards the admin links to from its home page (A-05), https: the exact page of the Widoo
# project. Empty: the home page of PostHog Cloud EU and of RevenueCat.
NEXT_PUBLIC_POSTHOG_URL=
diff --git a/apps/admin/src/app/(shell)/partenaires/page.tsx b/apps/admin/src/app/(shell)/partenaires/page.tsx
new file mode 100644
index 00000000..1943b94c
--- /dev/null
+++ b/apps/admin/src/app/(shell)/partenaires/page.tsx
@@ -0,0 +1,11 @@
+import { PartnersContainer } from '@/containers/PartnersContainer';
+import { Suspense } from 'react';
+
+/** A-10, the B2B partners. The open partner is read from the address, in the browser. */
+export default function PartnersPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerActions.tsx b/apps/admin/src/components/b2b/PartnerActions.tsx
new file mode 100644
index 00000000..1c998ff4
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerActions.tsx
@@ -0,0 +1,21 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+
+const texts = messages.b2b.panel;
+
+type PartnerActionsProps = {
+ onEdit: () => void;
+ onChooseRoutes: () => void;
+};
+
+/** The actions of the card of a partner (A-10): change it, choose the routes of its page. */
+export function PartnerActions({ onEdit, onChooseRoutes }: PartnerActionsProps) {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerFormDialog.tsx b/apps/admin/src/components/b2b/PartnerFormDialog.tsx
new file mode 100644
index 00000000..8b8f7677
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerFormDialog.tsx
@@ -0,0 +1,162 @@
+import { monthlyPricePattern, type PartnerFormValues } from '@/lib/partner-form';
+import { messages } from '@/messages/fr';
+import { b2bClientStatuses, b2bClientTypes } from '@widoo/shared';
+import { ConfirmDialog, Field, Input, Select, Textarea } from '@widoo/ui';
+
+const texts = messages.b2b.form;
+
+type PartnerFormDialogProps = {
+ isOpen: boolean;
+ /** True when it changes a partner; false when it registers one. */
+ isEdit: boolean;
+ values: PartnerFormValues;
+ onChange: (key: Key, value: PartnerFormValues[Key]) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Ajouter un partenaire » or « Modifier le partenaire »: its contact, contract and notes. */
+export function PartnerFormDialog({
+ isOpen,
+ isEdit,
+ values,
+ onChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: PartnerFormDialogProps) {
+ return (
+
+
+ onChange('name', event.target.value)}
+ />
+
+
+ onChange('slug', event.target.value)}
+ />
+
+
+
+
+
+
+
+
+ onChange('contactName', event.target.value)}
+ />
+
+
+ onChange('contactEmail', event.target.value)}
+ />
+
+
+ onChange('contactPhone', event.target.value)}
+ />
+
+
+ onChange('address', event.target.value)}
+ />
+
+
+ onChange('monthlyPrice', event.target.value)}
+ />
+
+
+ onChange('startedAt', event.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerLinks.tsx b/apps/admin/src/components/b2b/PartnerLinks.tsx
new file mode 100644
index 00000000..e8830b8f
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerLinks.tsx
@@ -0,0 +1,75 @@
+import { messages } from '@/messages/fr';
+import type { B2bClientStatus } from '@widoo/shared';
+
+const texts = messages.b2b.links;
+
+export type PartnerLink = {
+ /** What the link opens: « Page du partenaire », « Application ». */
+ label: string;
+ url: string;
+ /** The QR code of the link as an image; null while it is drawn. */
+ qrCodeImage: string | null;
+ fileName: string;
+};
+
+type PartnerLinksProps = {
+ /** Null when the origin of the site is not set: there is nothing to link to. */
+ links: PartnerLink[] | null;
+ status: B2bClientStatus;
+};
+
+/** The links and QR codes of a partner (A-10): its page, and the stores with its source of measure. */
+export function PartnerLinks({ links, status }: PartnerLinksProps) {
+ return (
+
+
{texts.title}
+ {links === null ? (
+
{texts.siteUrlMissing}
+ ) : (
+ <>
+
{texts.pageHint[status]}
+
+ {links.map((link) => (
+
+ {link.qrCodeImage ? (
+ // The QR code is a `data:` image of our own: nothing to optimise or to fetch.
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ )}
+
+ {link.label}
+
+ {link.url}
+
+ {link.qrCodeImage ? (
+
+ {texts.download}
+
+ ) : null}
+
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerPanelContent.tsx b/apps/admin/src/components/b2b/PartnerPanelContent.tsx
new file mode 100644
index 00000000..d5d706dc
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerPanelContent.tsx
@@ -0,0 +1,74 @@
+import { dayLabelOf, monthlyPriceLabelOf } from '@/lib/partner-display';
+import { messages } from '@/messages/fr';
+import type { B2bClient } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { PartnerStatusBadge } from './PartnerStatusBadge';
+
+const texts = messages.b2b.panel;
+
+function Line({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+
{children}
+
+ );
+}
+
+const noValue = {messages.b2b.noValue};
+
+type PartnerPanelContentProps = {
+ partner: B2bClient;
+ /** Address of the sheet of a route, to open it from the card. */
+ routePathOf: (routeId: string) => string;
+};
+
+/** The card of a partner in the detail panel (A-10): contact, contract, notes and routes. */
+export function PartnerPanelContent({ partner, routePathOf }: PartnerPanelContentProps) {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerRoutesDialog.tsx b/apps/admin/src/components/b2b/PartnerRoutesDialog.tsx
new file mode 100644
index 00000000..21e17480
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerRoutesDialog.tsx
@@ -0,0 +1,137 @@
+import { messages } from '@/messages/fr';
+import type { PickedRoute } from '@/lib/partner-routes';
+import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@phosphor-icons/react/dist/ssr';
+import { maxPartnerRoutes, minPartnerRoutes, type CatalogRoute } from '@widoo/shared';
+import { Button, ConfirmDialog, SearchInput } from '@widoo/ui';
+
+const texts = messages.b2b.routesDialog;
+
+type PartnerRoutesDialogProps = {
+ isOpen: boolean;
+ /** The routes chosen, in the order of the page. */
+ picked: PickedRoute[];
+ /** Published routes that match the search; undefined while they load. */
+ results: CatalogRoute[] | undefined;
+ onSearchChange: (search: string) => void;
+ onAdd: (route: PickedRoute) => void;
+ onRemove: (routeId: string) => void;
+ onMove: (routeId: string, direction: -1 | 1) => void;
+ /** The choice holds 3 to 10 routes. */
+ isComplete: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Choisir les parcours »: 3 to 10 published routes, in the order of the page of the partner. */
+export function PartnerRoutesDialog({
+ isOpen,
+ picked,
+ results,
+ onSearchChange,
+ onAdd,
+ onRemove,
+ onMove,
+ isComplete,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: PartnerRoutesDialogProps) {
+ const isFull = picked.length >= maxPartnerRoutes;
+ return (
+
+
+
{texts.chosen(picked.length)}
+ {picked.length === 0 ? (
+
{texts.noneChosen}
+ ) : (
+
+ {picked.map((route, index) => (
+
+ {index + 1}.
+ {route.title}
+
+
+
+
+ ))}
+
+ )}
+
+
+
{texts.search}
+ onSearchChange(event.target.value)}
+ // Enter looks for a route, it does not save the choice.
+ onKeyDown={(event) => event.key === 'Enter' && event.preventDefault()}
+ />
+ {results === undefined ? (
+
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerStats.tsx b/apps/admin/src/components/b2b/PartnerStats.tsx
new file mode 100644
index 00000000..5c44d404
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerStats.tsx
@@ -0,0 +1,16 @@
+import { messages } from '@/messages/fr';
+
+const texts = messages.b2b.stats;
+
+/**
+ * The clicks and installs that came from the source of a partner (A-10). Not wired yet: no event
+ * carries the source, and the admin does not read PostHog (ticket #346, Inbox of 2026-10-20).
+ */
+export function PartnerStats({ source }: { source: string }) {
+ return (
+
+
{texts.title}
+
{texts.notWired(source)}
+
+ );
+}
diff --git a/apps/admin/src/components/b2b/PartnerStatusBadge.tsx b/apps/admin/src/components/b2b/PartnerStatusBadge.tsx
new file mode 100644
index 00000000..ed5cca43
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnerStatusBadge.tsx
@@ -0,0 +1,15 @@
+import { messages } from '@/messages/fr';
+import type { B2bClientStatus } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+
+const tones = {
+ prospect: 'info',
+ active: 'success',
+ suspended: 'alert',
+ ended: 'neutral',
+} as const satisfies Record;
+
+/** Where a partner stands: prospect, active, suspended or ended. */
+export function PartnerStatusBadge({ status }: { status: B2bClientStatus }) {
+ return {messages.b2b.statuses[status]};
+}
diff --git a/apps/admin/src/components/b2b/PartnersTable.tsx b/apps/admin/src/components/b2b/PartnersTable.tsx
new file mode 100644
index 00000000..8d28d516
--- /dev/null
+++ b/apps/admin/src/components/b2b/PartnersTable.tsx
@@ -0,0 +1,76 @@
+import { citiesLabelOf, dayLabelOf, monthlyPriceLabelOf } from '@/lib/partner-display';
+import { messages } from '@/messages/fr';
+import type { B2bClientListItem } from '@widoo/shared';
+import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
+import { PartnerStatusBadge } from './PartnerStatusBadge';
+
+const texts = messages.b2b;
+const columnCount = 7;
+
+type PartnersTableProps = {
+ /** The partners; ignored while `isLoading`. */
+ items: B2bClientListItem[];
+ isLoading: boolean;
+ /** The partner whose card is open in the panel. */
+ openId: string | null;
+ onOpen: (partner: B2bClientListItem) => void;
+};
+
+/** The B2B partners as a table (A-10); a row opens its card. */
+export function PartnersTable({ items, isLoading, openId, onOpen }: PartnersTableProps) {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/containers/UsersContainer.tsx b/apps/admin/src/containers/UsersContainer.tsx
index d2c42c16..f9f67c61 100644
--- a/apps/admin/src/containers/UsersContainer.tsx
+++ b/apps/admin/src/containers/UsersContainer.tsx
@@ -8,7 +8,7 @@ import { toUsersParams, usersListOptions } from '@/config/users-list';
import { catalogRoutePathOf } from '@/containers/CatalogRoutesContainer';
import { UserDecisionsContainer } from '@/containers/UserDecisionsContainer';
import { useDebouncedCallback } from '@/hooks/useDebouncedCallback';
-import { useIsSlow } from '@/hooks/useIsSlow';
+import { useCardOnScreen } from '@/hooks/useCardOnScreen';
import { useListQuery } from '@/hooks/useListQuery';
import { useRemoteData } from '@/hooks/useRemoteData';
import { getSessionClients } from '@/lib/auth/clients';
@@ -18,13 +18,10 @@ import { userNameOf } from '@/lib/user-display';
import { messages } from '@/messages/fr';
import type { AdminUserDetail } from '@widoo/shared';
import { Button, DetailPanel, DetailPanelSkeleton, Pagination, SearchInput } from '@widoo/ui';
-import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
const texts = messages.users;
const searchDelayMs = 300;
-/** A card that loads faster than this keeps the previous one on screen, without a gray panel. */
-const skeletonDelayMs = 300;
/** Parameter of the address that holds the account whose card is open next to the list. */
const openUserParam = 'compte';
@@ -45,16 +42,11 @@ export function UsersContainer() {
openId ? getSessionClients().api.getAdminUser(openId, signal) : Promise.resolve(null),
);
const data = users.status === 'ready' ? users.data : undefined;
- const openUser = card.status === 'ready' ? card.data : null;
- // The card shown last, kept on screen while the next one loads, so that the panel does not
- // disappear and the list does not widen and narrow again at every click.
- const [lastUser, setLastUser] = useState(null);
- if (openUser && openUser !== lastUser) {
- setLastUser(openUser);
- } else if (!openId && lastUser) {
- setLastUser(null);
- }
- const isCardSlow = useIsSlow(openId !== null && card.status === 'loading', skeletonDelayMs);
+ const {
+ fresh: openUser,
+ isLoading: isCardLoading,
+ stale: staleUser,
+ } = useCardOnScreen(openId, card);
/** The address of the list as it is, with the card of `userId` open, or none. */
const showUser = (userId: string | null) => {
@@ -100,12 +92,12 @@ export function UsersContainer() {
if (openUser) {
return renderCard(openUser);
}
- if (openId === null || card.status !== 'loading') {
+ if (!isCardLoading) {
return null;
}
- return lastUser && !isCardSlow ? (
+ return staleUser ? (