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 a labels prop
  • Style slots (*Props) spread on target elements with twMerge
  • onSuccess / onError / onClose instead 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 required on 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 (default MdDevices) can be replaced or disabled (icon={null})
  • On submit, api.identifyDevice({ label, uuid }) is called. The smartAuth endpoint cleans user.deviceOptions on 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

  • fields is 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-qrcode names (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 products
  • categoryId === null -> products without category
  • categoryId === <number> -> products in that category
  • query: 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_sell to exclude inactive
  • Category type alias: "product" <-> [0, "0", "product"]
  • Bulk image joins from productDocuments/categoryDocuments filtered type === "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; onChange triggered once on pointerup
  • Pinch / wheel -> zoom (clamped [minZoom, maxZoom])
  • Drag one finger on zoomed background -> pan
  • Edit/delete buttons in list (window.confirm for deletion)
  • readOnly disables add/edit/delete/drag; tap and double tap remain active

Image Source

src accepts:

  • URL string (passthrough)
  • Blob / File (auto createObjectURL/revokeObjectURL via useImageUrl)

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