Advanced Components (High-Level)
SmartCommon exposes several "business" components that encapsulate a complete flow (login, device identification, QR/barcode scanning, product catalog navigation, photo annotation, modal "About"). Rather than reinventing these screens in each application, they are injected directly.
All follow the same convention:
- No internal
useTranslation()-> labels are passed via alabelsprop - Style slots (
*Props) spread on target elements withtwMerge onSuccess/onError/onCloseinstead of internal side effects- Compose primitive components
<Modal>,<Button>,<Input>, etc.
See also SmartCommon for the complete list of components.
LoginComponent
Complete Dolibarr login form (email + password + optional entity selection + "Remember me" checkbox) with built-in smartAuth QR pair flow by default.
Basic Usage
import { LoginComponent } from '@cap-rel/smartcommon';
<LoginComponent
onSuccess={(user) => navigate("/")}
onError={(err) => log.error(err)}
/>
With Labels and Options
<LoginComponent
onSuccess={handleSuccess}
onError={handleError}
showRememberMe // default: false
showEntities // default: true
enableQrPair // default: true
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"),
}}
// Custom error label mapping (optional)
getErrorLabel={(err) => err?.statusCode === 401 ? "Invalid credentials" : null}
/>
Style Slots
containerProps, formProps, inputProps, passwordInputProps, selectProps, booleanProps, submitButtonProps, scanQrButtonProps, qrSeparatorProps, qrOverlayProps, errorAlertProps, qrErrorAlertProps.
Each slot is spread on the target element with twMerge, so Tailwind classes resolve correctly (gap-4 vs gap-6).
Key Behaviors
- HTML5
requiredon email and password (empty field UX handled by browser) - QR Scan -> claim -> loop polling with global timeout and "Cancel" button
- Idempotency guard: duplicate scan is ignored (avoids 409 on Android with autofocus)
- Full-screen overlay with spinner during claim/poll, camera closed immediately after scan
Tip
QR pair is enabled by default because SmartCommon assumes the backend is SmartAuth (which ships /qr-pair). To disable: enableQrPair={false}.
DeviceIdentificationComponent
Device identification form for SmartAuth. Reads useApi().user.deviceOptions to decide rendering:
- Empty / absent -> "Device name" input only (first device)
- Present -> "Choose an existing device" radio + "New device" option that reveals the 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"),
}}
/>
Style Slots
containerProps, formProps, iconWrapperProps, iconProps, titleProps, descriptionProps, devicesCheckerProps, labelInputProps, submitButtonProps, errorAlertProps.
Notes
icon(defaultMdDevices) can be replaced or disabled (icon={null})- On submit,
api.identifyDevice({ label, uuid })is called. The smartAuth endpoint cleansuser.deviceOptionson the server side, no need to manually dispatch. noDeviceValue(default"noDevice") : value of the "New device" option. Change if it collides with an existing UUID.
RouteGuard
Guard component for react-router that replaces 4 historical custom layouts (PublicPagesLayout, PrivatePagesLayout, PreDeviceIdentificationLayout, PostDeviceIdentificationLayout).
Reads useApi().user (and user.deviceOptions for device modes) and either renders its children (or <Outlet /> if used as route element), or redirects via <Navigate>.
Four Mutually Orthogonal Modes
import { RouteGuard } from '@cap-rel/smartcommon';
import { Routes, Route } from 'react-router-dom';
<Routes>
// Public pages without user (login, register, forgot-password)
<Route element={<RouteGuard requireGuest />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/welcome" element={<WelcomePage />} />
</Route>
// Device identification page: authenticated user + deviceOptions present
<Route element={<RouteGuard requireDeviceIdentification />}>
<Route path="/device-identification" element={<DeviceIdentificationPage />} />
</Route>
// All private pages: authenticated user + deviceOptions absent
<Route element={<RouteGuard requireDeviceIdentified />}>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
// Less common: authenticated, regardless of device
<Route element={<RouteGuard requireAuth />}>
<Route path="/profile" element={<ProfilePage />} />
</Route>
</Routes>
Default Redirections
| Mode | Redirects to |
|---|---|
requireAuth |
/login |
requireGuest |
/ |
requireDeviceIdentification |
/ (already identified) |
requireDeviceIdentified |
/device-identification (to be identified) |
Can be overridden via redirectTo. Device modes always imply requireAuth: if no user, redirects to /login regardless of redirectTo.
Conflicts
requireAuth + requireGuest or both device modes detected via console.warn. The first listed mode wins.
AboutModal
"About" modal displaying application name, version and free fields. Integrated "Check for updates" button that uses usePWAUpdate to restart the 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: "User", 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
fieldsis optional: array of{ label, value }displayed as rows- Labels are in French by default with accents ("À propos", "Vérifier les mises à jour", etc.)
- For custom UI, use usePWAUpdate directly
BarcodeScanner
Full-screen QR/barcode scanner. Lazy-loads the html5-qrcode dependency (~150 kB) on first open, so apps that never scan don't pay the bundle cost. Falls back to manual entry if camera permission is denied.
Usage
import { BarcodeScanner } from '@cap-rel/smartcommon';
const [open, setOpen] = useState(false);
<BarcodeScanner
open={open}
onScan={(text) => addToCart(text)}
onClose={() => setOpen(false)}
continuous={false} // closes after each scan (default)
formats={["QR_CODE", "EAN_13"]} // 7 common formats by default
debounceMs={1500}
labels={{
title: "Scanner",
cancelButton: "Cancel",
manualEntry: "Manual entry",
}}
/>
Notes
continuous={true}keeps the scanner open after each scan (useful for batch entry)formats:html5-qrcodenames (QR_CODE,EAN_13,CODE_128, etc.)- The component manages camera closing, unmount, permissions itself
ProductCategoryBrowser
Full-screen modal that allows the user to navigate through a product catalog by category and select one (or multiple). Used anywhere an app needs to attach products to an entity: invoice/quote lines, photo annotations (smartintervention), inventory, repair quotes, etc.
Three Modes
| Mode | Confirmation Step | onSelect Payload (single) |
|---|---|---|
select |
no | product |
quantity |
quantity input | { product, qty } |
quantity-discount |
qty + discount + total | { product, qty, discountPercent, computedTotal } |
With multiple={true}, a cart at the page footer is maintained and onSelect is called once on validation with an array of the same format.
Adapters (No Dexie Coupling)
Data is fetched via two adapters that the app provides:
// Expected shape
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-> all productscategoryId === null-> products without categorycategoryId === <number>-> products in that categoryquery: free text (300ms debounce managed by component)type: passthrough filter (e.g., Dolibarr 0=product / 1=service)
Dexie Helper for offlinepropale-style Apps
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=product, 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)}
/>
The helper is calibrated on the offlinepropale Dexie schema:
- Tables
products,categories,productDocuments,categoryDocuments - Many-to-many via denormalized
product.categories[] - Filters
for_sale/tosell/status_sellto exclude inactive - Category type alias:
"product"<->[0, "0", "product"] - Bulk image joins from
productDocuments/categoryDocumentsfilteredtype === "image"
All table/field names are overrideable via options. For very large catalogs (>5000 products) pass attachImages: false and provide a custom renderItem that lazy loads thumbnails.
Editing an Existing Line
prefillProduct (with defaultQty / defaultDiscountPercent) opens directly on the confirmation step:
<ProductCategoryBrowser
open={open}
mode="quantity-discount"
prefillProduct={annotation.product}
defaultQty={annotation.qty}
defaultDiscountPercent={annotation.remise_percent}
onSelect={(payload) => updateAnnotation(annotation.id, payload)}
onClose={...}
/>
The user can tap "Change product" to return to the grid; the qty/discount fields are then reset to the default values of the new product.
Price Hook
getProductPriceDisplay(product, customerContext) returns:
{
unitPrice, // number
displayPrice?, // number
displayPriceLabel?, // string: rendered verbatim if provided
currency,
badge?, // discount badge (e.g., "-20%")
ttc?, // boolean
}
customerContext is free: customer id, price level, currency, etc. The component just passes it to the hook.
PhotoAnnotator
Allows users to place markers on a photo, each marker linked to a business object that the app defines (note, product, alert, sub-photo). Used by smartintervention (technician marking defective parts), repair quotes, building inspection, offlinepropale photo quotes, etc.
Two Modes
Controlled (in-memory state, simple):
import { PhotoAnnotator } from '@cap-rel/smartcommon';
const [annotations, setAnnotations] = useState([]);
<PhotoAnnotator
src={photo.url}
annotations={annotations}
onChange={setAnnotations}
annotationTypes={...}
/>
Event-based (backend persistence, granular callbacks):
<PhotoAnnotator
src={photo.url}
initialAnnotations={anns} // loaded once; pass a new ref to resync
annotationTypes={...}
onCreate={async (staged) => {
const id = await db.annotations.add({...});
return { ...staged, id }; // component adopts the new 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); }}
/>
Event-based mode is automatically detected when any of onCreate / onUpdate / onMove / onDelete / initialAnnotations is provided.
Annotation Shape
{
id: string|number, // stable identifier
type: string, // key in annotationTypes
x: number, // 0..100 (percentage)
y: number, // 0..100 (percentage)
payload?: object, // free, owned by the type
}
Type Registry
Each entry in annotationTypes is a 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, // no modal, see below
}
Headless Editor
For types that do not require modal UI (e.g., trigger a file input then save), set headlessEditor: true. The component then mounts the return value of renderEditor directly, without modal wrapping:
photo: {
label: "Detailed Photo",
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 with ProductCategoryBrowser
A "product" type delegates its editor to the catalog:
const productType = {
label: "Product",
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 background ->
TypePicker(or direct editor if only 1 type) - "+ Add" button -> creates at center (50%, 50%), persists, opens editor
- Tap marker -> selection (
onAnnotationSelect) - Double tap marker ->
onAnnotationActivate(drill-in) - Long press marker -> drag;
onChangetriggered once onpointerup - Pinch / wheel -> zoom (clamped
[minZoom, maxZoom]) - Drag one finger on zoomed background -> pan
- Edit/delete buttons in list (
window.confirmfor deletion) readOnlydisables add/edit/delete/drag; tap and double tap remain active
Image Source
src accepts:
- URL string (passthrough)
- Blob / File (auto
createObjectURL/revokeObjectURLviauseImageUrl)
Layout
listPosition:
"bottom"(default) -> list below image"right"-> sidebar (desktop)"off"-> no list, the app renders its own
Exported Helpers
Some utilities are exported alongside components:
| Helper | Description |
|---|---|
createDexieProductCategoryAdapters |
Builds both productsAdapter / categoriesAdapter from a Dexie instance compatible with the offlinepropale schema |
extractPairingId |
Parses a scanned QR payload (raw string, URL /qrpair/..., JSON pairing_id) and returns the 32-hex pairing_id or null |
buildDefaultGetQrErrorLabel(labels) |
Builds the default getQrErrorLabel function used in <LoginComponent>. Re-exported to be reused if the app partially overrides the mapping |
twMerge |
Re-export of tailwind-merge. To be used to merge default className + consumer className (otherwise gap-4/gap-6 conflicts are not resolved correctly) |
See Also
- SmartCommon - Complete Component List
- Hooks - useApi (claimQrPair / pollQrPair / identifyDevice), usePWAUpdate
- Routing - Detailed RouteGuard Usage
- SmartAuth - QR Pair Backend and Device Identification
- PWA - Service Worker Update Strategy