Last updated: 2026-05-07
This file documents frontend-specific patterns. For general project guidelines see ../CLAUDE.md.
pnpm workspace (part of the monorepo root workspace, not a separate lockfile):
frontend/
├── apps/
│ ├── dashboard/ # Admin/seller dashboard
│ │ └── src/{composables, components, stores, views, locales}
│ └── point-of-sale/ # POS kiosk app (same structure)
└── lib/
├── common/ # Shared utilities, types, Pinia stores, API client
└── themes/ # Shared PrimeVue themes
No .editorconfig or .prettierrc under frontend/. Prettier config is the root .prettierrc.js, which extends @gewis/prettier-config. Line endings are enforced through Prettier's own lf default, not a .editorconfig.
Use pnpm, NOT yarn or npm. Install only at the repo root.
| Old (yarn, pre-monorepo) | New (pnpm, from repo root) |
|---|---|
yarn install |
pnpm install |
yarn dev-dashboard:local |
pnpm dev:dashboard |
yarn dev-pos:local |
pnpm dev:pos |
yarn build-libraries |
pnpm --filter @sudosos/themes build && pnpm --filter @sudosos/sudosos-frontend-common build |
yarn lint / yarn lint-fix |
pnpm frontend:lint / pnpm --filter './frontend/**' lint-fix |
yarn build |
pnpm frontend:build |
yarn format / yarn format-fix |
pnpm format / pnpm format:fix |
Type-check specific apps only, not the root:
# ✅ Correct
pnpm --filter sudosos-dashboard exec vue-tsc --noEmit -p tsconfig.app.json
pnpm --filter @sudosos/point-of-sale exec vue-tsc --noEmit -p tsconfig.app.json
# ❌ Wrong — lib/common has pre-existing errors not checked by build
tsc --noEmitAll PrimeVue components (DataTable, Column, Button, ConfirmDialog, etc.) are globally registered in main.ts. Never import them locally — it works but is redundant and will draw a review comment.
Render <ConfirmDialog :group="groupName" /> once outside loops/tables. If multiple instances can be mounted simultaneously (accordion panels), scope the group: computed(() => \delete-${props.entity.id}`)`.
Locale files live in apps/*/src/locales/ (EN, NL, PL). Always grep before inventing a key:
grep -r "productContainer" apps/dashboard/src/locales/Lockstep is now structural: backend and frontend ship from the same repo on the same release train (one vX.Y.Z tag versions both), so a frontend change landing ahead of its backend dependency is a single-PR sequencing problem, not a cross-repo release coordination one. Still call out in the PR description if a change depends on backend work that hasn't landed on the same branch yet.
@gewis/sudosos-client is packages/sudosos-client in this repo (workspace:*), not a version bumped in package.json from npm. If the backend API changed, regenerate it (pnpm generate:client, needs Java 11+) instead of waiting for a published bump.
The point-of-sale app uses two separate tokens, both stored via lib/common/src/helpers/TokenHelper.ts as { token, expires } JSON:
- User token -- default key
jwt_token. Identifies the human operator. Set when a user logs in (PIN/NFC/EAN on the keypad). - POS token -- key
pos_jwt_token(usePosToken.ts). APOINT_OF_SALE-type token identifying which till this device is.setTokenInStoragethrows if apos_jwt_tokenvalue lacksuser.pointOfSale.
- No
pos_jwt_token-> redirect toconfigure-pos(theNoPosTokenViewQR screen). pos_jwt_tokenpresent but no user token -> redirect tologin(the PIN keypad). This is the normal ready state for a configured till -- it's where the operator enters their PIN.- Both present ->
/cashieris reachable (also guarded byauthGuard+posTokenGuard).
NoPosTokenView.vue shows a QR code. The QR encodes <frontendUrl>/auth/qr/confirm?sessionId=.... An already-authenticated user opens it, the dashboard POSTs /authentication/qr/{sessionId}/confirm, the backend emits qr-confirmed over websocket, and useQrAuth.ts puts the resulting user token on the device. The user then selects a POS (usePointOfSaleSwitch.switchToPos -> GET /authentication/pointofsale/{id} -> pos_jwt_token). The QR step exists only to get a token onto a device that has no credentials of its own.
Skip the QR/websocket dance entirely -- mint the POS token directly and plant it:
-
POST /v1/authentication/local{"accountMail":"admin@sudosos.nl","password":"admin"}-> admin JWT. -
GET /v1/authentication/pointofsale/{id}withAuthorization: Bearer <admin JWT>(dev seed: POS id1= "Bar",useAuthentication: true) -> aPOINT_OF_SALEtoken. -
In the preview page, store it the way
parseTokenexpects and reload:const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); localStorage.setItem('pos_jwt_token', JSON.stringify({ token, expires: String(payload.exp) })); window.location.href = window.location.origin + '/cashier';
This lands on the PIN login page for the configured till. To go further into /cashier, also set a user token under jwt_token (or just log in on the keypad -- see below).
The keypad takes a user id, then a 4-digit PIN. The fastest path is keyboard mode: press Escape to enable it (you'll see a toast), then type the sequence. Local accounts (admin/user) use the external path, so prefix with e; GEWIS members type their member id with no prefix.
- Admin:
Escape,e,1,Enter,0,0,0,0.Enterswitches from the user-id field to the PIN field; the PIN auto-submits and you land on/cashier.
Dev PINs: admin 0000 (id 1), user 1111, members alice 1234 / bob 5678.
The PR template requires comparison screenshots for UI changes. Upload to GitHub's user-attachments storage — the same destination you get from drag-dropping into the PR description editor. Don't commit screenshots into the repo; they pollute git history with binaries.
GitHub doesn't expose a public REST API for user-attachments uploads (the https://uploads.github.com/repos/<o>/<r>/issues/<n>/attachments endpoint exists but rejects PAT auth with Bad Size). The only reliable path is to drive a signed-in browser via Chrome MCP.
Effort budget: only follow the full Chrome MCP flow below when it's smooth — Chrome MCP is connected, the browser is already signed in to github.com, and the editor opens cleanly on the first try. If anything fights you (extension offline, browser not signed in, drop event silently no-ops), stop and just ask the user to drop the screenshot in themselves — tell them the file path (/tmp/sudosos-<short-id>.png) and the PR URL. A 10-second manual drop beats five minutes of automation debugging, and the user-attachments URL ends up identical either way.
-
Make the UI state visible. If the screenshot needs an authenticated/conditional state (modals, error banners, empty states), apply a preview-only patch to force the component visible, then revert before committing. Mark the diff with
// PREVIEW-ONLYso it's obvious if it slips through. -
Start the dev server via
preview_start(server name from.claude/launch.json—posordashboard). -
Capture with headless Chrome — produces a PNG file (the
preview_screenshotMCP tool returns inline only; it can't write to disk):"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ --headless=new --disable-gpu \ --virtual-time-budget=5000 \ --hide-scrollbars \ --window-size=1280,1024 \ --screenshot=/tmp/sudosos-<short-id>.png \ http://localhost:5174/
--headless=new— the legacy--headlessmode mis-measures heights inside PrimeVueMessage/Dialogand clips text. Always use--headless=new.--virtual-time-budget=5000— gives Vue/PrimeVue's mount + transition animations time to settle before the snapshot.- Tune
--window-sizeif the dialog gets clipped. POS dialogs usually fit in 1280x1024; cross-check againstpreview_screenshot(the MCP tool's inline image) to confirm the headless render matches the real browser.
-
Revert preview-only patches and verify with
vue-tsc+pnpm frontend:lint. -
Upload to user-attachments via Chrome MCP. Confirm Chrome MCP is connected (
mcp__Claude_in_Chrome__list_connected_browsers) and the browser is signed in to github.com.// In a chrome MCP javascript_tool call, on the PR page with the description editor open: // 1. Open the editor: click "..." on the PR description → "Edit" // 2. Inject the file. Browsers can't read /tmp directly via fetch, so either: // a) Fetch a previously-uploaded copy of the same image (e.g. from raw.githubusercontent.com), OR // b) Embed the file as base64 in the JS body (works up to ~100KB). // Option (a), via fetch: const res = await fetch('https://raw.githubusercontent.com/<owner>/<repo>/<sha>/<path>.png'); const blob = await res.blob(); const file = new File([blob], 'screenshot.png', { type: 'image/png' }); const ta = document.getElementById('issue-<NUM>-body'); // textarea in PR description editor const dt = new DataTransfer(); dt.items.add(file); const r = ta.getBoundingClientRect(); ['dragenter', 'dragover', 'drop'].forEach((t) => ta.dispatchEvent( new DragEvent(t, { bubbles: true, cancelable: true, dataTransfer: dt, clientX: r.left + 50, clientY: r.top + 50, }), ), );
- GitHub auto-uploads the file and inserts an
<img src="https://github.com/user-attachments/assets/<uuid>" />tag at the cursor. - Note:
mcp__Claude_in_Chrome__file_uploadreturnsNot allowedagainst GitHub's hidden file inputs — don't waste time on it. The drop-event approach above is the working path.
- GitHub auto-uploads the file and inserts an
-
Tidy the textarea with another
javascript_toolcall: remove any auto-inserted<img>you don't want, and rewrite the desired one withwidth="800"and a cleareralt. Use the nativeHTMLTextAreaElement.prototype.valuesetter so React/CodeMirror picks up the change:const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; setter.call(ta, newValue); ta.dispatchEvent(new Event('input', { bubbles: true }));
-
Click "Update comment" to save (
findfor the button, then click). -
Verify via
gh pr view <num> --json body -q '.body' | grep src=that only theuser-attachmentsURL is present, noraw.githubusercontent.comleftovers.
| ✅ Do | ❌ Don't |
|---|---|
| Use PrimeVue components from global registration | Import PrimeVue locally in components |
| Reload from server after API mutations | Read stale form/vee-validate state after mutations |
Use getToken callback for socket.io auth |
Capture auth token statically (stale on reconnect) |
Run pnpm format to verify CI compliance |
Run pnpm format:fix to verify (always exits 0) |
| Search locale files before adding i18n keys | Invent new keys without checking existing ones |
Type-check with vue-tsc -p tsconfig.app.json |
Run tsc --noEmit on the monorepo root |
Let Prettier's lf default apply |
Save files with crlf (breaks CI on Linux) |