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.exercises/ExercisesView.tsx. run comes from useAsyncAction().
Behaviour, from
components/ConfirmationAlertProvider.tsx:
- While
onConfirmruns, the confirm button shows a spinner, cancel is disabled and the dialog cannot be dismissed. - When
onConfirmresolves, 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 withtoastAction).
(authenticated)/layout.tsx. The hook throws outside it.
Unsaved changes guard
One provider protects every editor from losing work. Editors register with a hook.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 atargetother 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 anAlertDialog 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:- When the page becomes dirty, it pushes a duplicate history entry tagged
__unsavedChangesGuard: true. The tag is spread over Next’s existinghistory.state, never replacing it. - 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. - Confirming a
backexit sets a bypass flag and callshistory.go(-2): one step for the sentinel, one for the real back. - 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:
pathname changes.
Pagination
Two pieces that fit together.hooks/use-pagination.ts:
FormsView, ContentView and FoodsView use it exactly like this.
DEFAULT_PAGE_SIZEis 25.- When the list shrinks (a filter, a delete) and the current page no longer exists, it clamps to the last page.
isPaginatedis 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.
Sidebar
components/NavBar.tsx and components/AppWrapper.tsx. Layout and persistence are described in Layouts and providers. This section covers the menu.
Menu items
The list is built in auseMemo 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.NavTooltipshows 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.templateNavStatesplits training and nutrition by thescopeparam. - Below
mdthe nav becomes a top bar with a menu button that opens aSheeton thestartside. - The
navelement carriesdata-brand-nav, whichbrandingCssuses to scope the studio’s icon colour. - The logo comes from the studio branding (
logoUrl), scaled by the--brand-logo-scalecustom property. The rail shows a compact mark of the studio’s initials.
Adding a menu item
- Add the entry to
coachItemsinNavBar.tsx. - Add
app.menu.<key>toen/saas.jsonandhe/saas.json. - If the page needs the full width, add its path to
onCanvasinAppWrapper. If it is a full height board, add it tocontainsBoard.