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 proplabels - Slots de styling (
*Props) spreadés sur les éléments cibles avectwMerge onSuccess/onError/onCloseau 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
requiredHTML5 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(defautMdDevices) peut être remplacé ou désactivé (icon={null})- Au submit,
api.identifyDevice({ label, uuid })est appelé. L'endpoint smartAuth nettoieuser.deviceOptionscô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
fieldsest 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: nomshtml5-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 ^ PayloadonSelect(single) ^ |select| non |product| |quantity| input quantité |{ product, qty }| |quantity-discount| qté + remise + total |{ product, qty, discountPercent, computedTotal }| Avecmultiple={true}, un panier en pied de page est tenu etonSelectest 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-styleimport { 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 : * Tablesproducts,categories,productDocuments,categoryDocuments* Many-to-many viaproduct.categories[]dénormalisé * Filtresforsalepour exclure les inactifs * Alias type catégorie :/tosell/statussell“product”↔[0, “0”, “product”]* Join images en bulk depuisproductDocuments/categoryDocumentsfiltréstype === “image”Tous les noms de tables/champs sont surchargeable via options. Pour de très gros catalogues (>5000 produits) passerattachImages: falseet fournir unrenderItemcustom qui charge les vignettes en lazy. ### Édition d'une ligne existanteprefillProduct(avecdefaultQty/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 prixgetProductPriceDisplay(product, customerContext)retourne :{ unitPrice, // number displayPrice?, // number displayPriceLabel?, // string : rendu verbatim si fourni currency, badge?, // pastille remise (ex : "-20%") ttc?, // boolean }customerContextest 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 deonCreate/onUpdate/onMove/onDelete/initialAnnotationsest 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 deannotationTypesest unTypeDef:{ 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), positionnerheadlessEditor: true. Le composant monte alors la valeur de retour derenderEditordirectement, 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 ;onChangedéclenché une fois surpointerup* Pinch / molette → zoom (clampé[minZoom, maxZoom]) * Drag un doigt sur fond zoomé → pan * Boutons éditer/supprimer dans la liste (window.confirmpour suppression) *readOnlydésactive add/edit/delete/drag ; tap et double tap restent actifs ### Source d'imagesrcaccepte : * URL string (passthrough) * Blob / File (autocreateObjectURL/revokeObjectURLviauseImageUrl) ### LayoutlistPosition: *“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 adaptateursproductsAdapter/categoriesAdapterà partir d'une instance Dexie compatible avec le schéma offlinepropale | |extractPairingId| Parse un payload QR scanné (string brute, URL/qrpair/…, JSONpairingid) et retourne le pairing_id 32-hex ou ''null| |buildDefaultGetQrErrorLabel(labels)| Construit la fonctiongetQrErrorLabelpar défaut utilisée dans<LoginComponent>. Réexposée pour être réutilisée si l'app override partiellement le mapping | |twMerge| Re-export detailwind-merge. À utiliser pour fusionnerclassNamepar défaut +classNameconsommateur (sinon les conflitsgap-4/gap-6ne sont pas résolus correctement) | ## Voir aussi * SmartCommon - Liste complète des composants * Hooks - useApi (claimQrPair / pollQrPair / identifyDevice), usePWAUpdate * Routage - Utilisation détaillée de RouteGuard * SmartAuth - Backend QR pair et identification d'appareil * PWA - Stratégie de mise à jour Service Worker