Everything on this page lives in frontend/apps/saas/modules/shared. These are the pieces you should reuse instead of writing a local version.

Confirmation alert

One dialog for every destructive or irreversible action. 41 files call it.
That is the delete handler in exercises/ExercisesView.tsx. run comes from useAsyncAction(). Behaviour, from components/ConfirmationAlertProvider.tsx:
  • While onConfirm runs, the confirm button shows a spinner, cancel is disabled and the dialog cannot be dismissed.
  • When onConfirm resolves, the dialog closes.
  • When it throws, the dialog stays open so the user can retry or cancel. The provider swallows the error, so show your own toast inside onConfirm (for example with toastAction).
The provider is mounted in (authenticated)/layout.tsx. The hook throws outside it.

Unsaved changes guard

One provider protects every editor from losing work. Editors register with a hook.
Current users: ProgramBuilder, NutritionBuilder, FormEditor, PdfSignEditor, ContentEditor, HomeBannerView, BrandingView and PlanDialog.

Why a provider

Next.js 16 has no navigation guard. router.push() cannot be intercepted, and swapping every Link for a guarded one would touch every navigation call site. So UnsavedChangesProvider intercepts at the document level instead, which covers the sidebar and every other link without changing them.

Three interception layers

resolveInterceptableHref(event) in lib/link-intercept.ts decides whether a click is a navigation worth guarding. It ignores:
  • Prevented events and non primary buttons.
  • Clicks with Cmd, Ctrl, Shift or Alt held (new tab or window).
  • Anchors with download, or a target other than empty or _self.
  • Hash only links.
  • Other origins.
  • Links to the current pathname and search.

The dialog

When a guarded exit is requested, the provider opens an AlertDialog with three buttons. Copy comes from common.unsavedChanges.<variant>: The plan variant has a title only. generic has a title and a description.

The back button sentinel

A browser cannot cancel a back navigation. The provider works around that:
  1. When the page becomes dirty, it pushes a duplicate history entry tagged __unsavedChangesGuard: true. The tag is spread over Next’s existing history.state, never replacing it.
  2. Pressing back pops the sentinel, which leaves the user on the same URL. The handler pushes the sentinel again and opens the dialog with intent back.
  3. Confirming a back exit sets a bypass flag and calls history.go(-2): one step for the sentinel, one for the real back.
  4. When the page becomes clean, the cleanup removes the sentinel with history.back() under the bypass flag.
stripSentinel() removes the tag with replaceState before a programmatic navigation.

The returned helpers

holdExit covers “save and exit”. When a save succeeds the editor becomes clean, and the provider’s cleanup would call history.back() to drop the sentinel. That back navigation races the navigation the editor is about to make. Holding the exit removes the sentinel first and tells the cleanup to skip the history.back(). Both plan builders do this in handleSave:
The provider resets its flags whenever pathname changes.

Pagination

Two pieces that fit together. hooks/use-pagination.ts:
FormsView, ContentView and FoodsView use it exactly like this.
  • DEFAULT_PAGE_SIZE is 25.
  • When the list shrinks (a filter, a delete) and the current page no longer exists, it clamps to the last page.
  • isPaginated is false when everything fits on one page. Hide the control then.
components/Pagination.tsx:
pagination already has the four props the component needs: totalItems, itemsPerPage, currentPage and onChangeCurrentPage. It renders previous and next icon buttons around a range label from common.pagination.range (“1 to 25 of 60”). The chevrons are ChevronBackIcon and ChevronForwardIcon, so they are correct in both directions. This is client side pagination over an array that is already loaded. Lists that page on the server (the trainee board) manage their own paging. components/NavBar.tsx and components/AppWrapper.tsx. Layout and persistence are described in Layouts and providers. This section covers the menu. The list is built in a useMemo and depends on the active organization, the pathname, the scope search param, calendarEnabled and the admin flag. Order as rendered: Settings sub items: general, team (/coaches), billing, branding, plans and pricing, notifications, integrations and AutoFit. Admin only entries are filtered by isOrganizationAdmin. Every item except the home item requires an active organization. That is why the organization must be in the hydrated cache. See Data fetching.

Behaviour

  • Expanded width is md:w-68, collapsed is a 76px rail with icons only. NavTooltip shows labels on the rail.
  • On the rail, an item with sub items opens a pill flyout (NavSubmenuLinks).
  • Active state uses isNavSubItemActive(pathname, href), a prefix match on the path without query or hash. templateNavState splits training and nutrition by the scope param.
  • Below md the nav becomes a top bar with a menu button that opens a Sheet on the start side.
  • The nav element carries data-brand-nav, which brandingCss uses to scope the studio’s icon colour.
  • The logo comes from the studio branding (logoUrl), scaled by the --brand-logo-scale custom property. The rail shows a compact mark of the studio’s initials.

Adding a menu item

  1. Add the entry to coachItems in NavBar.tsx.
  2. Add app.menu.<key> to en/saas.json and he/saas.json.
  3. If the page needs the full width, add its path to onCanvas in AppWrapper. If it is a full height board, add it to containsBoard.

Other building blocks

Hooks