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 :
useTranslation() interne → les libellés passent par une prop labels*Props) spreadés sur les éléments cibles avec twMergeonSuccess / onError / onClose au lieu d'effets de bord internes<Modal>, <Button>, <Input>, etc.Voir aussi SmartCommon pour la liste complète des composants.
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.
import { LoginComponent } from '@cap-rel/smartcommon';
<LoginComponent
onSuccess={(user) => navigate("/")}
onError={(err) => log.error(err)}
/>
<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}
/>
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).
required HTML5 sur email et mot de passe (UX champs vides gérée par le navigateur)
<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>
Formulaire d'identification d'appareil pour SmartAuth. Lit useApi().user.deviceOptions pour décider du rendu :
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"),
}}
/>
containerProps, formProps, iconWrapperProps, iconProps, titleProps, descriptionProps, devicesCheckerProps, labelInputProps, submitButtonProps, errorAlertProps.
icon (defaut MdDevices) peut être remplacé ou désactivé (icon={null})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.
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>.
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>
| 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.
requireAuth + requireGuest ou les deux modes device détectés via console.warn. Le mode listé en premier l'emporte.
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.
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"),
}}
/>
fields est optionnel : tableau de { label, value } affichés en lignesÀ propos, Vérifier les mises à jour, etc.)
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.
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",
}}
/>
continuous={true} garde le scanner ouvert après chaque scan (utile pour saisie en lot)formats : noms html5-qrcode (QRCODE, EAN13, CODE_128, etc.)
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.
| 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.
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 produitscategoryId === null → produits sans catégoriecategoryId === <number> → produits de cette catégoriequery : texte libre (debounce 300 ms géré par le composant)type : filtre passthrough (ex : Dolibarr 0=produit / 1=service)
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 :
products, categories, productDocuments, categoryDocumentsproduct.categories[] dénormaliséforsale/tosell/statussell pour exclure les inactifs“product” ↔ [0, “0”, “product”]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.
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.
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.
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.
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.
{
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
}
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
}
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}
/>
),
}
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}
/>
),
};
TypePicker (ou éditeur direct si 1 seul type)onAnnotationSelect)onAnnotationActivate (drill-in)onChange déclenché une fois sur pointerup[minZoom, maxZoom])window.confirm pour suppression)readOnly désactive add/edit/delete/drag ; tap et double tap restent actifs
src accepte :
createObjectURL/revokeObjectURL via useImageUrl)
listPosition :
“bottom” (défaut) → liste sous l'image“right” → sidebar (desktop)“off” → pas de liste, l'app rend la sienneQuelques 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 |