---
title: "Composants avancés (high-level)"
weight: 90
---

# Composants avancés (high-level)

SmartCommon expose plusieurs composants "métier" qui encapsulent un flux complet (login, identification d'appareil, scan de QR/codes-barres, navigation dans un catalogue produit, annotation de photos, modal "À propos"). Plutôt que de réinventer ces écrans dans chaque application, on les injecte directement.

Tous suivent la même convention :
- Aucun `useTranslation()` interne -> les libellés passent par une prop `labels`
- Slots de styling (`*Props`) spreadés sur les éléments cibles avec `twMerge`
- `onSuccess` / `onError` / `onClose` au lieu d'effets de bord internes
- Composent les primitives `<Modal>`, `<Button>`, `<Input>`, etc.

Voir aussi [SmartCommon](/front/smartcommon) pour la liste complète des composants.

## LoginComponent

Formulaire de connexion Dolibarr complet (email + mot de passe + sélection d'entité optionnelle + checkbox "Se souvenir de moi") avec **flux QR pair smartAuth intégré** par défaut.

### Usage de base

```
import { LoginComponent } from '@cap-rel/smartcommon';

<LoginComponent
  onSuccess={(user) => navigate("/")}
  onError={(err) => log.error(err)}
/>
```

### Avec libellés et options

```
<LoginComponent
  onSuccess={handleSuccess}
  onError={handleError}
  showRememberMe                          // false par défaut
  showEntities                            // true par défaut
  enableQrPair                            // true par défaut
  deviceLabel={navigator.userAgent}
  qrPollIntervalMs={2000}
  qrTimeoutMs={120000}
  labels={{
    emailLabel: t("login.email"),
    passwordLabel: t("login.password"),
    submitLabel: t("login.submit"),
    scanQrLabel: t("login.scan-qr"),
    qrSeparator: t("login.or"),
  }}
  // Mapping d'erreurs custom (optionnel)
  getErrorLabel={(err) => err?.statusCode === 401 ? "Identifiants invalides" : null}
/>
```

### Slots de styling

`containerProps`, `formProps`, `inputProps`, `passwordInputProps`, `selectProps`, `booleanProps`, `submitButtonProps`, `scanQrButtonProps`, `qrSeparatorProps`, `qrOverlayProps`, `errorAlertProps`, `qrErrorAlertProps`.

Chaque slot est étalé sur l'élément cible avec `twMerge`, donc les classes Tailwind se résolvent correctement (`gap-4` vs `gap-6`).

### Comportements clés
- `required` HTML5 sur email et mot de passe (UX champs vides gérée par le navigateur)
- Scan QR -> claim -> poll en boucle avec timeout global et bouton "Annuler"
- Garde d'idempotence : un scan dupliqué est ignoré (évite le 409 sur Android avec autofocus)
- Overlay plein écran avec spinner pendant claim/poll, caméra fermée immédiatement après scan

> [!TIP]
> Le QR pair est activé par défaut car SmartCommon présume que le backend est SmartAuth (qui ship `/qr-pair`). Pour désactiver : `enableQrPair={false}`.

## DeviceIdentificationComponent

Formulaire d'identification d'appareil pour SmartAuth. Lit `useApi().user.deviceOptions` pour décider du rendu :
- Vide / absent -> input "Nom de l'appareil" uniquement (premier device)
- Présent -> radio "Choisir un appareil existant" + option "Nouvel appareil" qui révèle l'input

### Usage

```
import { DeviceIdentificationComponent } from '@cap-rel/smartcommon';

<DeviceIdentificationComponent
  onSuccess={() => navigate("/")}
  onError={(err) => toast.error(err.message)}
  labels={{
    title: t("device.title"),
    devicesDescription: t("device.choose-existing"),
    noDevicesDescription: t("device.first-device"),
    devicesCheckerLabel: t("device.devices"),
    noDeviceLabel: t("device.new-device"),
    newDeviceInputLabel: t("device.label"),
    newDeviceInputHelp: t("device.help"),
    submitLabel: t("device.submit"),
  }}
/>
```

### Slots de styling

`containerProps`, `formProps`, `iconWrapperProps`, `iconProps`, `titleProps`, `descriptionProps`, `devicesCheckerProps`, `labelInputProps`, `submitButtonProps`, `errorAlertProps`.

### Notes
- `icon` (defaut `MdDevices`) peut être remplacé ou désactivé (`icon={null}`)
- Au submit, `api.identifyDevice({ label, uuid })` est appelé. L'endpoint smartAuth nettoie `user.deviceOptions` côté serveur, **pas besoin** de dispatcher manuellement.
- `noDeviceValue` (défaut `"noDevice"`) : valeur de l'option "Nouvel appareil". À changer si elle entre en collision avec un UUID existant.

## RouteGuard

Composant de garde pour `react-router` qui remplace 4 layouts custom historiques (`PublicPagesLayout`, `PrivatePagesLayout`, `PreDeviceIdentificationLayout`, `PostDeviceIdentificationLayout`).

Lit `useApi().user` (et `user.deviceOptions` pour les modes device) et soit rend ses enfants (ou `<Outlet />` s'il est utilisé comme route element), soit redirige via `<Navigate>`.

### Quatre modes mutuellement orthogonaux

```
import { RouteGuard } from '@cap-rel/smartcommon';
import { Routes, Route } from 'react-router-dom';

<Routes>
  // Pages publiques sans utilisateur (login, register, forgot-password)
  <Route element={<RouteGuard requireGuest />}>
    <Route path="/login" element={<LoginPage />} />
    <Route path="/welcome" element={<WelcomePage />} />
  </Route>

  // Page d'identification d'appareil : user authentifié + deviceOptions présent
  <Route element={<RouteGuard requireDeviceIdentification />}>
    <Route path="/device-identification" element={<DeviceIdentificationPage />} />
  </Route>

  // Toutes les pages privées : user authentifié + deviceOptions absent
  <Route element={<RouteGuard requireDeviceIdentified />}>
    <Route path="/" element={<HomePage />} />
    <Route path="/settings" element={<SettingsPage />} />
  </Route>

  // Cas plus rare : authentifié, peu importe le device
  <Route element={<RouteGuard requireAuth />}>
    <Route path="/profile" element={<ProfilePage />} />
  </Route>
</Routes>
```

### Redirections par défaut

| Mode | Redirige vers |
| --- | --- |
| `requireAuth` | `/login` |
| `requireGuest` | `/` |
| `requireDeviceIdentification` | `/` (déjà identifié) |
| `requireDeviceIdentified` | `/device-identification` (à identifier) |

Surchargeable via `redirectTo`. Les modes device impliquent toujours `requireAuth` : si pas d'utilisateur, redirige vers `/login` quel que soit le `redirectTo`.

### Conflits

`requireAuth` + `requireGuest` ou les deux modes device détectés via `console.warn`. Le mode listé en premier l'emporte.

## AboutModal

Modal "À propos" affichant le nom de l'application, la version et des champs libres. Bouton intégré "Vérifier les mises à jour" qui utilise [usePWAUpdate](/front/hooks#usepwaupdate) pour relancer le Service Worker.

### Usage

```
import { AboutModal } from '@cap-rel/smartcommon';
import { APP_VERSION } from 'src/utils';

<AboutModal
  open={isOpen}
  onClose={() => setIsOpen(false)}
  appName="SmartPOS"
  version={APP_VERSION}
  fields={[
    { label: "Backend", value: prefixUrl },
    { label: "Utilisateur", value: user?.email },
  ]}
  labels={{
    title: t("about.title"),
    checkUpdates: t("about.check-updates"),
    upToDate: t("about.up-to-date"),
    installUpdate: t("about.install"),
    close: t("about.close"),
  }}
/>
```

### Notes
- `fields` est optionnel : tableau de `{ label, value }` affichés en lignes
- Les libellés sont en français par défaut avec accents (`À propos`, `Vérifier les mises à jour`, etc.)
- Pour une UI custom, utiliser directement [usePWAUpdate](/front/hooks#usepwaupdate)

## BarcodeScanner

Scanner QR / codes-barres plein écran. **Lazy-load** la dépendance `html5-qrcode` (~150 kB) à la première ouverture, donc les apps qui ne scannent jamais ne paient pas le coût du bundle. Fallback en saisie manuelle si la permission caméra est refusée.

### Usage

```
import { BarcodeScanner } from '@cap-rel/smartcommon';

const [open, setOpen] = useState(false);

<BarcodeScanner
  open={open}
  onScan={(text) => addToCart(text)}
  onClose={() => setOpen(false)}
  continuous={false}                  // ferme après chaque scan (défaut)
  formats={["QR_CODE", "EAN_13"]}     // 7 formats courants par défaut
  debounceMs={1500}
  labels={{
    title: "Scanner",
    cancelButton: "Annuler",
    manualEntry: "Saisie manuelle",
  }}
/>
```

### Notes
- `continuous={true}` garde le scanner ouvert après chaque scan (utile pour saisie en lot)
- `formats` : noms `html5-qrcode` (`QR_CODE`, `EAN_13`, `CODE_128`, etc.)
- Le composant gère seul la fermeture de la caméra, l'unmount, les permissions

## ProductCategoryBrowser

Modal plein écran qui permet à l'utilisateur de naviguer dans un catalogue produit par catégorie et d'en sélectionner un (ou plusieurs). Utilisé partout où une app doit attacher des produits à une entité : lignes de facture/devis, annotations photo (`smartintervention`), inventaire, devis de réparation, etc.

### Trois modes

| Mode | Étape de confirmation | Payload `onSelect` (single) |
| --- | --- | --- |
| `select` | non | `product` |
| `quantity` | input quantité | `{ product, qty }` |
| `quantity-discount` | qté + remise + total | `{ product, qty, discountPercent, computedTotal }` |

Avec `multiple={true}`, un panier en pied de page est tenu et `onSelect` est appelé une fois à la validation avec un tableau du même format.

### Adaptateurs (pas de couplage Dexie)

Les données sont fetchées via deux adaptateurs que l'app fournit :

```
// Forme attendue
productsAdapter = {
  search:  ({ categoryId, query, type }) => Promise<Product[]>,
  getById: (id) => Promise<Product>,
};

categoriesAdapter = {
  getRoots:    (type)     => Promise<Category[]>,
  getChildren: (parentId) => Promise<Category[]>,
  getById:     (id)       => Promise<Category>,
};
```

Conventions :
- `categoryId === undefined` -> tous les produits
- `categoryId === null` -> produits sans catégorie
- `categoryId === <number>` -> produits de cette catégorie
- `query` : texte libre (debounce 300 ms géré par le composant)
- `type` : filtre passthrough (ex : Dolibarr 0=produit / 1=service)

### Helper Dexie pour les apps offlinepropale-style

```
import { ProductCategoryBrowser, createDexieProductCategoryAdapters } from '@cap-rel/smartcommon';
import db from 'src/db';

const { productsAdapter, categoriesAdapter } = createDexieProductCategoryAdapters({ db });

<ProductCategoryBrowser
  open={open}
  onClose={() => setOpen(false)}
  mode="quantity-discount"
  productsAdapter={productsAdapter}
  categoriesAdapter={categoriesAdapter}
  productType={0}                                 // 0=produit, 1=service
  customerContext={{ priceLevel: customer.priceLevel }}
  getProductPriceDisplay={(product, ctx) => ({
    unitPrice: product.price,
    displayPriceLabel: `${product.price} ${ctx.currency}`,
    badge: product.discount ? `-${product.discount}%` : null,
  })}
  onSelect={(payload) => addLine(payload)}
/>
```

Le helper est calibré sur le schéma Dexie d'offlinepropale :
- Tables `products`, `categories`, `productDocuments`, `categoryDocuments`
- Many-to-many via `product.categories[]` dénormalisé
- Filtres `for_sale`/`tosell`/`status_sell` pour exclure les inactifs
- Alias type catégorie : `"product"` <-> `[0, "0", "product"]`
- Join images en bulk depuis `productDocuments`/`categoryDocuments` filtrés `type === "image"`

Tous les noms de tables/champs sont surchargeable via options. Pour de très gros catalogues (>5000 produits) passer `attachImages: false` et fournir un `renderItem` custom qui charge les vignettes en lazy.

### Édition d'une ligne existante

`prefillProduct` (avec `defaultQty` / `defaultDiscountPercent`) ouvre directement sur l'étape de confirmation :

```
<ProductCategoryBrowser
  open={open}
  mode="quantity-discount"
  prefillProduct={annotation.product}
  defaultQty={annotation.qty}
  defaultDiscountPercent={annotation.remise_percent}
  onSelect={(payload) => updateAnnotation(annotation.id, payload)}
  onClose={...}
/>
```

L'utilisateur peut taper "Changer de produit" pour revenir à la grille ; les champs qté/remise sont alors réinitialisés aux valeurs par défaut du nouveau produit.

### Hook de prix

`getProductPriceDisplay(product, customerContext)` retourne :

```
{
  unitPrice,           // number
  displayPrice?,       // number
  displayPriceLabel?,  // string : rendu verbatim si fourni
  currency,
  badge?,              // pastille remise (ex : "-20%")
  ttc?,                // boolean
}
```

`customerContext` est libre : id client, niveau de prix, devise, etc. Le composant le passe juste au hook.

## PhotoAnnotator

Permet à l'utilisateur de placer des marqueurs sur une photo, chaque marqueur lié à un objet métier que l'app définit (note, produit, alerte, sous-photo). Utilisé par smartintervention (technicien marquant les pièces défectueuses), devis de réparation, inspection bâtiment, devis photo offlinepropale, etc.

### Deux modes

**Contrôlé** (état en mémoire, simple) :

```
import { PhotoAnnotator } from '@cap-rel/smartcommon';

const [annotations, setAnnotations] = useState([]);

<PhotoAnnotator
  src={photo.url}
  annotations={annotations}
  onChange={setAnnotations}
  annotationTypes={...}
/>
```

**Event-based** (persistance backend, callbacks granulaires) :

```
<PhotoAnnotator
  src={photo.url}
  initialAnnotations={anns}             // chargé une fois ; passer une nouvelle ref pour resync
  annotationTypes={...}
  onCreate={async (staged) => {
    const id = await db.annotations.add({...});
    return { ...staged, id };           // le composant adopte le nouvel id
  }}
  onUpdate={async (annotation) => { await db.update(annotation.id, ...); }}
  onMove={async (annotation, { x, y }) => { await db.update(annotation.id, { pos_x: x, pos_y: y }); }}
  onDelete={async (annotation) => { await db.delete(annotation.id); }}
/>
```

Le mode event-based est détecté automatiquement dès que l'un de `onCreate` / `onUpdate` / `onMove` / `onDelete` / `initialAnnotations` est fourni.

### Forme d'une annotation

```
{
  id: string|number,    // identifiant stable
  type: string,         // clé dans annotationTypes
  x: number,            // 0..100 (pourcentage)
  y: number,            // 0..100 (pourcentage)
  payload?: object,     // libre, possédé par le type
}
```

### Registre de types

Chaque entrée de `annotationTypes` est un `TypeDef` :

```
{
  label: string,
  icon: ReactNode,
  color?: string,
  newPayload?: () => object,
  renderMarker:    (annotation, ctx) => ReactNode, // ctx = { num, selected, dragging, readOnly }
  renderEditor:    (annotation, ctx) => ReactNode, // ctx = { onSave(partial), onCancel, typeDef }
  renderListItem?: (annotation, ctx) => ReactNode,
  headlessEditor?: boolean,                        // pas de modal, voir ci-dessous
}
```

### Éditeur "headless"

Pour les types qui ne nécessitent pas d'UI modale (ex : déclencher un input fichier puis sauver), positionner `headlessEditor: true`. Le composant monte alors la valeur de retour de `renderEditor` directement, sans habillage modal :

```
photo: {
  label: "Photo détaillée",
  icon: <FaCamera />,
  headlessEditor: true,
  renderMarker: (a, { num }) => <CameraCircle num={num} />,
  renderEditor: (a, { onSave, onCancel }) => (
    <PhotoCaptureFlow
      onCaptured={async (blob) => {
        const targetPhotoId = await imagesService.create(blob);
        onSave({ payload: { targetPhotoId } });
      }}
      onCancel={onCancel}
    />
  ),
}
```

### Composition avec ProductCategoryBrowser

Un type "produit" délègue son éditeur au catalogue :

```
const productType = {
  label: "Produit",
  icon: <FaBoxesStacked />,
  color: "#3B82F6",
  renderMarker: (a, { num }) => <Circle color="#3B82F6">{num}</Circle>,
  renderEditor: (a, { onSave, onCancel }) => (
    <ProductCategoryBrowser
      open
      mode="quantity-discount"
      productsAdapter={productsAdapter}
      categoriesAdapter={categoriesAdapter}
      prefillProduct={a.payload?.fk_product ? { id: a.payload.fk_product } : undefined}
      defaultQty={a.payload?.qty || 1}
      defaultDiscountPercent={a.payload?.remise_percent || 0}
      onSelect={({ product, qty, discountPercent, computedTotal }) =>
        onSave({ payload: {
          fk_product: product.id,
          qty,
          remise_percent: discountPercent,
          computed_total: computedTotal,
        } })
      }
      onClose={onCancel}
    />
  ),
};
```

### Interactions
- Long press fond -> `TypePicker` (ou éditeur direct si 1 seul type)
- Bouton "+ Ajouter" -> crée au centre (50%, 50%), persiste, ouvre l'éditeur
- Tap marqueur -> sélection (`onAnnotationSelect`)
- Double tap marqueur -> `onAnnotationActivate` (drill-in)
- Long press marqueur -> drag ; `onChange` déclenché une fois sur `pointerup`
- Pinch / molette -> zoom (clampé `[minZoom, maxZoom]`)
- Drag un doigt sur fond zoomé -> pan
- Boutons éditer/supprimer dans la liste (`window.confirm` pour suppression)
- `readOnly` désactive add/edit/delete/drag ; tap et double tap restent actifs

### Source d'image

`src` accepte :
- URL string (passthrough)
- Blob / File (auto `createObjectURL`/`revokeObjectURL` via `useImageUrl`)

### Layout

`listPosition` :
- `"bottom"` (défaut) -> liste sous l'image
- `"right"` -> sidebar (desktop)
- `"off"` -> pas de liste, l'app rend la sienne

## Helpers exportés

Quelques utilitaires sont exportés à côté des composants :

| Helper | Description |
| --- | --- |
| `createDexieProductCategoryAdapters` | Construit les deux adaptateurs `productsAdapter` / `categoriesAdapter` à partir d'une instance Dexie compatible avec le schéma offlinepropale |
| `extractPairingId` | Parse un payload QR scanné (string brute, URL `/qrpair/...`, JSON `pairing_id`) et retourne le pairing_id 32-hex ou `null` |
| `buildDefaultGetQrErrorLabel(labels)` | Construit la fonction `getQrErrorLabel` par défaut utilisée dans `<LoginComponent>`. Réexposée pour être réutilisée si l'app override partiellement le mapping |
| `twMerge` | Re-export de `tailwind-merge`. À utiliser pour fusionner `className` par défaut + `className` consommateur (sinon les conflits `gap-4`/`gap-6` ne sont pas résolus correctement) |

## Voir aussi
- [SmartCommon](/front/smartcommon) - Liste complète des composants
- [Hooks](/front/hooks) - useApi (claimQrPair / pollQrPair / identifyDevice), usePWAUpdate
- [Routage](/front/routage) - Utilisation détaillée de RouteGuard
- [SmartAuth](/smartauth) - Backend QR pair et identification d'appareil
- [PWA](/front/pwa) - Stratégie de mise à jour Service Worker
