@kindraos/ui
Advanced tools
+49
| # `@kindraos/ui` — Package Author Notes | ||
| This is the shared layout package consumed by every Kindra app (shell + sub-apps + standalone backoffice builds). When you're editing files in this folder, you're changing UX for **every** consumer simultaneously. | ||
| ## Long-form docs | ||
| | Read this when… | File | | ||
| |---|---| | ||
| | You need the public component / hook / type API | [`prompt.md`](./prompt.md) | | ||
| | You're building or touching the shell app (`kindra-dashboard`) | [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) | | ||
| | You're bootstrapping a new Kindra sub-app from scratch | [`prompt-new-subapp.md`](./prompt-new-subapp.md) | | ||
| ## Hard rules for this package | ||
| The reason these exist: `@kindraos/ui` ships into many different runtime environments (Clerk-protected, JWT-protected, no-auth marketing) and across multiple Next.js versions. Anything app-specific that leaks into the package immediately breaks at least one consumer. | ||
| 1. **No Clerk imports.** Auth UI flows in via the `topBarRight` and `banner` slots from the consumer. | ||
| 2. **No `next/navigation` imports.** The current pathname comes in as a `pathname` prop. | ||
| 3. **No `next/link` imports.** The link component comes in as the `linkComponent` prop; default is plain `<a>`. | ||
| 4. **No `lucide-react` imports.** Icons come in as `ReactNode`. Inline SVGs only for things the package owns (chevron, menu, close). | ||
| 5. **No app-specific terms.** Don't reference "bookkeeping", "payroll", "accountant", or any product slug — keep names generic (`appId`, `appName`). | ||
| 6. **No business logic.** Filtering nav by role, fetching businesses, deciding when to impersonate — all of that lives in the consumer. | ||
| 7. **Shadcn primitives stay internal.** `primitives/` is not re-exported; consumers have their own shadcn copies and double-imports cause Tailwind class collisions. | ||
| 8. **CSS variables for theming.** Apps override `--kindra-*` in their own `globals.css`; never hard-code consumer-facing colors. | ||
| ## After making changes | ||
| 1. Bump `version` in [`package.json`](./package.json) — the publish workflow refuses to re-publish the same version. | ||
| 2. Push to `main`. [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) auto-publishes on changes inside `packages/ui/**`. | ||
| 3. Verify in a consumer: `npm install @kindraos/ui@latest` and rebuild. The shell at `../kindra-dashboard` and the bookkeeper (this repo's app at `app/`) should both build cleanly — if either breaks, the package change is leaking app-specific behavior or assumptions. | ||
| ## Package layout | ||
| ``` | ||
| src/ | ||
| ├── index.ts # Public exports (PlatformLayout, BusinessSwitcher, hooks, types) | ||
| ├── types/index.ts # NavItem, AppConfig, PlatformLayoutProps, … | ||
| ├── hooks/use-sidebar-state.tsx # SidebarProvider + useSidebarState | ||
| ├── lib/ | ||
| │ ├── cn.ts # clsx + tailwind-merge | ||
| │ ├── nav-utils.ts # isLinkActive, isGroupActive | ||
| │ └── icons.tsx # Inline SVGs (chevron, menu, X) | ||
| ├── primitives/ # Internal shadcn copies — NOT exported | ||
| │ ├── scroll-area.tsx, tooltip.tsx, collapsible.tsx, sheet.tsx, button.tsx, skeleton.tsx | ||
| ├── components/PlatformLayout/ # The main layout | ||
| │ ├── PlatformLayout.tsx, Sidebar.tsx, SidebarNav.tsx, SidebarItem.tsx, | ||
| │ ├── TopBar.tsx, MobileDrawer.tsx | ||
| └── styles/tokens.css # CSS custom properties (--kindra-*) | ||
| ``` |
| # Kindra Sub-App Playbook | ||
| > Read this end-to-end before bootstrapping a new sub-app, or jump to a section by anchor when you know what you need. | ||
| This is the canonical instruction set for building a new Kindra sub-app — the kind of app that lives at `dashboard.kindraos.com/<slug>` for clients (owner/manager via Clerk) and at `<slug>.kindraspace.com` for backoffice staff (email + password JWT). Every existing pattern referenced here is implemented in **greenForestAdmin** (the bookkeeper sub-app); when in doubt, read the file linked in the section. | ||
| The companion docs in this folder are still authoritative for their narrower topics: | ||
| - [`prompt.md`](./prompt.md) — `@kindraos/ui` component API (props, slots, types) | ||
| - [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) — shell-side setup (the `kindra-dashboard` repo) | ||
| --- | ||
| ## 1. Overview | ||
| The Kindra platform is a **Multi-Zone Next.js architecture**. One shell app at `dashboard.kindraos.com` proxies sub-apps via `next.config.js` rewrites; each sub-app is an independent Next.js deployment with its own `basePath`. The same sub-app codebase is built **twice** — once for the client zone (Clerk auth, mounted in the shell) and once standalone for backoffice staff (JWT auth, served at `<slug>.kindraspace.com`). One env var (`NEXT_PUBLIC_DEPLOYMENT_TARGET`) flips the build between the two surfaces. | ||
| ``` | ||
| kindraos.com (marketing landing) | ||
| │ | ||
| ▼ | ||
| ┌───────────────────────────────────────────────────────────────────┐ | ||
| │ dashboard.kindraos.com ← shell app (kindra-dashboard, port 3000) | ||
| │ ───────────────────── │ | ||
| │ / Clerk-protected platform overview │ | ||
| │ /bookkeeper/* ◀─── rewrite ──▶ bookkeeper sub-app (port 3001) │ | ||
| │ /payroll/* ◀─── rewrite ──▶ payroll sub-app (port 3002) │ | ||
| │ /<slug>/* ◀─── rewrite ──▶ any sub-app (port 30NN) │ | ||
| │ /api/v1/* ◀─── rewrite ──▶ api.kindraos.com │ | ||
| └───────────────────────────────────────────────────────────────────┘ | ||
| kindraspace.com (backoffice domain) | ||
| │ | ||
| ┌───────────────────────────────────────────────────────────────────┐ | ||
| │ bookkeeper.kindraspace.com ← same sub-app, NEXT_PUBLIC_DEPLOYMENT_TARGET=accountant | ||
| │ payroll.kindraspace.com ← same payroll sub-app, target=payroll-admin | ||
| │ <slug>.kindraspace.com ← same <slug> sub-app, target=<role-slug> | ||
| │ │ | ||
| │ Auth: email + password JWT, no Clerk middleware on this build │ | ||
| └───────────────────────────────────────────────────────────────────┘ | ||
| ``` | ||
| --- | ||
| ## 2. Domain & Routing Topology | ||
| | Domain | Purpose | Auth | Repo | | ||
| |---|---|---|---| | ||
| | `kindraos.com` | Marketing landing | none | `kindra/kindraos-landing` | | ||
| | `dashboard.kindraos.com` | Shell + sub-app proxy | Clerk (owner/manager) | `kindra-dashboard` | | ||
| | `dashboard.kindraos.com/<slug>` | Sub-app via Multi Zone | Clerk (inherited from shell) | sub-app repo | | ||
| | `<slug>.kindraspace.com` | Standalone backoffice | Email+password JWT | same sub-app repo, different build | | ||
| | `api.kindraos.com` | Django Ninja backend | Bearer token | `priyo-inc-financial-management-system-backend` | | ||
| **Local dev port assignments** (keep this list authoritative — append your slug): | ||
| | Port | App | | ||
| |------|-----| | ||
| | 3000 | `kindra-dashboard` (shell) | | ||
| | 3001 | `greenForestAdmin` (bookkeeper) | | ||
| | 3002 | payroll *(reserved)* | | ||
| | 3003 | tax *(reserved)* | | ||
| | 3004 | crm *(reserved)* | | ||
| | 3005 | marketing *(reserved)* | | ||
| | 3006 | website *(in `kindra-website-frontend`)* | | ||
| | 3007+ | next free for new sub-apps | | ||
| --- | ||
| ## 3. The Two-Build Pattern | ||
| A single sub-app codebase produces two deployments by branching on `NEXT_PUBLIC_DEPLOYMENT_TARGET`. Canonical implementation: [`next.config.js`](../../next.config.js) and [`middleware.ts`](../../middleware.ts). | ||
| | Concern | Kindra build (`NEXT_PUBLIC_DEPLOYMENT_TARGET` unset) | Backoffice build (`NEXT_PUBLIC_DEPLOYMENT_TARGET=<role>`) | | ||
| |---|---|---| | ||
| | Domain | `dashboard.kindraos.com/<slug>` | `<slug>.kindraspace.com` | | ||
| | `basePath` | `/<slug>` | `''` (empty) | | ||
| | Middleware | Clerk (with manual redirect — see § 5a) | No-op pass-through | | ||
| | Root redirect | none | `/` → `/<role>/login` | | ||
| | Auth provider | `<AuthProvider>` (Clerk + impersonation) | `<StaffAuthProvider role="…">` (JWT) | | ||
| | `NEXT_PUBLIC_API_URL` | `/<slug>/api/v1` | `/api/v1` | | ||
| | `API_BASE_URL` (server) | `https://api.kindraos.com/<slug>` | same | | ||
| | Clerk env keys | required and used | required for build, runtime-skipped | | ||
| **Rule:** `next.config.js` and `middleware.ts` MUST branch together on the same env var. Drift between the two is the #1 source of sign-in loops. | ||
| --- | ||
| ## 4. Shell App Multi Zones — Consumer-Side Rules | ||
| The shell (`kindra-dashboard`) owns rewrites and the parent domain. Inside the sub-app you only need to obey four rules: | ||
| 1. **`basePath` is automatic.** Set it once in `next.config.js`. After that, `next/link`, `next/navigation`'s `useRouter()`, and asset URLs auto-prepend `/<slug>`. Never hardcode the prefix. | ||
| 2. **`usePathname()` returns paths *without* basePath** (e.g. `/accounts`, not `/<slug>/accounts`). Pass it straight into `<PlatformLayout pathname={...}>` — `@kindraos/ui` matches against unprefixed paths. | ||
| 3. **Intra-app nav uses `href`**, cross-app nav uses `externalHref`. The latter renders as `<a>` (full page navigation between zones); the former uses your `linkComponent` (soft nav). Cross-app paths are absolute and **not** basePath-prefixed (`/payroll`, not `/payroll/...` with a leading slug). The shell owns the parent domain. | ||
| 4. **Role filtering happens in the consumer**, never inside `@kindraos/ui`. Wrap your nav in `filterNavByRole(sections, user.user_type)` before passing it to `<PlatformLayout>`. See [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx). | ||
| Shell-side wiring (rewrites, port env vars, `isSubAppRoute` matcher) is documented in [`prompt-kindra-shell.md`](./prompt-kindra-shell.md). When you ship a new sub-app, you must update both the shell's `next.config.js` rewrites and its middleware route matcher (§ 6 step 12). | ||
| --- | ||
| ## 5. Dual Authentication Contract | ||
| Two completely separate auth systems coexist in one codebase. They never run at the same time on the same deployment. | ||
| ### 5a. Clerk (owner / manager) — `*.kindraos.com` only | ||
| Used by client-facing builds. Cookie domain in Clerk is set to `.kindraos.com` so the session is shared across `kindraos.com`, `dashboard.kindraos.com`, and every sub-app served beneath the shell. | ||
| **Implementation:** [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) — wraps Clerk's `useUser`/`useAuth`, attaches the token via `apiClient.setTokenGetter(() => getToken())`, calls `authApi.getMe()` (with retry — Clerk webhook → Django provisioning is eventually consistent), exposes `user`, `isAuthenticated`, `signOut`, and impersonation state. | ||
| **Why a *manual* redirect in middleware** (not `auth.protect()`): in the multi-zone proxy setup, `auth.protect()` constructs the redirect URL using the sub-app's internal Vercel host (e.g. `bookkeeper-frontend-priyoinc.vercel.app`) instead of `dashboard.kindraos.com`, causing a sign-in loop. Read [`middleware.ts:32-52`](../../middleware.ts) — copy the manual redirect verbatim. | ||
| **Required env (Kindra build):** | ||
| ``` | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... | ||
| CLERK_SECRET_KEY=sk_... | ||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/select-role | ||
| ``` | ||
| **Post-sign-up flow:** Clerk redirects to `/select-role` → user picks `owner` or `manager` → frontend calls `authApi.setRole({ user_type })` → Django persists → redirect to `/`. The `/select-role` page is part of the Kindra build only. | ||
| ### 5b. JWT staff login (backoffice) — `*.kindraspace.com` only | ||
| Used by backoffice builds. No Clerk on this surface; middleware is a no-op pass-through. | ||
| **Implementation pattern:** [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) is the reference. For a new sub-app, generalize this into `lib/hooks/use-staff-auth.tsx` with the staff role passed as a prop. | ||
| **Generalized template** (drop into a new sub-app, replace `accountant` examples with your role slugs): | ||
| ```tsx | ||
| "use client"; | ||
| import { createContext, useCallback, useContext, useEffect, useState } from "react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { apiClient, ApiError } from "@/lib/api/client"; | ||
| import { authApi } from "@/lib/api/auth"; | ||
| import type { User } from "@/lib/types"; | ||
| import { toast } from "sonner"; | ||
| type StaffRole = "accountant" | "accountant_supervisor" | "payroll_admin" | "<role>"; | ||
| const STORAGE_KEYS: Record<StaffRole, { access: string; role: string }> = { | ||
| // localStorage keys are PREFIXED `greenforest_*` for backward compat with the | ||
| // bookkeeper rebrand. Cross-app shared state (impersonation, current business) | ||
| // MUST use this prefix. New sub-apps may use their own slug for private keys. | ||
| accountant: { | ||
| access: "greenforest_accountant_access_token", | ||
| role: "greenforest_role_id", | ||
| }, | ||
| accountant_supervisor: { | ||
| access: "greenforest_supervisor_access_token", | ||
| role: "greenforest_supervisor_role_id", | ||
| }, | ||
| // Add new staff roles here: | ||
| payroll_admin: { | ||
| access: "greenforest_payroll_admin_access_token", | ||
| role: "greenforest_payroll_admin_role_id", | ||
| }, | ||
| }; | ||
| export function StaffAuthProvider({ children, role }: { children: React.ReactNode; role: StaffRole }) { | ||
| const router = useRouter(); | ||
| const [user, setUser] = useState<User | null>(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const keys = STORAGE_KEYS[role]; | ||
| const loginPath = `/${role.replace("_", "-")}/login`; | ||
| const clearAuth = useCallback(() => { | ||
| localStorage.removeItem(keys.access); | ||
| localStorage.removeItem(keys.role); | ||
| apiClient.setAccessToken(null); | ||
| apiClient.setTokenGetter(null); | ||
| apiClient.setRoleId(null); | ||
| }, [keys]); | ||
| useEffect(() => { | ||
| const init = async () => { | ||
| const token = localStorage.getItem(keys.access); | ||
| const roleId = localStorage.getItem(keys.role); | ||
| if (!token) { router.push(loginPath); return; } | ||
| apiClient.setTokenGetter(null); // staff = static token, not Clerk | ||
| apiClient.setAccessToken(token); | ||
| if (roleId) apiClient.setRoleId(roleId); | ||
| try { | ||
| const me = await authApi.getMe(); | ||
| if (me.user_type !== role) { clearAuth(); router.push(loginPath); return; } | ||
| setUser(me); | ||
| } catch (err) { | ||
| if (err instanceof ApiError && err.status === 401) { | ||
| toast.error("Session expired. Please sign in again."); | ||
| } | ||
| clearAuth(); | ||
| router.push(loginPath); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; | ||
| init(); | ||
| }, [router, keys, role, loginPath, clearAuth]); | ||
| const logout = useCallback(async () => { | ||
| clearAuth(); | ||
| window.location.href = "/"; // backoffice build redirects "/" → /<role>/login | ||
| }, [clearAuth]); | ||
| // Provider value omitted for brevity — see use-accountant-auth.tsx for full impl | ||
| } | ||
| ``` | ||
| **Login endpoint contract:** | ||
| - `POST /auth/staff/login` with `{ email, password }` returns `{ access: string; user: User }`. | ||
| - Token has no refresh; on 401 the user is redirected to the role-specific login page. | ||
| **Login pages live at** `/<role>/login` (e.g. `/accountant/login`, `/payroll-admin/login`). Each role gets its own page because branding/messaging may differ. See [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) as the reference. | ||
| ### 5c. Invitation flow (admin → staff) | ||
| Backoffice users never self-register. An admin invites them via the backend, which emails a link of the form: | ||
| ``` | ||
| https://<domain>/accept-invitation?token=<jwt> | ||
| ``` | ||
| The accept-invitation page is **dual-purpose** (works for both Kindra and backoffice builds — the public route bypasses both auth systems). See [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx). | ||
| **Endpoints:** | ||
| - `POST /auth/validate-invitation?token=<token>` → `{ is_valid, email, user_type }` | ||
| - `POST /auth/accept-invitation` body `{ token, password, first_name, last_name }` → `{ access, user, is_impersonation }` | ||
| **Routing logic post-accept** (replicate exactly): | ||
| ```ts | ||
| if (user.user_type === "owner" || user.user_type === "manager") { | ||
| // Owner/manager will sign in via Clerk separately — invite only validates the | ||
| // backend account. Don't store any JWT for them; redirect to Clerk sign-in. | ||
| router.push("/sign-in"); | ||
| } else { | ||
| // Staff: store JWT under the role-specific key, redirect to their dashboard. | ||
| const keys = STAFF_STORAGE_KEYS[user.user_type]; | ||
| localStorage.setItem(keys.access, response.access); | ||
| router.push(`/${user.user_type.replace("_", "-")}/dashboard`); | ||
| } | ||
| ``` | ||
| ### 5d. Impersonation (staff → owner) | ||
| Lets an accountant or supervisor view the platform as a specific owner, with a fallback path to restore the staff session. Implementation lives in [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) (owner-side) and [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) (staff-side, `handleImpersonate`). | ||
| **localStorage keys involved** (all `greenforest_*` for cross-app sharing): | ||
| | Key | Set by | Purpose | | ||
| |---|---|---| | ||
| | `greenforest_access_token` | staff impersonating | Owner's JWT (read by owner `AuthProvider`) | | ||
| | `greenforest_role_id` | staff impersonating | Owner's role ID for `X-Role` header | | ||
| | `greenforest_impersonated` | staff impersonating | Flag (`"true"`) — owner provider checks this before falling back to Clerk | | ||
| | `greenforest_impersonation_access` | staff impersonating | Staff's original token (fallback if end-impersonate API fails) | | ||
| | `greenforest_impersonation_role_id` | staff impersonating | Staff's original role ID | | ||
| | `greenforest_impersonation_role` | staff impersonating | Staff's role (`"accountant"` or `"accountant_supervisor"`) | | ||
| **Token swap pattern:** | ||
| 1. Staff calls `POST /users/impersonate { email }` → backend returns owner JWT. | ||
| 2. Staff stashes their own token in `greenforest_impersonation_*` keys. | ||
| 3. Staff writes owner JWT to `greenforest_access_token` and sets `greenforest_impersonated="true"`. | ||
| 4. Hard-redirects to `/overview` (owner dashboard). | ||
| 5. Owner `AuthProvider` boots, sees the flag, uses the stored token instead of Clerk. | ||
| **End-impersonation:** `POST /users/end-impersonate` returns a fresh staff JWT. Restore staff token to its role-specific key, clear all `greenforest_impersonation_*` and `greenforest_impersonated`, redirect back to staff dashboard. If the end API fails, fall back to `greenforest_impersonation_access`. | ||
| --- | ||
| ## 6. Bootstrapping a New Sub-App — 12 Steps | ||
| Follow these in order. Each step references the canonical file in this repo to copy from. | ||
| ### Step 1 — Create the project | ||
| ```bash | ||
| cd ~/ # sibling to greenForestAdmin | ||
| npx create-next-app@latest <slug>-frontend \ | ||
| --typescript --tailwind --app --src-dir=false --eslint --import-alias="@/*" | ||
| cd <slug>-frontend | ||
| ``` | ||
| ### Step 2 — Install dependencies | ||
| ```bash | ||
| npm i @kindraos/ui@file:../greenForestAdmin/packages/ui # local during dev | ||
| # or in CI / Vercel: npm i @kindraos/ui (pulls from NPM) | ||
| npm i @clerk/nextjs @tanstack/react-query react-hook-form zod sonner lucide-react | ||
| npm i -D @types/node | ||
| ``` | ||
| ### Step 3 — `next.config.js` | ||
| Replace the generated file with this template (substitute `<slug>` and your role slugs): | ||
| ```js | ||
| /** @type {import('next').NextConfig} */ | ||
| const STAFF_TARGETS = ["<role-1>", "<role-2>"]; // e.g. ["accountant", "accountant-supervisor"] | ||
| const isBackoffice = STAFF_TARGETS.includes(process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET); | ||
| const nextConfig = { | ||
| basePath: isBackoffice ? '' : '/<slug>', | ||
| reactStrictMode: true, | ||
| transpilePackages: ["@kindraos/ui"], | ||
| async rewrites() { | ||
| const apiBase = process.env.API_BASE_URL || 'http://localhost:8001/<slug>'; | ||
| return [ | ||
| { source: '/api/v1/:path*', destination: `${apiBase}/api/v1/:path*` }, | ||
| ]; | ||
| }, | ||
| async redirects() { | ||
| if (isBackoffice) { | ||
| const role = process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET; | ||
| const loginPath = `/${role}/login`; | ||
| return [ | ||
| { source: '/', destination: loginPath, permanent: false }, | ||
| { source: '/<slug>/:path*', destination: loginPath, permanent: false }, | ||
| { source: '/sign-in', destination: loginPath, permanent: false }, | ||
| { source: '/sign-up', destination: loginPath, permanent: false }, | ||
| { source: '/login', destination: loginPath, permanent: false }, | ||
| { source: '/register', destination: loginPath, permanent: false }, | ||
| ]; | ||
| } | ||
| return [ | ||
| { source: '/login', destination: '/sign-in', permanent: true }, | ||
| { source: '/register', destination: '/sign-up', permanent: true }, | ||
| ]; | ||
| }, | ||
| }; | ||
| module.exports = nextConfig; | ||
| ``` | ||
| Reference: [`next.config.js`](../../next.config.js). | ||
| ### Step 4 — `middleware.ts` | ||
| ```ts | ||
| import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; | ||
| import { NextResponse, type NextRequest } from "next/server"; | ||
| const STAFF_TARGETS = ["<role-1>", "<role-2>"]; | ||
| const isBackoffice = STAFF_TARGETS.includes(process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET ?? ""); | ||
| const isPublicRoute = createRouteMatcher([ | ||
| "/sign-in(.*)", "/sign-up(.*)", "/accept-invitation(.*)", | ||
| "/select-role(.*)", "/login", "/register", | ||
| ]); | ||
| const isStaffRoute = createRouteMatcher([ | ||
| "/<role-1>(.*)", "/<role-2>(.*)", "/staff(.*)", | ||
| ]); | ||
| const backofficeMiddleware = (_req: NextRequest) => NextResponse.next(); | ||
| const kindraMiddleware = clerkMiddleware(async (auth, req) => { | ||
| if (isPublicRoute(req) || isStaffRoute(req)) return; | ||
| const { userId } = await auth(); | ||
| if (!userId) { | ||
| const signInUrl = new URL( | ||
| process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL || "/sign-in", | ||
| process.env.NEXT_PUBLIC_APP_URL || req.nextUrl.origin | ||
| ); | ||
| const returnPath = req.nextUrl.basePath + req.nextUrl.pathname + req.nextUrl.search; | ||
| const returnUrl = new URL(returnPath, process.env.NEXT_PUBLIC_APP_URL || req.nextUrl.origin); | ||
| signInUrl.searchParams.set("redirect_url", returnUrl.toString()); | ||
| return NextResponse.redirect(signInUrl); | ||
| } | ||
| }); | ||
| export default isBackoffice ? backofficeMiddleware : kindraMiddleware; | ||
| export const config = { | ||
| matcher: ["/((?!api|_next/static|_next/image|favicon.ico|icon.svg).*)"], | ||
| }; | ||
| ``` | ||
| Reference: [`middleware.ts`](../../middleware.ts). | ||
| ### Step 5 — `lib/api/client.ts` | ||
| Copy [`lib/api/client.ts`](../../lib/api/client.ts) verbatim. The `tokenGetter` (Clerk async) + `accessToken` (static JWT) duality is the linchpin of the dual-auth contract — don't reimplement. | ||
| ### Step 6 — `lib/hooks/use-auth.tsx` (Clerk + impersonation) | ||
| Copy [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx). Adjust only: | ||
| - The `getMe` retry shape if your API differs. | ||
| - The post-`signOut` redirect URL if your sub-app should land somewhere other than `kindraos.com`. | ||
| The impersonation logic depends on the `greenforest_*` localStorage keys (see § 5d). **Do not rename these** unless you also coordinate with every other Kindra sub-app — they share state across the parent domain. | ||
| ### Step 7 — `lib/hooks/use-staff-auth.tsx` (JWT) | ||
| Use the generalized template in § 5b. Define your `StaffRole` union and `STORAGE_KEYS` map for the role(s) this sub-app exposes. | ||
| ### Step 8 — `lib/constants/platform-nav.tsx` | ||
| Copy [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx) and edit the nav items. Two rules: | ||
| - **Intra-app entries** use `href` (relative path, basePath auto-prepended). | ||
| - **Cross-app entries** use `externalHref` with absolute paths matching shell rewrites (`/bookkeeper`, `/payroll`, `/<slug>`). | ||
| Each item supports `allowedUserTypes?: UserType[]`. The shipped `filterNavByRole` recursively strips items the current user can't access — call it before passing nav to `<PlatformLayout>`. | ||
| ### Step 9 — `app/(dashboard)/layout.tsx` | ||
| ```tsx | ||
| "use client"; | ||
| import { usePathname } from "next/navigation"; | ||
| import Link from "next/link"; | ||
| import { PlatformLayout } from "@kindraos/ui"; | ||
| import "@kindraos/ui/styles.css"; | ||
| import { useAuth } from "@/lib/hooks/use-auth"; | ||
| import { platformNav, bottomNav, filterNavByRole } from "@/lib/constants/platform-nav"; | ||
| import { BusinessSwitcher } from "@/components/layout/business-switcher"; | ||
| import { UserNav } from "@/components/layout/user-nav"; | ||
| import { ImpersonationBanner } from "@/components/layout/impersonation-banner"; | ||
| export default function DashboardLayout({ children }: { children: React.ReactNode }) { | ||
| const pathname = usePathname(); | ||
| const { user } = useAuth(); | ||
| return ( | ||
| <PlatformLayout | ||
| app={{ appId: "<slug>", appName: "<App Name>" }} | ||
| platformNav={filterNavByRole(platformNav, user?.user_type)} | ||
| bottomNav={filterNavByRole(bottomNav, user?.user_type)} | ||
| pathname={pathname} | ||
| linkComponent={Link} | ||
| sidebarHeader={<BusinessSwitcher />} | ||
| topBarRight={<UserNav />} | ||
| banner={<ImpersonationBanner />} | ||
| > | ||
| {children} | ||
| </PlatformLayout> | ||
| ); | ||
| } | ||
| ``` | ||
| Reference: [`app/(dashboard)/layout.tsx`](../../app/(dashboard)/layout.tsx). The `BusinessSwitcher`, `UserNav`, and `ImpersonationBanner` are app-specific adapters that wrap shared `@kindraos/ui` primitives — keep that boundary, don't push business logic into the package. | ||
| ### Step 10 — Auth pages | ||
| For the **Kindra build** (client-facing), create: | ||
| ``` | ||
| app/(auth)/sign-in/[[...sign-in]]/page.tsx — Clerk <SignIn /> embed | ||
| app/(auth)/sign-up/[[...sign-up]]/page.tsx — Clerk <SignUp /> embed | ||
| app/select-role/page.tsx — owner/manager picker → authApi.setRole | ||
| app/(auth)/accept-invitation/page.tsx — copy from this repo verbatim | ||
| ``` | ||
| For the **backoffice build**, create one login page per staff role: | ||
| ``` | ||
| app/<role>/(auth)/login/page.tsx — email + password → authApi.staffLogin | ||
| app/<role>/(dashboard-view)/dashboard/page.tsx — landing page after login | ||
| ``` | ||
| Reference: [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) and [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx). | ||
| ### Step 11 — Environment files | ||
| Create three `.env` files. The exact env-var matrix is in § 9. | ||
| ``` | ||
| .env.local — local dev (Kindra build by default) | ||
| .env.production — Kindra deploy (committed only as .env.example) | ||
| .env.prod.<role> — backoffice deploy per role | ||
| ``` | ||
| Vercel build env vars override these — set them per-project in the Vercel dashboard. | ||
| ### Step 12 — Wire the shell | ||
| In `kindra-dashboard/next.config.js`, add a rewrite block: | ||
| ```js | ||
| const subAppUrl = process.env.<SLUG>_URL || 'http://localhost:30NN'; | ||
| beforeFiles: [ | ||
| // …existing… | ||
| { source: '/<slug>', destination: `${subAppUrl}/<slug>` }, | ||
| { source: '/<slug>/:path*', destination: `${subAppUrl}/<slug>/:path*` }, | ||
| ], | ||
| ``` | ||
| In `kindra-dashboard/middleware.ts`, add the slug to `isSubAppRoute`: | ||
| ```ts | ||
| const isSubAppRoute = createRouteMatcher([ | ||
| '/bookkeeper(.*)', | ||
| '/<slug>(.*)', // ← new | ||
| // … | ||
| ]); | ||
| ``` | ||
| Set the new env var (`<SLUG>_URL`) in the shell's Vercel project (production) and `.env.local` (dev). | ||
| --- | ||
| ## 7. Backoffice Subdomain Convention (Kindraspace) | ||
| Every sub-app may expose one or more backoffice surfaces at `<sub-domain>.kindraspace.com`. Each subdomain serves a distinct staff role. Examples: | ||
| | Subdomain | Sub-app | Staff role | `NEXT_PUBLIC_DEPLOYMENT_TARGET` | | ||
| |---|---|---|---| | ||
| | `bookkeeper.kindraspace.com` | bookkeeper | accountant + accountant-supervisor (combined) | `accountant` | | ||
| | `payroll.kindraspace.com` | payroll | payroll admin | `payroll-admin` | | ||
| | `crm.kindraspace.com` | crm | CRM agent | `crm-agent` | | ||
| **Rules:** | ||
| 1. **One Vercel project per backoffice deploy** — same git repo, separate project IDs, distinct env-var sets. | ||
| 2. **`NEXT_PUBLIC_DEPLOYMENT_TARGET` is the role slug** — kebab-case, used as both the env value and the URL segment for the login page (`/payroll-admin/login`). The value MUST be in the `STAFF_TARGETS` array in `next.config.js` and `middleware.ts`. | ||
| 3. **Clerk env vars must be present** in the Vercel project even for backoffice builds. The Clerk SDK reads them at module-load time during `next build`; absence breaks the build even though middleware is a no-op at runtime. | ||
| 4. **The accept-invitation page works on both surfaces.** Don't gate it by deployment target. | ||
| 5. **Cross-tenant impersonation flows** through `greenforest_impersonation_*` localStorage keys (§ 5d). For these to work across backoffice → Kindra builds (e.g. accountant on `bookkeeper.kindraspace.com` impersonates an owner who lands on `dashboard.kindraos.com`), the impersonation pivot is a hard redirect to `https://dashboard.kindraos.com/<slug>/overview` — localStorage doesn't cross domains, so the backend issues a one-time owner JWT in the impersonation response and the redirect carries the session via that JWT. | ||
| --- | ||
| ## 8. GitHub Actions: Dual-Hook Deploy Pattern | ||
| Reference: [`.github/workflows/deploy-production.yml`](../../.github/workflows/deploy-production.yml). | ||
| Each sub-app gets one workflow that fires N Vercel deploy hooks in parallel — one per build target. Hooks are stored as plain URLs in the matrix (they're per-project secrets but not credentials; rotation is via Vercel UI). | ||
| **Template** (drop into `.github/workflows/deploy-production.yml`): | ||
| ```yaml | ||
| name: Deploy Production | ||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| jobs: | ||
| deploy: | ||
| name: Deploy ${{ matrix.project }} | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| - project: <slug> (kindraos) | ||
| hook: https://api.vercel.com/v1/integrations/deploy/prj_<KINDRA_ID>/<TOKEN> | ||
| - project: <role> portal (kindraspace) | ||
| hook: https://api.vercel.com/v1/integrations/deploy/prj_<BACKOFFICE_ID>/<TOKEN> | ||
| steps: | ||
| - name: Trigger Vercel | ||
| run: curl -fsS -X POST "${{ matrix.hook }}" | ||
| ``` | ||
| **Why `fail-fast: false`:** a Vercel hook failure for one target shouldn't cancel the other deploys. | ||
| **UI package publishing:** `@kindraos/ui` is published to NPM by [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) on changes inside `packages/ui/`. Sub-app repos consume it via `file:` (during dev when working on shared UI) or NPM (in CI). Always run `transpilePackages: ["@kindraos/ui"]` in your sub-app's `next.config.js`. | ||
| --- | ||
| ## 9. Environment Variable Matrix | ||
| | Variable | Kindra build | Backoffice build | Where used | | ||
| |---|---|---|---| | ||
| | `NEXT_PUBLIC_DEPLOYMENT_TARGET` | unset | role slug (`accountant`, `payroll-admin`, …) | `next.config.js`, `middleware.ts` | | ||
| | `NEXT_PUBLIC_API_URL` | `/<slug>/api/v1` | `/api/v1` | `lib/api/client.ts` (frontend fetches) | | ||
| | `API_BASE_URL` | `https://api.kindraos.com/<slug>` | same | server-side rewrite target | | ||
| | `NEXT_PUBLIC_APP_URL` | `https://dashboard.kindraos.com` | `https://<slug>.kindraspace.com` | Clerk redirect base, manual middleware redirect | | ||
| | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | required + used | required for build, runtime-skipped | Clerk SDK | | ||
| | `CLERK_SECRET_KEY` | required + used | required for build, runtime-skipped | Clerk SDK | | ||
| | `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | `/sign-in` | `https://dashboard.kindraos.com/sign-in` (any value works — middleware skipped) | manual redirect URL | | ||
| | `NEXT_PUBLIC_CLERK_SIGN_UP_URL` | `/sign-up` | unused | Clerk SDK | | ||
| | `NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL` | `/` | unused | Clerk SDK | | ||
| | `NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL` | `/select-role` | unused | Clerk SDK | | ||
| | `<SLUG>_URL` (in shell repo only) | `https://<slug>-frontend-<vercel-id>.vercel.app` | n/a | shell rewrites | | ||
| **Local dev defaults:** | ||
| - `API_BASE_URL=http://localhost:8001/<slug>` (or whatever port the Django backend listens on) | ||
| - `NEXT_PUBLIC_APP_URL=http://localhost:3000` (when running through the shell) or `http://localhost:30NN` (when running standalone) | ||
| --- | ||
| ## 10. Critical Rules (the "do not break" list) | ||
| Imperative checklist. Violating any of these will produce hard-to-debug breakage. | ||
| 1. **Never manually prepend `basePath`** to `href` values. Next.js auto-prepends; double-prefixing produces `/bookkeeper/bookkeeper/foo`. | ||
| 2. **Never put auth, business, or domain logic inside `@kindraos/ui`.** All app-specific data flows in via props and slots (`sidebarHeader`, `topBarRight`, `banner`, `linkComponent`, `pathname`, `platformNav`). | ||
| 3. **Always branch `next.config.js` and `middleware.ts` together** on `NEXT_PUBLIC_DEPLOYMENT_TARGET`. Drift between the two is the root cause of sign-in loops. | ||
| 4. **Always update [`.claude/docs/structure.md`](../../.claude/docs/structure.md)** when adding files or folders, per the project's CLAUDE.md. | ||
| 5. **Backoffice builds must keep Clerk env vars present** even though the runtime never invokes Clerk — the SDK reads them at module-load. | ||
| 6. **Cross-app shared localStorage uses `greenforest_*` keys**, not `kindra_*` (the rebrand kept the keys unchanged for backward compat). New sub-apps reading impersonation or current-business state MUST use these exact key names. | ||
| 7. **Sub-app slugs must be added to the shell** in two places: `kindra-dashboard/next.config.js` rewrites AND `kindra-dashboard/middleware.ts` `isSubAppRoute` matcher. Forgetting the matcher means Clerk middleware tries to protect the sub-app's routes and fails on the proxied host. | ||
| 8. **Use `auth()` + manual redirect in middleware**, not `auth.protect()`. Auto-redirect targets the wrong host inside the proxy. See § 5a. | ||
| 9. **Cross-app links use `externalHref` with absolute paths** (`/payroll`), never with the sub-app's basePath prefix. The shell owns the parent domain. | ||
| 10. **Login pages live at `/<role>/login`**, not `/login`. The plain `/login` path redirects to either `/sign-in` (Kindra build) or the role-specific login (backoffice build). | ||
| --- | ||
| ## 11. Verification Checklist | ||
| After bootstrapping, verify in this order: | ||
| **Local dev — Kindra build (sub-app inside shell):** | ||
| 1. Start backend: `python manage.py runserver 8001` in the Django repo. | ||
| 2. Start sub-app: `PORT=30NN npm run dev` in the sub-app repo. | ||
| 3. Start shell: `PORT=3000 npm run dev` in `kindra-dashboard`. | ||
| 4. Visit `http://localhost:3000/<slug>` → should render the sub-app inside the shell layout. | ||
| 5. Sign in with a Clerk test user → land on `/<slug>/...` after sign-in. | ||
| 6. Click a `href` nav item → soft nav (no full reload). | ||
| 7. Click an `externalHref` nav item → full page reload. | ||
| **Local dev — backoffice build:** | ||
| 1. `NEXT_PUBLIC_DEPLOYMENT_TARGET=<role> PORT=30NN npm run dev`. | ||
| 2. Visit `http://localhost:30NN/` → redirects to `/<role>/login`. | ||
| 3. Submit valid email + password → land on `/<role>/dashboard`. | ||
| 4. Hard-refresh — session persists (token in localStorage). | ||
| 5. Wait for / force a 401 → redirects to login with toast. | ||
| **API path check (both builds):** | ||
| - Kindra build: open devtools → fetch a page → confirm requests go to `/<slug>/api/v1/...` and resolve through the rewrite to `${API_BASE_URL}/api/v1/...`. | ||
| - Backoffice build: requests go to `/api/v1/...` and resolve to the same backend. | ||
| - Both: `Authorization: Bearer …` header present on every authenticated request. | ||
| **Build:** | ||
| - `npm run build` with no env → succeeds (Kindra build). | ||
| - `NEXT_PUBLIC_DEPLOYMENT_TARGET=<role> npm run build` → succeeds (backoffice build). | ||
| - Both produce the expected `basePath` (check the build manifest or `_next/static` paths). | ||
| **Cross-app sanity:** | ||
| - From inside the shell at `/<slug>/...`, `usePathname()` returns paths *without* the basePath. | ||
| - Clicking back to `/` (the shell root) goes through a full reload (cross-zone). | ||
| --- | ||
| ## 12. Critical Files Index | ||
| Every file referenced in this playbook, with one-line summaries: | ||
| | File | What's in it | | ||
| |---|---| | ||
| | [`next.config.js`](../../next.config.js) | `basePath` + rewrites, conditional on deployment target | | ||
| | [`middleware.ts`](../../middleware.ts) | Clerk vs no-op middleware switch + manual redirect | | ||
| | [`lib/api/client.ts`](../../lib/api/client.ts) | `tokenGetter` (Clerk) + `accessToken` (JWT) duality | | ||
| | [`lib/hooks/use-auth.tsx`](../../lib/hooks/use-auth.tsx) | Clerk auth + impersonation pivot | | ||
| | [`lib/hooks/use-accountant-auth.tsx`](../../lib/hooks/use-accountant-auth.tsx) | Reference impl for the staff JWT provider (generalize this) | | ||
| | [`lib/constants/platform-nav.tsx`](../../lib/constants/platform-nav.tsx) | Nav config + `filterNavByRole` | | ||
| | [`app/(dashboard)/layout.tsx`](../../app/(dashboard)/layout.tsx) | `<PlatformLayout>` wiring | | ||
| | [`app/(auth)/accept-invitation/page.tsx`](../../app/(auth)/accept-invitation/page.tsx) | Invitation accept page (works for both builds) | | ||
| | [`app/accountant/(auth)/login/page.tsx`](../../app/accountant/(auth)/login/page.tsx) | Reference staff login page | | ||
| | [`.github/workflows/deploy-production.yml`](../../.github/workflows/deploy-production.yml) | Dual-hook Vercel deploy | | ||
| | [`.github/workflows/publish-ui.yml`](../../.github/workflows/publish-ui.yml) | `@kindraos/ui` NPM publish | | ||
| | [`.claude/docs/architecture.md`](../../.claude/docs/architecture.md) | Existing platform architecture (cross-app state, ports) | | ||
| | [`.claude/docs/api-client.md`](../../.claude/docs/api-client.md) | API client header/auth contract | | ||
| | [`prompt.md`](./prompt.md) | `@kindraos/ui` component API | | ||
| | [`prompt-kindra-shell.md`](./prompt-kindra-shell.md) | Shell-side setup (rewrites, matcher) | |
+1
-1
| { | ||
| "name": "@kindraos/ui", | ||
| "version": "0.1.0", | ||
| "version": "0.1.1", | ||
| "main": "./src/index.ts", | ||
@@ -5,0 +5,0 @@ "types": "./src/index.ts", |
+210
-428
@@ -1,133 +0,97 @@ | ||
| # Prompt: Create Kindra Dashboard Shell App at `dashboard.kindraos.com` | ||
| # Prompt: Build the Kindra Dashboard Shell at `dashboard.kindraos.com` | ||
| ## Architecture Overview | ||
| > Read this if you're building or editing the **shell app** (the `kindra-dashboard` repo). For the broader platform topology — dual auth, dual build per sub-app, deploy contract — see [`prompt-new-subapp.md`](./prompt-new-subapp.md). | ||
| ``` | ||
| kindraos.com → Kindra landing page (marketing + login) | ||
| kindraos.com/login → Clerk sign-in page | ||
| kindraos.com/signup → Clerk sign-up page | ||
| kindraos.com/accounting → Product page (marketing) | ||
| ## What the shell is | ||
| dashboard.kindraos.com → Dashboard shell app (NEW — this is what we're building) | ||
| dashboard.kindraos.com/ → Platform dashboard (overview across all apps) | ||
| dashboard.kindraos.com/bookkeeper/ → Bookkeeper app (greenForestAdmin, via Multi Zone) | ||
| dashboard.kindraos.com/payroll/ → Payroll app (future, via Multi Zone) | ||
| dashboard.kindraos.com/tax/ → Tax app (future, via Multi Zone) | ||
| dashboard.kindraos.com/crm/ → CRM app (future, via Multi Zone) | ||
| dashboard.kindraos.com/marketing/ → Marketing app (future, via Multi Zone) | ||
| dashboard.kindraos.com/website/ → Website builder app (future, via Multi Zone) | ||
| ``` | ||
| The shell is a Next.js 15 app deployed at `dashboard.kindraos.com`. It does three things: | ||
| **Key facts:** | ||
| - `kindraos.com` and `dashboard.kindraos.com` are different subdomains but share `.kindraos.com` — Clerk cookies can be shared with cross-subdomain config | ||
| - All apps under `dashboard.kindraos.com` share the same origin — `localStorage` is shared automatically | ||
| - Each sub-app (bookkeeper, payroll, etc.) is a standalone Next.js project with its own `basePath` | ||
| - The shell app uses **Next.js Multi Zones** (rewrites) to proxy sub-app routes | ||
| 1. **Auth gateway.** Users sign in via Clerk. The shell's middleware enforces the session for every page that's not a sub-app route or a known public path. | ||
| 2. **Multi-Zone proxy.** `next.config.js` rewrites send `/bookkeeper/*`, `/payroll/*`, etc. to each sub-app's own Next.js deployment. Each sub-app is independent and sets its own `basePath`. | ||
| 3. **Platform overview.** When the user lands on `/`, the shell renders its own dashboard page using `<PlatformLayout>` from `@kindraos/ui` so the chrome looks identical to every sub-app. | ||
| ## Diagram | ||
| ## Architecture overview | ||
| ``` | ||
| kindraos.com dashboard.kindraos.com | ||
| │ │ | ||
| ┌───────┴───────┐ ┌───────┴────────┐ | ||
| │ Kindra Landing│ │ Dashboard Shell │ | ||
| │ (existing) │ ──login───→ │ (NEW app) │ | ||
| │ marketing │ │ │ | ||
| │ + sign-in │ │ Multi Zones: │ | ||
| └───────────────┘ │ / │ → Shell's own dashboard page | ||
| │ /bookkeeper/* │ → Proxy to Bookkeeper app | ||
| │ /payroll/* │ → Proxy to Payroll app | ||
| │ /tax/* │ → Proxy to Tax app | ||
| │ /crm/* │ → Proxy to CRM app | ||
| │ /marketing/* │ → Proxy to Marketing app | ||
| │ /website/* │ → Proxy to Website Builder | ||
| └───────┬────────┘ | ||
| │ | ||
| ┌──────────┬───────┼───────┬──────────┐ | ||
| │ │ │ │ │ | ||
| Bookkeeper Payroll Tax CRM Marketing Website | ||
| (basePath: (base: (base: (base: (base: (base: | ||
| /bookkeeper /payroll /tax /crm /marketing /website) | ||
| │ │ │ │ │ | ||
| └────┬─────┘───────┘───────┘──────────┘ | ||
| │ | ||
| @kindra/ui | ||
| (shared layout) | ||
| │ | ||
| Same localStorage | ||
| Same Clerk session | ||
| kindraos.com → Marketing landing (separate repo, no auth) | ||
| dashboard.kindraos.com → Shell app (THIS repo) | ||
| / → Shell's own platform overview | ||
| /sign-in, /sign-up → Clerk pages | ||
| /bookkeeper/* ◀── rewrite ──▶ bookkeeper sub-app (greenForestAdmin, port 3001) | ||
| /payroll/* ◀── rewrite ──▶ payroll sub-app (port 3002, future) | ||
| /tax/* ◀── rewrite ──▶ tax sub-app (port 3003, future) | ||
| /crm/* ◀── rewrite ──▶ crm sub-app (port 3004, future) | ||
| /marketing/* ◀── rewrite ──▶ marketing sub-app (port 3005, future) | ||
| /website/* ◀── rewrite ──▶ website builder (port 3006) | ||
| <slug>.kindraspace.com → Per-sub-app standalone backoffice (separate Vercel projects) | ||
| Same codebase as the matching sub-app, NEXT_PUBLIC_DEPLOYMENT_TARGET set. | ||
| Does NOT involve the shell. | ||
| ``` | ||
| ## Step-by-Step Implementation | ||
| **Key facts:** | ||
| - All paths under `dashboard.kindraos.com` share the same origin → `localStorage` is shared automatically across sub-apps. | ||
| - Clerk session cookies are scoped to `.kindraos.com`, so the session covers the marketing landing + the shell + every sub-app served beneath the shell. | ||
| - Each sub-app sets its own `basePath` (e.g. `/bookkeeper`). Next.js auto-prepends the prefix on all internal links and assets — never hardcode it. | ||
| - Backoffice deployments at `<slug>.kindraspace.com` are *not* part of the shell. They serve from a different domain with no Clerk middleware. | ||
| --- | ||
| ## Step-by-step | ||
| ### STEP 1: Create the Dashboard Shell App | ||
| ### Step 1 — Create the shell app | ||
| Create a new Next.js project for the dashboard shell. | ||
| ```bash | ||
| cd /Users/rubaiyatnoman | ||
| npx create-next-app@latest kindra-dashboard --typescript --tailwind --app --src-dir=false --import-alias="@/*" | ||
| cd ~/ # sibling to greenForestAdmin | ||
| npx create-next-app@latest kindra-dashboard \ | ||
| --typescript --tailwind --app --src-dir=false --import-alias="@/*" | ||
| cd kindra-dashboard | ||
| ``` | ||
| **Tech stack should match the ecosystem:** | ||
| Tech stack must match the rest of the platform: | ||
| - Next.js 15+ (App Router) | ||
| - TypeScript | ||
| - Tailwind CSS v3 (to match bookkeeper, not v4 like the landing page) | ||
| - Clerk for auth (`@clerk/nextjs`) | ||
| - `@kindra/ui` for shared layout | ||
| - TypeScript strict | ||
| - Tailwind CSS v3 (not v4 — the shared package is v3) | ||
| - `@clerk/nextjs` | ||
| - `@kindraos/ui` | ||
| **Install dependencies:** | ||
| ```bash | ||
| npm install @clerk/nextjs @tanstack/react-query lucide-react | ||
| npm install @kindra/ui@file:../greenForestAdmin/packages/ui | ||
| npm install @kindraos/ui # NPM (production) | ||
| # or, when working on shared UI locally: | ||
| # npm install @kindraos/ui@file:../greenForestAdmin/packages/ui | ||
| ``` | ||
| --- | ||
| ### Step 2 — Project layout | ||
| ### STEP 2: Project Structure | ||
| ``` | ||
| kindra-dashboard/ | ||
| ├── app/ | ||
| │ ├── layout.tsx # Root layout: ClerkProvider + fonts | ||
| │ ├── globals.css # Tailwind + CSS variables (copy from bookkeeper) | ||
| │ ├── (marketing)/ | ||
| │ │ └── page.tsx # Redirect to first app or show platform dashboard | ||
| │ ├── layout.tsx # Root: ClerkProvider + fonts | ||
| │ ├── globals.css # Tailwind + CSS vars (copy from bookkeeper) | ||
| │ ├── (dashboard)/ | ||
| │ │ ├── layout.tsx # PlatformLayout shell with @kindra/ui | ||
| │ │ └── page.tsx # Platform dashboard (overview page) | ||
| │ │ ├── layout.tsx # PlatformLayout shell | ||
| │ │ └── page.tsx # Platform overview | ||
| │ ├── (auth)/ | ||
| │ │ ├── layout.tsx # Centered auth layout | ||
| │ │ ├── sign-in/ | ||
| │ │ │ └── [[...sign-in]]/page.tsx | ||
| │ │ └── sign-up/ | ||
| │ │ └── [[...sign-up]]/page.tsx | ||
| │ └── api/ # Any shell-specific API routes | ||
| ├── components/ | ||
| │ └── layout/ | ||
| │ └── business-switcher.tsx # Connects @kindra/ui BusinessSwitcher to data | ||
| │ │ ├── sign-in/[[...sign-in]]/page.tsx | ||
| │ │ └── sign-up/[[...sign-up]]/page.tsx | ||
| │ └── api/ # Shell-specific API routes (rare) | ||
| ├── components/layout/ | ||
| │ ├── business-switcher.tsx # Wraps @kindraos/ui BusinessSwitcher with data | ||
| │ └── user-nav.tsx # Clerk <UserButton /> in topbar slot | ||
| ├── lib/ | ||
| │ ├── api/ | ||
| │ │ └── client.ts # API client (shared backend) | ||
| │ ├── api/client.ts # API client (shared with sub-apps) | ||
| │ ├── hooks/ | ||
| │ │ ├── use-auth.ts # Auth hook (Clerk-based) | ||
| │ │ └── use-business.ts # Business hook (fetches from API) | ||
| │ └── constants/ | ||
| │ └── platform-nav.tsx # Platform navigation config | ||
| ├── middleware.ts # Clerk middleware + sub-app passthrough | ||
| ├── next.config.js # Multi Zone rewrites | ||
| │ │ ├── use-auth.ts # Clerk-based, mirrors sub-app pattern | ||
| │ │ └── use-business.ts # Business list + selection | ||
| │ └── constants/platform-nav.tsx # Nav with externalHref to each sub-app | ||
| ├── middleware.ts # Clerk + sub-app passthrough | ||
| ├── next.config.js # Multi-Zone rewrites | ||
| ├── tailwind.config.ts | ||
| ├── tsconfig.json | ||
| └── package.json | ||
| ``` | ||
| --- | ||
| ### Step 3 — `next.config.js` (Multi-Zone rewrites) | ||
| ### STEP 3: `next.config.js` — Multi Zone Rewrites | ||
| Each sub-app has its own URL env var. In dev these point to local ports; in production they point to the sub-app's Vercel deployment URL. | ||
| This is the core routing logic. The shell proxies sub-app paths to their respective deployments. | ||
| ```js | ||
@@ -137,73 +101,33 @@ /** @type {import('next').NextConfig} */ | ||
| reactStrictMode: true, | ||
| transpilePackages: ["@kindra/ui"], | ||
| transpilePackages: ["@kindraos/ui"], | ||
| async rewrites() { | ||
| const bookkeepingUrl = process.env.BOOKKEEPER_URL || 'http://localhost:3001'; | ||
| // const payrollUrl = process.env.PAYROLL_URL || 'http://localhost:3002'; | ||
| // const taxUrl = process.env.TAX_URL || 'http://localhost:3003'; | ||
| // const crmUrl = process.env.CRM_URL || 'http://localhost:3004'; | ||
| // const marketingUrl = process.env.MARKETING_URL || 'http://localhost:3005'; | ||
| // const websiteUrl = process.env.WEBSITE_URL || 'http://localhost:3006'; | ||
| const bookkeeperUrl = process.env.BOOKKEEPER_URL || 'http://localhost:3001'; | ||
| const payrollUrl = process.env.PAYROLL_URL || 'http://localhost:3002'; | ||
| const taxUrl = process.env.TAX_URL || 'http://localhost:3003'; | ||
| const crmUrl = process.env.CRM_URL || 'http://localhost:3004'; | ||
| const marketingUrl = process.env.MARKETING_URL || 'http://localhost:3005'; | ||
| const websiteUrl = process.env.WEBSITE_URL || 'http://localhost:3006'; | ||
| // For each sub-app: rewrite both "/<slug>" (the index) and "/<slug>/:path*" | ||
| // (everything beneath). Add a new pair when a new sub-app ships. | ||
| return { | ||
| beforeFiles: [ | ||
| // Bookkeeper app — all requests under /bookkeeper/* | ||
| { | ||
| source: '/bookkeeper', | ||
| destination: `${bookkeepingUrl}/bookkeeper`, | ||
| }, | ||
| { | ||
| source: '/bookkeeper/:path*', | ||
| destination: `${bookkeepingUrl}/bookkeeper/:path*`, | ||
| }, | ||
| { source: '/bookkeeper', destination: `${bookkeeperUrl}/bookkeeper` }, | ||
| { source: '/bookkeeper/:path*', destination: `${bookkeeperUrl}/bookkeeper/:path*` }, | ||
| // Payroll app (future) | ||
| // { | ||
| // source: '/payroll', | ||
| // destination: `${payrollUrl}/payroll`, | ||
| // }, | ||
| // { | ||
| // source: '/payroll/:path*', | ||
| // destination: `${payrollUrl}/payroll/:path*`, | ||
| // }, | ||
| { source: '/payroll', destination: `${payrollUrl}/payroll` }, | ||
| { source: '/payroll/:path*', destination: `${payrollUrl}/payroll/:path*` }, | ||
| // Tax app (future) | ||
| // { | ||
| // source: '/tax', | ||
| // destination: `${taxUrl}/tax`, | ||
| // }, | ||
| // { | ||
| // source: '/tax/:path*', | ||
| // destination: `${taxUrl}/tax/:path*`, | ||
| // }, | ||
| { source: '/tax', destination: `${taxUrl}/tax` }, | ||
| { source: '/tax/:path*', destination: `${taxUrl}/tax/:path*` }, | ||
| // CRM app (future) | ||
| // { | ||
| // source: '/crm', | ||
| // destination: `${crmUrl}/crm`, | ||
| // }, | ||
| // { | ||
| // source: '/crm/:path*', | ||
| // destination: `${crmUrl}/crm/:path*`, | ||
| // }, | ||
| { source: '/crm', destination: `${crmUrl}/crm` }, | ||
| { source: '/crm/:path*', destination: `${crmUrl}/crm/:path*` }, | ||
| // Marketing app (future) | ||
| // { | ||
| // source: '/marketing', | ||
| // destination: `${marketingUrl}/marketing`, | ||
| // }, | ||
| // { | ||
| // source: '/marketing/:path*', | ||
| // destination: `${marketingUrl}/marketing/:path*`, | ||
| // }, | ||
| { source: '/marketing', destination: `${marketingUrl}/marketing` }, | ||
| { source: '/marketing/:path*', destination: `${marketingUrl}/marketing/:path*` }, | ||
| // Website builder app (future) | ||
| // { | ||
| // source: '/website', | ||
| // destination: `${websiteUrl}/website`, | ||
| // }, | ||
| // { | ||
| // source: '/website/:path*', | ||
| // destination: `${websiteUrl}/website/:path*`, | ||
| // }, | ||
| { source: '/website', destination: `${websiteUrl}/website` }, | ||
| { source: '/website/:path*', destination: `${websiteUrl}/website/:path*` }, | ||
| ], | ||
@@ -217,49 +141,49 @@ }; | ||
| **Environment variables** (`.env.local`): | ||
| `.env.local` for dev (`.env.production` in Vercel for prod): | ||
| ``` | ||
| # Sub-app URLs (internal — these are NOT exposed to the browser) | ||
| # Sub-app URLs — server-only; never NEXT_PUBLIC_-prefixed | ||
| BOOKKEEPER_URL=http://localhost:3001 | ||
| # PAYROLL_URL=http://localhost:3002 | ||
| # TAX_URL=http://localhost:3003 | ||
| # CRM_URL=http://localhost:3004 | ||
| # MARKETING_URL=http://localhost:3005 | ||
| # WEBSITE_URL=http://localhost:3006 | ||
| PAYROLL_URL=http://localhost:3002 | ||
| # …others as they ship | ||
| # Clerk (SAME keys as all other apps) | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx | ||
| CLERK_SECRET_KEY=sk_live_xxxxx | ||
| # Clerk — same keys as every other Kindra app | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... | ||
| CLERK_SECRET_KEY=sk_... | ||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/select-role | ||
| # Used by sub-app middleware to construct the correct external redirect | ||
| NEXT_PUBLIC_APP_URL=https://dashboard.kindraos.com | ||
| ``` | ||
| --- | ||
| ### Step 4 — `middleware.ts` (Clerk + sub-app passthrough) | ||
| ### STEP 4: `middleware.ts` — Let Sub-App Routes Pass Through | ||
| The shell's middleware does Clerk on its own pages and **passes through** sub-app routes — each sub-app runs its own middleware after the proxy hop. Crucially, sub-app routes also bypass `auth.protect()` here so Clerk doesn't try to redirect using the proxied internal Vercel hostname. | ||
| The shell's Clerk middleware must NOT interfere with sub-app routes. Sub-apps have their own middleware. | ||
| ```ts | ||
| import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; | ||
| ```typescript | ||
| import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; | ||
| const isPublicRoute = createRouteMatcher([ | ||
| '/sign-in(.*)', | ||
| '/sign-up(.*)', | ||
| "/sign-in(.*)", | ||
| "/sign-up(.*)", | ||
| "/accept-invitation(.*)", | ||
| "/select-role(.*)", | ||
| ]); | ||
| // Sub-app routes are proxied to other Next.js apps — let them through | ||
| // Sub-app routes are proxied to other Next.js apps; let them through. | ||
| // Add the slug here when a new sub-app ships. | ||
| const isSubAppRoute = createRouteMatcher([ | ||
| '/bookkeeper(.*)', | ||
| '/payroll(.*)', | ||
| '/tax(.*)', | ||
| '/crm(.*)', | ||
| '/marketing(.*)', | ||
| '/website(.*)', | ||
| "/bookkeeper(.*)", | ||
| "/payroll(.*)", | ||
| "/tax(.*)", | ||
| "/crm(.*)", | ||
| "/marketing(.*)", | ||
| "/website(.*)", | ||
| ]); | ||
| export default clerkMiddleware(async (auth, req) => { | ||
| if (isPublicRoute(req) || isSubAppRoute(req)) { | ||
| return; | ||
| } | ||
| if (isPublicRoute(req) || isSubAppRoute(req)) return; | ||
| await auth.protect(); | ||
@@ -270,4 +194,4 @@ }); | ||
| matcher: [ | ||
| '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', | ||
| '/(api|trpc)(.*)', | ||
| "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", | ||
| "/(api|trpc)(.*)", | ||
| ], | ||
@@ -277,5 +201,5 @@ }; | ||
| --- | ||
| > **Why `auth.protect()` is OK here but not in sub-apps.** The shell IS the canonical host (`dashboard.kindraos.com`) — Clerk's auto-redirect builds the correct URL. Inside a proxied sub-app, `auth.protect()` would build the redirect from the sub-app's internal Vercel hostname and cause a sign-in loop. The sub-app's own middleware does a manual redirect for that reason; see [`middleware.ts`](../../middleware.ts) in this repo. | ||
| ### STEP 5: Root Layout with Clerk | ||
| ### Step 5 — Root layout with Clerk | ||
@@ -289,14 +213,5 @@ ```tsx | ||
| const dmSans = DM_Sans({ | ||
| subsets: ["latin"], | ||
| variable: "--font-dm-sans", | ||
| weight: ["400", "500", "600", "700"], | ||
| }); | ||
| const dmSans = DM_Sans({ subsets: ["latin"], variable: "--font-dm-sans", weight: ["400","500","600","700"] }); | ||
| const spaceMono = Space_Mono({ subsets: ["latin"], variable: "--font-space-mono", weight: ["400","700"] }); | ||
| const spaceMono = Space_Mono({ | ||
| subsets: ["latin"], | ||
| variable: "--font-space-mono", | ||
| weight: ["400", "700"], | ||
| }); | ||
| export const metadata: Metadata = { | ||
@@ -311,5 +226,3 @@ title: "Kindra Dashboard", | ||
| <body className={`${dmSans.variable} ${spaceMono.variable} font-sans`}> | ||
| <ClerkProvider> | ||
| {children} | ||
| </ClerkProvider> | ||
| <ClerkProvider>{children}</ClerkProvider> | ||
| </body> | ||
@@ -321,15 +234,13 @@ </html> | ||
| --- | ||
| ### Step 6 — Dashboard layout with `@kindraos/ui` | ||
| ### STEP 6: Dashboard Layout with `@kindra/ui` | ||
| The shell shows `<PlatformLayout>` on its own pages so users see the same sidebar/topbar before navigating into a specific app. | ||
| The shell's dashboard page uses the shared PlatformLayout. This gives the user the same sidebar/topbar experience when they land on `dashboard.kindraos.com/` before navigating into a specific app. | ||
| ```tsx | ||
| // app/(dashboard)/layout.tsx | ||
| "use client"; | ||
| import { usePathname } from "next/navigation"; | ||
| import Link from "next/link"; | ||
| import { PlatformLayout } from "@kindra/ui"; | ||
| import { PlatformLayout } from "@kindraos/ui"; | ||
| import "@kindraos/ui/styles.css"; | ||
| import { platformNav, bottomNav } from "@/lib/constants/platform-nav"; | ||
@@ -358,8 +269,6 @@ import { BusinessSwitcher } from "@/components/layout/business-switcher"; | ||
| --- | ||
| ### Step 7 — Platform navigation config | ||
| ### STEP 7: Platform Navigation Config | ||
| Cross-app entries use `externalHref` so they render as `<a>` (full page navigation between zones). The shell's own nav items use `href`. | ||
| The shell's nav config uses `externalHref` for sub-app links (these cause full page navigation to the other Next.js zone) and `href` for internal shell routes. | ||
| ```tsx | ||
@@ -371,3 +280,3 @@ // lib/constants/platform-nav.tsx | ||
| } from "lucide-react"; | ||
| import type { NavSection, NavItem } from "@kindra/ui"; | ||
| import type { NavSection, NavItem } from "@kindraos/ui"; | ||
@@ -377,49 +286,9 @@ export const platformNav: NavSection[] = [ | ||
| items: [ | ||
| { | ||
| id: "dashboard", | ||
| label: "Dashboard", | ||
| icon: <LayoutDashboard className="h-4 w-4" />, | ||
| href: "/", // Shell's own page | ||
| }, | ||
| { | ||
| id: "bookkeeper", | ||
| label: "Bookkeeping", | ||
| icon: <BookOpen className="h-4 w-4" />, | ||
| externalHref: "/bookkeeper", // Full nav to bookkeeper zone | ||
| }, | ||
| { | ||
| id: "payroll", | ||
| label: "Payroll", | ||
| icon: <Wallet className="h-4 w-4" />, | ||
| externalHref: "/payroll", | ||
| comingSoon: true, | ||
| }, | ||
| { | ||
| id: "tax", | ||
| label: "Tax", | ||
| icon: <Calculator className="h-4 w-4" />, | ||
| externalHref: "/tax", | ||
| comingSoon: true, | ||
| }, | ||
| { | ||
| id: "crm", | ||
| label: "CRM", | ||
| icon: <UsersRound className="h-4 w-4" />, | ||
| externalHref: "/crm", | ||
| comingSoon: true, | ||
| }, | ||
| { | ||
| id: "marketing", | ||
| label: "Marketing", | ||
| icon: <Megaphone className="h-4 w-4" />, | ||
| externalHref: "/marketing", | ||
| comingSoon: true, | ||
| }, | ||
| { | ||
| id: "website", | ||
| label: "Website Builder", | ||
| icon: <Globe className="h-4 w-4" />, | ||
| externalHref: "/website", | ||
| comingSoon: true, | ||
| }, | ||
| { id: "dashboard", label: "Dashboard", icon: <LayoutDashboard className="h-4 w-4" />, href: "/" }, | ||
| { id: "bookkeeper", label: "Bookkeeping", icon: <BookOpen className="h-4 w-4" />, externalHref: "/bookkeeper" }, | ||
| { id: "payroll", label: "Payroll", icon: <Wallet className="h-4 w-4" />, externalHref: "/payroll", comingSoon: true }, | ||
| { id: "tax", label: "Tax", icon: <Calculator className="h-4 w-4" />, externalHref: "/tax", comingSoon: true }, | ||
| { id: "crm", label: "CRM", icon: <UsersRound className="h-4 w-4" />, externalHref: "/crm", comingSoon: true }, | ||
| { id: "marketing", label: "Marketing", icon: <Megaphone className="h-4 w-4" />, externalHref: "/marketing", comingSoon: true }, | ||
| { id: "website", label: "Website Builder", icon: <Globe className="h-4 w-4" />, externalHref: "/website", comingSoon: true }, | ||
| ], | ||
@@ -430,214 +299,127 @@ }, | ||
| export const bottomNav: NavItem[] = [ | ||
| { | ||
| id: "reports", | ||
| label: "Reports", | ||
| icon: <BarChart3 className="h-4 w-4" />, | ||
| href: "/reports", | ||
| }, | ||
| { | ||
| id: "settings", | ||
| label: "Settings", | ||
| icon: <Settings className="h-4 w-4" />, | ||
| href: "/settings", | ||
| }, | ||
| { id: "reports", label: "Reports", icon: <BarChart3 className="h-4 w-4" />, href: "/reports" }, | ||
| { id: "settings", label: "Settings", icon: <Settings className="h-4 w-4" />, href: "/settings" }, | ||
| ]; | ||
| ``` | ||
| --- | ||
| ### Step 8 — Sub-app side: set `basePath` | ||
| ### STEP 8: Update Bookkeeper App — Add `basePath` | ||
| Each sub-app must serve under `/<slug>` so the shell's rewrite lands on a matching path. In the sub-app repo's `next.config.js`: | ||
| The bookkeeper must serve under `/bookkeeper` so the shell can route to it. | ||
| **File: `/Users/rubaiyatnoman/greenForestAdmin/next.config.js`** | ||
| ```js | ||
| const nextConfig = { | ||
| basePath: '/bookkeeper', | ||
| basePath: process.env.NEXT_PUBLIC_DEPLOYMENT_TARGET ? '' : '/<slug>', | ||
| reactStrictMode: true, | ||
| transpilePackages: ["@kindra/ui"], | ||
| // ... existing rewrites, redirects | ||
| transpilePackages: ["@kindraos/ui"], | ||
| // …rewrites, redirects per the sub-app playbook | ||
| }; | ||
| ``` | ||
| **What `basePath: '/bookkeeper'` does automatically:** | ||
| - `next/link` prepends `/bookkeeper` to all `href` values | ||
| - `next/router` and `usePathname()` return paths WITHOUT the basePath (e.g., `/accounts`, not `/bookkeeper/accounts`) | ||
| - Static assets are served from `/bookkeeper/_next/...` | ||
| - API routes move to `/bookkeeper/api/...` | ||
| The `basePath` is conditional because the same sub-app is also built standalone for `<slug>.kindraspace.com` (the backoffice surface), where it runs at the root. See [`prompt-new-subapp.md`](./prompt-new-subapp.md) §3 for the full two-build pattern. | ||
| **What you need to update manually:** | ||
| - Clerk env vars: | ||
| ``` | ||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ | ||
| ``` | ||
| (These are relative to basePath, so `/sign-in` actually becomes `/bookkeeper/sign-in`) | ||
| What `basePath: '/<slug>'` does automatically: | ||
| - `next/link` and `useRouter()` prepend `/<slug>` to all `href` values | ||
| - `usePathname()` returns paths *without* basePath — `<PlatformLayout pathname={...}>` gets a clean path and `isLinkActive()` matches as expected | ||
| - Static assets serve from `/<slug>/_next/...` | ||
| - API routes move to `/<slug>/api/...` | ||
| - Update `platform-nav.tsx` — cross-app links use `externalHref` without basePath prefix since they're absolute paths on the domain: | ||
| ```tsx | ||
| { id: "payroll", label: "Payroll", externalHref: "/payroll", ... } | ||
| { id: "website", label: "Website", externalHref: "/website", ... } | ||
| ``` | ||
| The bookkeeper's own nav items keep `href` (relative, basePath is auto-prepended): | ||
| ```tsx | ||
| { id: "bk-accounts", label: "Chart of Accounts", href: "/accounts", ... } | ||
| ``` | ||
| ### Step 9 — Cross-subdomain Clerk auth | ||
| - The `@kindra/ui` PlatformLayout receives `pathname` from `usePathname()`, which returns the path WITHOUT basePath. So active state detection continues to work — `/accounts` matches `href: "/accounts"`. | ||
| Clerk session must be valid on both the marketing landing (`kindraos.com`) and the shell (`dashboard.kindraos.com`). Configuration: | ||
| --- | ||
| **In the Clerk dashboard:** | ||
| 1. Application Settings → Paths: set Sign-in URL to `https://dashboard.kindraos.com/sign-in` | ||
| 2. Sessions → Cookie domain: `.kindraos.com` (with leading dot) | ||
| ### STEP 9: Cross-Subdomain Clerk Auth | ||
| **Same Clerk keys in every Kindra app** (`@clerk/nextjs` package, marketing, shell, every sub-app). The cookie on `.kindraos.com` is the shared session. | ||
| Since login happens on `kindraos.com` but the dashboard is on `dashboard.kindraos.com`, Clerk needs cross-subdomain session sharing. | ||
| **In the marketing landing app** (`kindraos.com`): | ||
| **In Clerk Dashboard (clerk.com):** | ||
| 1. Go to your application settings | ||
| 2. Under "Paths" → set Sign-in URL to `https://kindraos.com/login` | ||
| 3. Under "Sessions" → set Cookie domain to `.kindraos.com` (with the leading dot) | ||
| This makes the Clerk session cookie accessible to all `*.kindraos.com` subdomains. | ||
| **In the Kindra landing page app** (`kindraos.com`): | ||
| ``` | ||
| # .env.local | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx | ||
| CLERK_SECRET_KEY=sk_live_xxxxx | ||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_... | ||
| CLERK_SECRET_KEY=sk_... | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=https://dashboard.kindraos.com | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=https://dashboard.kindraos.com | ||
| NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=https://dashboard.kindraos.com/select-role | ||
| ``` | ||
| **In the dashboard shell app** (`dashboard.kindraos.com`): | ||
| ``` | ||
| # .env.local | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx # SAME key | ||
| CLERK_SECRET_KEY=sk_live_xxxxx # SAME key | ||
| NEXT_PUBLIC_CLERK_SIGN_IN_URL=https://kindraos.com/login | ||
| NEXT_PUBLIC_CLERK_SIGN_UP_URL=https://kindraos.com/signup | ||
| ``` | ||
| **In the shell + every sub-app** (Kindra build): same keys, sign-in/up URLs are relative (`/sign-in`, `/sign-up`). | ||
| **In the bookkeeper app** (runs behind the shell): | ||
| ``` | ||
| # .env.local | ||
| NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx # SAME key | ||
| CLERK_SECRET_KEY=sk_live_xxxxx # SAME key | ||
| ``` | ||
| ### Step 10 — `localStorage` strategy | ||
| All three apps use the **same Clerk application** with the **same keys**. The cookie on `.kindraos.com` is shared. | ||
| All paths under `dashboard.kindraos.com` share the same origin, so `localStorage` is shared across the shell and every sub-app. The platform standardized on the `greenforest_*` prefix (kept unchanged through the bookkeeper rebrand for backward compatibility — every consumer is still reading these keys). | ||
| --- | ||
| ### STEP 10: localStorage Strategy | ||
| Since all sub-apps run under `dashboard.kindraos.com` (same origin), localStorage is shared: | ||
| | Key | Purpose | Set by | Read by | | ||
| |-----|---------|--------|---------| | ||
| | `kindra_current_business` | Selected business ID | BusinessSwitcher (any app) | All apps | | ||
| | `kindra_access_token` | JWT for API calls (impersonation) | Auth flow | All apps | | ||
| | `kindra_role_id` | User's role ID | Auth flow | All apps | | ||
| | `greenforest_current_business` | Selected business UUID | BusinessSwitcher in any app | All apps + shell | | ||
| | `greenforest_access_token` | Owner JWT (impersonation flow) | Backoffice impersonation handler | Owner `AuthProvider` in any sub-app | | ||
| | `greenforest_role_id` | Active role ID for `X-Role` header | Auth flow | API client across all apps | | ||
| | `greenforest_impersonated` | `"true"` while owner session is impersonated | Backoffice impersonation handler | Every sub-app's auth provider | | ||
| | `greenforest_impersonation_*` | Staff fallback tokens during impersonation | Backoffice impersonation handler | End-impersonate handler | | ||
| **Note:** `kindraos.com` (landing page) does NOT share localStorage with `dashboard.kindraos.com` — they're different origins. But that's fine since the landing page doesn't need business state. | ||
| Sub-app-private state can use any namespace (`payroll_*`, `crm_*`, etc.), but **anything cross-app keeps the `greenforest_*` prefix**. Don't introduce a `kindra_*` prefix — it would silently fork the shared state. | ||
| --- | ||
| `kindraos.com` (marketing) is a different origin and does **not** share `localStorage` with the shell. That's fine — the landing page doesn't read business state. | ||
| ### STEP 11: Local Development | ||
| ### Step 11 — Local development | ||
| Run all apps simultaneously with different ports: | ||
| Run apps in parallel on different ports. The shell at `:3000` rewrites each sub-app path to its respective port. | ||
| ```bash | ||
| # Terminal 1: Dashboard shell (port 3000) | ||
| cd /Users/rubaiyatnoman/kindra-dashboard | ||
| PORT=3000 npm run dev | ||
| # Terminal 1 — Shell | ||
| cd ~/kindra-dashboard && PORT=3000 npm run dev | ||
| # Terminal 2: Bookkeeper (port 3001) | ||
| cd /Users/rubaiyatnoman/greenForestAdmin | ||
| PORT=3001 npm run dev | ||
| # Terminal 2 — Bookkeeper | ||
| cd ~/greenForestAdmin && PORT=3001 npm run dev | ||
| # Terminal 3: Payroll (port 3002) — when built | ||
| # cd /Users/rubaiyatnoman/kindra-payroll | ||
| # PORT=3002 npm run dev | ||
| # Terminal 4: Tax (port 3003) — when built | ||
| # cd /Users/rubaiyatnoman/kindra-tax | ||
| # PORT=3003 npm run dev | ||
| # Terminal 5: CRM (port 3004) — when built | ||
| # cd /Users/rubaiyatnoman/kindra-crm | ||
| # PORT=3004 npm run dev | ||
| # Terminal 6: Marketing (port 3005) — when built | ||
| # cd /Users/rubaiyatnoman/kindra-marketing | ||
| # PORT=3005 npm run dev | ||
| # Terminal 7: Website Builder (port 3006) — when built | ||
| # cd /Users/rubaiyatnoman/kindra-website | ||
| # PORT=3006 npm run dev | ||
| # Terminal 8: Kindra landing page (port 4000) — optional, separate concern | ||
| cd /Users/rubaiyatnoman/kindra/kindraos-landing | ||
| PORT=4000 npm run dev | ||
| # Terminal 3+ — additional sub-apps as they ship (3002, 3003, …) | ||
| ``` | ||
| The dashboard shell at `localhost:3000` rewrites each sub-app path to its respective port (e.g., `/bookkeeper/*` → `localhost:3001/bookkeeper/*`). | ||
| Visit `http://localhost:3000/bookkeeper` — the shell proxies to the bookkeeper at `:3001/bookkeeper/...` and you should see the sub-app inside the shared chrome. | ||
| --- | ||
| ## File checklist for the shell | ||
| ## File Checklist for the Dashboard Shell App | ||
| | File | Purpose | | ||
| |------|---------| | ||
| | `package.json` | Dependencies: next, react, @clerk/nextjs, @tanstack/react-query, @kindra/ui, lucide-react | | ||
| | `next.config.js` | Multi Zone rewrites for `/bookkeeper/*`, `/payroll/*`, etc. + `transpilePackages` | | ||
| | `tsconfig.json` | Path aliases for `@/*` and `@kindra/ui` | | ||
| | `tailwind.config.ts` | Copy from bookkeeper (same design tokens) + `@kindra/ui` content path | | ||
| | `middleware.ts` | Clerk middleware — let sub-app routes pass through | | ||
| | `app/layout.tsx` | Root: ClerkProvider + fonts | | ||
| | `app/globals.css` | Copy from bookkeeper (same CSS variables) | | ||
| | `app/(auth)/layout.tsx` | Centered auth layout (if shell has its own sign-in pages) | | ||
| | `app/(dashboard)/layout.tsx` | PlatformLayout from @kindra/ui | | ||
| | `app/(dashboard)/page.tsx` | Platform overview dashboard | | ||
| | `lib/constants/platform-nav.tsx` | Nav config with `externalHref` for sub-apps | | ||
| | `lib/hooks/use-auth.ts` | Clerk-based auth hook | | ||
| | `lib/hooks/use-business.ts` | Business list + selection (same API as bookkeeper) | | ||
| | `package.json` | Deps: next, react, @clerk/nextjs, @tanstack/react-query, @kindraos/ui, lucide-react | | ||
| | `next.config.js` | Multi-Zone rewrites + `transpilePackages: ["@kindraos/ui"]` | | ||
| | `tsconfig.json` | Path aliases (`@/*`) | | ||
| | `tailwind.config.ts` | Match bookkeeper tokens; include `@kindraos/ui` in `content` | | ||
| | `middleware.ts` | Clerk middleware with sub-app passthrough | | ||
| | `app/layout.tsx` | ClerkProvider + fonts | | ||
| | `app/globals.css` | CSS variables (copy from bookkeeper) | | ||
| | `app/(auth)/sign-in/[[...sign-in]]/page.tsx` | Clerk `<SignIn />` | | ||
| | `app/(auth)/sign-up/[[...sign-up]]/page.tsx` | Clerk `<SignUp />` | | ||
| | `app/(dashboard)/layout.tsx` | `<PlatformLayout>` | | ||
| | `app/(dashboard)/page.tsx` | Platform overview | | ||
| | `lib/constants/platform-nav.tsx` | Nav with `externalHref` to each sub-app | | ||
| | `lib/hooks/use-auth.ts`, `lib/hooks/use-business.ts` | Auth + business selection | | ||
| | `lib/api/client.ts` | API client for the shared backend | | ||
| | `components/layout/business-switcher.tsx` | Wrapper connecting @kindra/ui BusinessSwitcher to data | | ||
| | `components/layout/user-nav.tsx` | User avatar dropdown | | ||
| | `components/layout/business-switcher.tsx`, `components/layout/user-nav.tsx` | Slot adapters | | ||
| ## Important Rules | ||
| ## Critical rules | ||
| 1. **Same Clerk app** — All three deployments (landing, shell, bookkeeper) use the same Clerk publishable key and secret key | ||
| 2. **Cookie domain `.kindraos.com`** — Configured in Clerk dashboard so session works across subdomains | ||
| 3. **basePath in sub-apps** — Each sub-app sets `basePath` in its `next.config.js` (e.g., `/bookkeeper`) | ||
| 4. **usePathname() returns path without basePath** — Active state detection in `@kindra/ui` works automatically | ||
| 5. **Cross-app links use `externalHref`** — These render as `<a>` tags, causing full page navigation between zones | ||
| 6. **Intra-app links use `href`** — These render via Next.js `<Link>`, getting basePath auto-prepended | ||
| 7. **localStorage shared under dashboard.kindraos.com** — All sub-apps can read/write `kindra_*` keys | ||
| 8. **Don't duplicate @kindra/ui** — The shell and all sub-apps import from the same package | ||
| 9. **Shell middleware must pass through sub-app routes** — Otherwise Clerk in the shell will block requests meant for the bookkeeper's own Clerk middleware | ||
| 1. **Same Clerk app, same keys**, everywhere — marketing, shell, every sub-app. Cookie domain `.kindraos.com`. | ||
| 2. **Each sub-app sets its own `basePath`** in its own `next.config.js`. The shell does not configure sub-app basePaths. | ||
| 3. **`usePathname()` returns the path without basePath** — every active-state check in `@kindraos/ui` already accounts for that. Don't strip or prepend manually. | ||
| 4. **Cross-app links use `externalHref`** with absolute paths (`/payroll`), never with the sub-app's basePath prefix. The shell owns the parent domain. | ||
| 5. **Intra-app links use `href`** and pass through `linkComponent` (Next.js `<Link>`). | ||
| 6. **Shell middleware MUST pass through sub-app routes.** Otherwise the shell's Clerk middleware blocks requests meant for the sub-app, and the sub-app's own middleware never runs. | ||
| 7. **Sub-app middleware does a manual redirect, not `auth.protect()`.** The shell can use `auth.protect()` because it IS the canonical host; sub-apps cannot. | ||
| 8. **`localStorage` keys for cross-app state use the `greenforest_*` prefix.** Do not introduce a new prefix unless every sub-app is migrating in lockstep. | ||
| 9. **One `@kindraos/ui` source of truth.** Shell and every sub-app import from the same NPM package; running multiple copies causes Tailwind class collisions. | ||
| 10. **Updating the shell when a new sub-app ships:** add the rewrite pair in `next.config.js` AND the slug in the `isSubAppRoute` matcher in `middleware.ts`. Forgetting the matcher means Clerk tries to protect the sub-app's routes and fails on the proxied host. | ||
| ## All Platform Apps | ||
| ## All platform apps | ||
| | App | basePath | Port (dev) | Status | | ||
| |-----|----------|------------|--------| | ||
| | Dashboard Shell | `/` (root) | 3000 | To build | | ||
| | Bookkeeper | `/bookkeeper` | 3001 | Existing (greenForestAdmin) | | ||
| | Payroll | `/payroll` | 3002 | Future | | ||
| | Tax | `/tax` | 3003 | Future | | ||
| | CRM | `/crm` | 3004 | Future | | ||
| | Marketing | `/marketing` | 3005 | Future | | ||
| | Website Builder | `/website` | 3006 | Future | | ||
| | Landing Page | N/A (kindraos.com) | 4000 | Existing | | ||
| | App | basePath | Port (dev) | Status | Backoffice subdomain | | ||
| |-----|----------|------------|--------|---------------------| | ||
| | Dashboard shell | `/` (root) | 3000 | this repo's target | n/a | | ||
| | Bookkeeper | `/bookkeeper` | 3001 | shipped (`greenForestAdmin`) | `bookkeeper.kindraspace.com` | | ||
| | Payroll | `/payroll` | 3002 | future | `payroll.kindraspace.com` | | ||
| | Tax | `/tax` | 3003 | future | `tax.kindraspace.com` | | ||
| | CRM | `/crm` | 3004 | future | `crm.kindraspace.com` | | ||
| | Marketing | `/marketing` | 3005 | future | (n/a — no backoffice yet) | | ||
| | Website Builder | `/website` | 3006 | shipped (`kindra-website-frontend`) | future | | ||
| | Marketing landing | n/a (`kindraos.com`) | 4000 | shipped | n/a | | ||
| Each future app follows the same pattern as the bookkeeper: | ||
| 1. Create a Next.js project | ||
| 2. Set `basePath` in `next.config.js` | ||
| 3. Install `@kindra/ui` and use `PlatformLayout` | ||
| 4. Define app-specific nav in `platform-nav.tsx` (with `externalHref` for cross-app links) | ||
| 5. Add the rewrite in the dashboard shell's `next.config.js` | ||
| 6. Add the route to the shell's `middleware.ts` passthrough list | ||
| Each new sub-app follows the same template — see [`prompt-new-subapp.md`](./prompt-new-subapp.md) for the full bootstrap recipe (dual auth, two builds, GitHub Actions, env-var matrix). |
+8
-8
@@ -1,2 +0,2 @@ | ||
| # @kindra/ui — Platform Shell Layout Package | ||
| # @kindraos/ui — Platform Shell Layout Package | ||
@@ -40,3 +40,3 @@ ## What This Package Does | ||
| ```tsx | ||
| import { PlatformLayout } from "@kindra/ui"; | ||
| import { PlatformLayout } from "@kindraos/ui"; | ||
| import { usePathname } from "next/navigation"; | ||
@@ -64,3 +64,3 @@ import Link from "next/link"; | ||
| }, | ||
| { id: "payroll", label: "Payroll", icon: <PayrollIcon />, externalHref: "https://payroll.kindra.com" }, | ||
| { id: "payroll", label: "Payroll", icon: <PayrollIcon />, externalHref: "/payroll" }, | ||
| ], | ||
@@ -88,3 +88,3 @@ }, | ||
| ```tsx | ||
| import { PlatformLayout } from "@kindra/ui"; | ||
| import { PlatformLayout } from "@kindraos/ui"; | ||
| import { usePathname } from "next/navigation"; | ||
@@ -97,4 +97,4 @@ import Link from "next/link"; | ||
| items: [ | ||
| { id: "dashboard", label: "Dashboard", icon: <DashboardIcon />, externalHref: "https://app.kindra.com" }, | ||
| { id: "bookkeeper", label: "Bookkeeping", icon: <BookIcon />, externalHref: "https://bookkeeping.kindra.com" }, | ||
| { id: "dashboard", label: "Dashboard", icon: <DashboardIcon />, externalHref: "/" }, | ||
| { id: "bookkeeper", label: "Bookkeeping", icon: <BookIcon />, externalHref: "/bookkeeper" }, | ||
| { | ||
@@ -139,3 +139,3 @@ id: "website-builder", | ||
| ```tsx | ||
| import { BusinessSwitcher } from "@kindra/ui"; | ||
| import { BusinessSwitcher } from "@kindraos/ui"; | ||
@@ -164,3 +164,3 @@ // In your app's wrapper component: | ||
| For cross-app state sync, persist the selected business ID in a cookie on the shared parent domain (e.g., `.kindra.com`) so all subdomains can read it. | ||
| For cross-app state sync, persist the selected business ID in `localStorage` on the shared parent domain (`dashboard.kindraos.com`) so all sub-app paths under the shell read the same value. Use the `greenforest_current_business` key to interop with existing apps. | ||
@@ -167,0 +167,0 @@ ## PlatformLayout Props |
@@ -5,3 +5,3 @@ "use client"; | ||
| import { cn } from "../../lib/cn"; | ||
| import { MenuIcon } from "../../lib/icons"; | ||
| import { ArrowLeftIcon, MenuIcon } from "../../lib/icons"; | ||
| import { isGroupActive } from "../../lib/nav-utils"; | ||
@@ -22,2 +22,4 @@ import { ScrollArea } from "../../primitives/scroll-area"; | ||
| sidebarFooter?: React.ReactNode; | ||
| homeHref?: string; | ||
| homeLabel?: string; | ||
| } | ||
@@ -32,2 +34,4 @@ | ||
| sidebarFooter, | ||
| homeHref, | ||
| homeLabel = "Back to Home", | ||
| }: MobileDrawerProps) { | ||
@@ -74,2 +78,15 @@ const { isMobileOpen, setMobileOpen } = useSidebarState(); | ||
| {/* Back to home link */} | ||
| {homeHref && ( | ||
| <div className="border-b border-black/[0.06] dark:border-white/10 px-2 py-2"> | ||
| <a | ||
| href={homeHref} | ||
| className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200" | ||
| > | ||
| <ArrowLeftIcon className="h-4 w-4 shrink-0" /> | ||
| <span>{homeLabel}</span> | ||
| </a> | ||
| </div> | ||
| )} | ||
| {/* Navigation */} | ||
@@ -76,0 +93,0 @@ <ScrollArea className="flex-1 py-2"> |
@@ -25,2 +25,4 @@ "use client"; | ||
| mainClassName, | ||
| homeHref, | ||
| homeLabel, | ||
| }: PlatformLayoutProps) { | ||
@@ -39,2 +41,4 @@ return ( | ||
| sidebarFooter={sidebarFooter} | ||
| homeHref={homeHref} | ||
| homeLabel={homeLabel} | ||
| /> | ||
@@ -50,2 +54,4 @@ <div className="flex flex-1 flex-col overflow-hidden"> | ||
| sidebarFooter={sidebarFooter} | ||
| homeHref={homeHref} | ||
| homeLabel={homeLabel} | ||
| /> | ||
@@ -52,0 +58,0 @@ </TopBar> |
@@ -5,5 +5,11 @@ "use client"; | ||
| import { cn } from "../../lib/cn"; | ||
| import { ArrowLeftIcon } from "../../lib/icons"; | ||
| import { isGroupActive } from "../../lib/nav-utils"; | ||
| import { ScrollArea } from "../../primitives/scroll-area"; | ||
| import { TooltipProvider } from "../../primitives/tooltip"; | ||
| import { | ||
| Tooltip, | ||
| TooltipContent, | ||
| TooltipTrigger, | ||
| TooltipProvider, | ||
| } from "../../primitives/tooltip"; | ||
| import { useSidebarState } from "../../hooks/use-sidebar-state"; | ||
@@ -20,2 +26,4 @@ import { SidebarNav } from "./SidebarNav"; | ||
| sidebarFooter?: React.ReactNode; | ||
| homeHref?: string; | ||
| homeLabel?: string; | ||
| } | ||
@@ -30,2 +38,4 @@ | ||
| sidebarFooter, | ||
| homeHref, | ||
| homeLabel = "Back to Home", | ||
| }: SidebarProps) { | ||
@@ -70,2 +80,36 @@ const { isCollapsed } = useSidebarState(); | ||
| {/* Back to home link */} | ||
| {homeHref && ( | ||
| <div | ||
| className={cn( | ||
| "border-b border-black/[0.06] dark:border-white/10", | ||
| isCollapsed ? "px-1 py-2" : "px-2 py-2" | ||
| )} | ||
| > | ||
| {isCollapsed ? ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <a | ||
| href={homeHref} | ||
| className="flex items-center justify-center rounded-lg h-10 w-10 mx-auto text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200" | ||
| > | ||
| <ArrowLeftIcon className="h-4 w-4" /> | ||
| </a> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="right" className="font-medium"> | ||
| {homeLabel} | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| ) : ( | ||
| <a | ||
| href={homeHref} | ||
| className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-200" | ||
| > | ||
| <ArrowLeftIcon className="h-4 w-4 shrink-0" /> | ||
| <span>{homeLabel}</span> | ||
| </a> | ||
| )} | ||
| </div> | ||
| )} | ||
| {/* Navigation */} | ||
@@ -72,0 +116,0 @@ <ScrollArea className="flex-1 py-2"> |
+18
-0
@@ -113,2 +113,20 @@ export function ChevronRightIcon({ className }: { className?: string }) { | ||
| export function ArrowLeftIcon({ className }: { className?: string }) { | ||
| return ( | ||
| <svg | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth={2} | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| className={className} | ||
| > | ||
| <path d="m12 19-7-7 7-7" /> | ||
| <path d="M19 12H5" /> | ||
| </svg> | ||
| ); | ||
| } | ||
| export function PlusIcon({ className }: { className?: string }) { | ||
@@ -115,0 +133,0 @@ return ( |
@@ -120,2 +120,6 @@ import type { ReactNode, ComponentType } from "react"; | ||
| mainClassName?: string; | ||
| /** Cross-app href for "Back to Home" link. When provided, renders above nav. Always uses plain <a> for full page navigation. */ | ||
| homeHref?: string; | ||
| /** Label for the home link. Defaults to "Back to Home". */ | ||
| homeLabel?: string; | ||
| } |
119623
47.68%31
6.9%1509
6.04%