SmarMaker - Documentation
Docs» 03_front:composants_avances

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 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

<note 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}.</note>

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 redirectTo="/device-identification" />}>
    <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 /identify-device (à 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 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

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 (QRCODE, EAN13, 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 forsale/tosell/statussell 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
Previous Next

Made with ❤ by CAP-REL · SmartMaker · GNU AGPL v3+
Code source · Faire un don
SmarMaker - Documentation
Traductions de cette page:
  • Français
  • Deutsch
  • English
  • Español
  • Italiano
  • Nederlands

Table of Contents

Table des matières

  • Composants avancés (high-level)
    • LoginComponent
      • Usage de base
      • Avec libellés et options
      • Slots de styling
      • Comportements clés
    • DeviceIdentificationComponent
      • Usage
      • Slots de styling
      • Notes
    • RouteGuard
      • Quatre modes mutuellement orthogonaux
      • Redirections par défaut
      • Conflits
    • AboutModal
      • Usage
      • Notes
    • BarcodeScanner
      • Usage
      • Notes
    • ProductCategoryBrowser
      • Trois modes
      • Adaptateurs (pas de couplage Dexie)
      • Helper Dexie pour les apps offlinepropale-style
      • Édition d'une ligne existante
      • Hook de prix
    • PhotoAnnotator
      • Deux modes
      • Forme d'une annotation
      • Registre de types
      • Éditeur "headless"
      • Composition avec ProductCategoryBrowser
      • Interactions
      • Source d'image
      • Layout
    • Helpers exportés
  • SmartAuth
  • SmartMaker - Back (PHP)
    • Mapping Dolibarr - React
  • SmartMaker - Front (React)
    • Animations de pages
    • Architecture
    • Astuces
    • Calendar
    • Composants avancés (high-level)
    • Composants et pages
    • Configuration du Provider
    • Debug et Logs
    • Hooks SmartCommon
    • PWA (Progressive Web App)
    • Requêtes API
    • Routage
    • SmartCommon
    • Stockage de données
    • Synchronisation offline
    • Thèmes
    • Traductions
  • HowTo - Pas à pas - Votre première application
    • Développement PHP (back)
    • Développement React (front)
    • Première étape : Module Builder Dolibarr
    • SmartAuth
    • SmartBoot : Un squelette prêt à l'emploi
  • Formation SmartMaker
    • Module 1 : Fondamentaux JavaScript ES6+
      • Chapitre 1 : Variables et Scope
      • Chapitre 2 : Fonctions
      • Chapitre 3 : Programmation Asynchrone
      • Chapitre 4 : Modules ES6
    • Module 2 : Introduction à React
      • Chapitre 1 : Philosophie React
      • Chapitre 2 : JSX
      • Chapitre 3 : Composants
    • Module 3 : Hooks React Fondamentaux
      • Chapitre 1 : useState
      • Chapitre 2 : useEffect
      • Chapitre 3 : useRef
      • Chapitre 4 : useContext
    • Module 4 : React Avancé
      • Chapitre 1 : useCallback et useMemo
      • Chapitre 2 : Custom Hooks
      • Chapitre 3 : Redux et Redux Toolkit
    • Module 5 : Architecture SmartMaker
      • Chapitre 1 : Structure du projet
      • Chapitre 2 : Configuration
      • Chapitre 3 : Flux de données
    • Module 6 : SmartCommon - Composants
      • Chapitre 1 : Mise en page
      • Chapitre 2 : Navigation
      • Chapitre 3 : Formulaires
      • Chapitre 4 : Affichage
    • Module 7 : SmartCommon - Hooks
      • Chapitre 1 : useApi
      • Chapitre 2 : Gestion d'état
      • Chapitre 3 : Hooks utilitaires
      • Chapitre 4 : Synchronisation Offline
    • Module 8 : Backend API (PHP)
      • Chapitre 1 : Routage
      • Chapitre 2 : Controllers
      • Chapitre 3 : Mappers
      • Extrafields et formulaires dynamiques
    • Module 9 : Intégration complète
      • Chapitre 1 : Backend
      • Chapitre 2 : Frontend
      • Chapitre 3 : Déploiement
    • Module 10 : Fonctionnalités avancées
      • Chapitre 1 : Mode offline
      • Chapitre 2 : Internationalisation (i18n)
      • Chapitre 3 : Autres fonctionnalités
    • Module 11 : Bonnes pratiques
  • Démonstration
  • Start
  • Composants et pages
  • Afficher le texte source
  • Anciennes révisions
  • Liens de retour
  • Haut de page