From 440f815df79b7e29b377ca10e28c9622800bde2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 11:24:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(chrome)!:=20v0.4.0=20=E2=80=94=20AdminShel?= =?UTF-8?q?l=20+=20headers=20as=20/chrome=20sub-export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `./chrome` entrypoint exporting `` and the header components (Search, SearchHistory, SearchOptions, Messages, BrowseApps, HeaderCustomers, HeaderContacts, LogoutButton). Refactored from the Chronos-style AdminShell that gscCRM was vendoring byte-for-byte — header/footer/sidebar are now a single shared surface across apps. Explicit props contract (no site-informations.json, no internal data sources): `menus`, `apps`, `user`, `brand` are required; `features.*` flags gate every section (search/browseApps/messages/notifications/ subbar*/pageHeader*/activityPanel/chat/footer); `slots.*` lets apps inject content; `labels` overrides the next-intl "chrome" namespace. Locale-aware navigation: chrome calls useLocale() and prepends /{locale} to internal menu URLs, leaving externals (http(s)://…) and the "#" sentinel alone. Breadcrumbs and the path-derived page title strip the leading locale segment so they read "Contacts" not "En › Contacts". Necessary for `localePrefix: 'always'` consumers like gscCRM. Phosphor 2.x icons: `normalizeIconClass` prepends the base `ph` class (compound selectors `.ph.ph-house:before` require both). All hardcoded `` sites switched to `ph ph-…`. `next-intl` and `next-auth` moved to peerDependencies (with devDep copies for the kit's own typecheck/build). Consumers must symlink their installed copies into the kit's node_modules at build time — otherwise useTranslations()/useSession() bind to a separate React context and next-intl throws Error(void 0) on render. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 150 +++- package-lock.json | 116 ++- package.json | 29 +- src/chrome/AdminShell.tsx | 1111 +++++++++++++++++++++++++ src/chrome/LogoutButton.tsx | 46 + src/chrome/_ambient.d.ts | 17 + src/chrome/header/BrowseApps.tsx | 110 +++ src/chrome/header/HeaderContacts.tsx | 12 + src/chrome/header/HeaderCustomers.tsx | 98 +++ src/chrome/header/Messages.tsx | 24 + src/chrome/header/Search.tsx | 26 + src/chrome/header/SearchHistory.tsx | 39 + src/chrome/header/SearchOptions.tsx | 103 +++ src/chrome/header/index.ts | 7 + src/chrome/index.ts | 25 + src/chrome/labels.ts | 72 ++ src/chrome/types.ts | 174 ++++ src/index.ts | 1 + 18 files changed, 2146 insertions(+), 14 deletions(-) create mode 100644 src/chrome/AdminShell.tsx create mode 100644 src/chrome/LogoutButton.tsx create mode 100644 src/chrome/_ambient.d.ts create mode 100644 src/chrome/header/BrowseApps.tsx create mode 100644 src/chrome/header/HeaderContacts.tsx create mode 100644 src/chrome/header/HeaderCustomers.tsx create mode 100644 src/chrome/header/Messages.tsx create mode 100644 src/chrome/header/Search.tsx create mode 100644 src/chrome/header/SearchHistory.tsx create mode 100644 src/chrome/header/SearchOptions.tsx create mode 100644 src/chrome/header/index.ts create mode 100644 src/chrome/index.ts create mode 100644 src/chrome/labels.ts create mode 100644 src/chrome/types.ts diff --git a/README.md b/README.md index 86ea3dc..f3ce4cd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,10 @@ your app ```ts import "@gsc/web-kit/css"; // CSS bundle (layout-3 + JetBrains Mono) -// Chrome +// Chrome — unified header/footer/sidebar shell +import { AdminShell } from "@gsc/web-kit/chrome"; + +// Lower-level layout primitives import { AppLayout } from "@gsc/web-kit/layout"; import { useShell } from "@gsc/web-kit/shell"; import { fetchShellConfig } from "@gsc/web-kit/shell/server"; @@ -81,8 +84,149 @@ The `/api` sub-export is reserved for a future HTTP client helper; it currently | 2 | layout · auth · shell — usable end-to-end with shell-api | **done** | | 3 | data · forms — curated re-exports from limitless + validation | **done (v0.3.0)** | | 4 | feedback · navigation · utils — curated re-exports from limitless | **done (v0.3.0)** | -| 4a | api · HTTP client helper (Bearer injection, 401 → signInRedirect) | planned | -| 5 | Roll out to gscCRM / gscChronos / gscAdmin / gscPortal | planned | +| 5 | chrome · AdminShell + headers + LogoutButton + nav migrations | **done (v0.4.0)** | +| 5a | Roll out chrome to gscCRM / gscChronos / gscAdmin / gscPortal | in progress | +| 6 | api · HTTP client helper (Bearer injection, 401 → signInRedirect) | planned | + +--- + +## Chrome (`/chrome`) — v0.4.0 + +Unified `AdminShell` providing navbar, subbar, page-header, sidebar, footer, +optional chat overlay and activity panel. Every app receives the same UI +chrome and toggles features it doesn't use via `features` props. + +### Adopting chrome in a new app + +1. **Add the dep** (already a `file:` resolve to this kit) and import: + + ```tsx + // app/[locale]/layout.tsx + import { AdminShell } from "@gsc/web-kit/chrome"; + import "@gsc/web-kit/css"; + ``` + +2. **Apply nav migrations** to your app's database: + + ```bash + # In your app's migrations directory, copy the two kit-canonical files verbatim + cp node_modules/@gsc/web-kit/migrations/nav-schema.up.sql apps//migrations/00X_nav_schema.up.sql + cp node_modules/@gsc/web-kit/migrations/nav-apps-seed.up.sql apps//migrations/00Y_nav_apps_seed.up.sql + # Then copy the menu-items template once and adapt to your app's menu + cp node_modules/@gsc/web-kit/migrations/nav-menu-items-template.sql apps//migrations/00Z_nav_menu_items.up.sql + # edit 00Z_… to replace example rows with your app's sidebar/topbar entries + psql "$DATABASE_URL" -f apps//migrations/00X_nav_schema.up.sql + psql "$DATABASE_URL" -f apps//migrations/00Y_nav_apps_seed.up.sql + psql "$DATABASE_URL" -f apps//migrations/00Z_nav_menu_items.up.sql + ``` + + Re-copy `nav-schema` + `nav-apps-seed` on every kit upgrade. The + menu-items file is yours after the first copy — the kit never touches it + again. + +3. **Add Prisma** to read the data: + + ```prisma + // prisma/schema.prisma + datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + schemas = ["nav"] + } + generator client { + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] + } + ``` + + Add `"postinstall": "prisma generate"` to package.json. Copy `prisma/` + into the Docker image *before* `npm install` so the generate step sees it. + +4. **Wire the layout server component**: + + ```tsx + const [sidebar, topbar, subbar, apps] = await Promise.all([ + getMenuItemsByType("sidebar"), + getMenuItemsByType("topbar"), + getMenuItemsByType("subbar"), + getApps(), + ]); + + return ( + + {children} + + ); + ``` + +### Props reference + +`` is the kit's contract — see `src/chrome/types.ts` for the +authoritative TypeScript definition. Summary: + +- **Data**: `menus`, `apps`, `user`, `notificationCount?`, `activity?` +- **Brand** (required): `name`, `product`, `logoUrl`, `websiteUrl`, + `supportUrl`, `docsUrl`, `copyrightStartYear` (+ optional `logoSmallUrl`) +- **`features`** (all optional booleans, sensible defaults): + `search`, `searchHistory`, `searchOptions`, `browseApps`, `messages`, + `notifications`, `subbar`, `subbarSupport`, `subbarSettings`, + `pageHeader`, `pageHeaderCustomers`, `pageHeaderContacts`, + `activityPanel`, `chat`, `footer` +- **`slots`** (ReactNode overrides): + `pageTitle`, `pageHeaderExtras`, `subbarExtras`, `activityPanel`, + `navbarExtras`, `footerExtras` +- **Behavior**: `onSignOut?` (default `next-auth signOut`), + `labels?: Partial` (override individual chrome strings) + +### i18n + +Chrome's own strings come from next-intl namespace `chrome` with English +fallbacks. Add to your app's `common.json`: + +```json +{ + "chrome": { + "navigation": "Navigation", + "logout": "Logout", + "support": "Support", + ... + } +} +``` + +Menu item labels (`menu.dashboard`, `menu.accounts`, …) live in your app's +existing translation namespace. + +### Semver + +Public surface = `` props + exported types + migration files + +chrome CSS class names. + +- **Major** — removed/renamed prop, changed prop shape, removed feature + flag, renamed CSS class, changed migration DDL. +- **Minor** — new optional prop, new feature flag (default off), new + slot, new export, new migration file. +- **Patch** — bugfix, internal refactor. + +Deprecations land for one minor release with `console.warn` and a +`CHANGELOG.md` note before removal in the next major. Pin +`"@gsc/web-kit": "^X.Y.Z"` to receive minors automatically. + +--- ## Notes diff --git a/package-lock.json b/package-lock.json index 7ebfc65..6f14ecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,15 @@ { "name": "@gsc/web-kit", - "version": "0.2.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gsc/web-kit", - "version": "0.2.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@limitless/ui": "file:../limitless-ui", - "next-auth": "^5.0.0-beta.25", - "next-intl": "^4.6.1", "zod": "^3.23.0" }, "devDependencies": { @@ -19,15 +17,23 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "next": "16.1.1", + "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.6.1", "typescript": "^5.4.0" }, "peerDependencies": { + "@gsc/chat": "*", "bootstrap": "^5.3.3", "next": ">=15.0.0", + "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.6.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { + "@gsc/chat": { + "optional": true + }, "bootstrap": { "optional": true } @@ -62,6 +68,7 @@ "version": "0.41.2", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "dev": true, "license": "ISC", "dependencies": { "@panva/hkdf": "^1.2.1", @@ -91,6 +98,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -101,12 +109,14 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz", "integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA==", + "dev": true, "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { "version": "3.5.7", "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz", "integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==", + "dev": true, "license": "MIT", "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.7" @@ -116,12 +126,14 @@ "version": "2.1.7", "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz", "integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA==", + "dev": true, "license": "MIT" }, "node_modules/@formatjs/intl-localematcher": { "version": "0.8.6", "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz", "integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==", + "dev": true, "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "3.1.4" @@ -131,6 +143,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -144,6 +157,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -166,6 +180,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -188,6 +203,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -204,6 +220,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -220,6 +237,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -236,6 +254,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -252,6 +271,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -268,6 +288,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -284,6 +305,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -300,6 +322,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -316,6 +339,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -332,6 +356,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -348,6 +373,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -370,6 +396,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -392,6 +419,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -414,6 +442,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -436,6 +465,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -458,6 +488,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -480,6 +511,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -502,6 +534,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -524,6 +557,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -543,6 +577,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -562,6 +597,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -581,6 +617,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -601,6 +638,7 @@ "version": "16.1.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", "integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==", + "dev": true, "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { @@ -610,6 +648,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -626,6 +665,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -642,6 +682,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -658,6 +699,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,6 +716,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -690,6 +733,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,6 +750,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -722,6 +767,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -735,6 +781,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -744,6 +791,7 @@ "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -782,6 +830,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -802,6 +851,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -822,6 +872,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -842,6 +893,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -862,6 +914,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -882,6 +935,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -902,6 +956,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -922,6 +977,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -942,6 +998,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -962,6 +1019,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -982,6 +1040,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1002,6 +1061,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1022,6 +1082,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1051,6 +1112,7 @@ "version": "1.21.5", "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "dev": true, "license": "MIT" }, "node_modules/@swc/core-darwin-arm64": { @@ -1060,6 +1122,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1076,6 +1139,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1092,6 +1156,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1108,6 +1173,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1124,6 +1190,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1140,6 +1207,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1156,6 +1224,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1172,6 +1241,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1188,6 +1258,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1204,6 +1275,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1220,6 +1292,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1236,6 +1309,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1249,12 +1323,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -1264,6 +1340,7 @@ "version": "0.1.26", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -1303,6 +1380,7 @@ "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -1336,6 +1414,7 @@ "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1356,6 +1435,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "dev": true, "license": "MIT" }, "node_modules/csstype": { @@ -1369,6 +1449,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1378,6 +1459,7 @@ "version": "4.11.1", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.1.tgz", "integrity": "sha512-C0tsPVuvyNp+++qWJP+mty/KLLStjerOZqu3W1xWLJkChEDbDi9Taoj6blK7L/onxbuVzwgH6k9Sf+rOV6lOvw==", + "dev": true, "funding": [ { "type": "individual", @@ -1393,6 +1475,7 @@ "version": "11.2.4", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz", "integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "@formatjs/fast-memoize": "3.1.4", @@ -1403,6 +1486,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1412,6 +1496,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -1424,6 +1509,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1433,6 +1519,7 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, "funding": [ { "type": "github", @@ -1451,6 +1538,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1460,6 +1548,7 @@ "version": "16.1.1", "resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz", "integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==", + "dev": true, "license": "MIT", "dependencies": { "@next/env": "16.1.1", @@ -1513,6 +1602,7 @@ "version": "5.0.0-beta.31", "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "dev": true, "license": "ISC", "dependencies": { "@auth/core": "0.41.2" @@ -1540,6 +1630,7 @@ "version": "4.11.1", "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.1.tgz", "integrity": "sha512-s32lFFLXkxrW6fy+4IVaGD5J8xPpbEDFLfBbXV73CTbHAGhOGMjYN4/rftdsKOQ44AnPhnZ5Et+ZNMr5tRpsqA==", + "dev": true, "funding": [ { "type": "individual", @@ -1571,12 +1662,14 @@ "version": "4.11.1", "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.1.tgz", "integrity": "sha512-jHKGij7NoYccy2y54+e/wHVMoRgNt4h/Kn0XS9c4GbKu3KgJyANLUN8sFcDixv6sqz4V2kh6CTWgrkIidQksUg==", + "dev": true, "license": "MIT" }, "node_modules/next-intl/node_modules/@swc/core": { "version": "1.15.33", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1617,6 +1710,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "dev": true, "license": "Apache-2.0", "optional": true, "peer": true, @@ -1628,12 +1722,14 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT" }, "node_modules/oauth4webapi": { "version": "3.8.6", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -1643,12 +1739,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1661,12 +1759,14 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "dev": true, "license": "MIT" }, "node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -1695,6 +1795,7 @@ "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -1705,6 +1806,7 @@ "version": "6.5.11", "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "dev": true, "license": "MIT", "peerDependencies": { "preact": ">=10" @@ -1744,6 +1846,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, "license": "ISC", "optional": true, "bin": { @@ -1757,6 +1860,7 @@ "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "optional": true, @@ -1802,6 +1906,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -1811,6 +1916,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dev": true, "license": "MIT", "dependencies": { "client-only": "0.0.1" @@ -1834,6 +1940,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/typescript": { @@ -1861,6 +1968,7 @@ "version": "4.11.1", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.1.tgz", "integrity": "sha512-/dqWSqUSbVMzC+fdy7io8enhGYHeGeHK1bFhTLrp0ZblqdzY4FkE+tkffW6IfCauqaIA2/z4DQae4XEn93+raw==", + "dev": true, "funding": [ { "type": "individual", diff --git a/package.json b/package.json index bc841bf..3ef8bdc 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,16 @@ { "name": "@gsc/web-kit", - "version": "0.3.0", - "description": "GSC web app skeleton \u2014 layout, auth, data, forms, feedback, navigation. Built on @limitless/ui. Drop into a Next.js app and just write pages.", + "version": "0.4.0", + "description": "GSC web app skeleton — layout, auth, data, forms, feedback, navigation, chrome. Built on @limitless/ui. Drop into a Next.js app and just write pages.", "license": "MIT", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist", + "migrations" + ], "exports": { ".": { "types": "./dist/index.d.ts", @@ -37,6 +41,10 @@ "types": "./dist/shell/server.d.ts", "import": "./dist/shell/server.js" }, + "./chrome": { + "types": "./dist/chrome/index.d.ts", + "import": "./dist/chrome/index.js" + }, "./data": { "types": "./dist/data/index.d.ts", "import": "./dist/data/index.js" @@ -60,7 +68,10 @@ "./utils": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js" - } + }, + "./migrations/nav-schema.up.sql": "./migrations/nav-schema.up.sql", + "./migrations/nav-apps-seed.up.sql": "./migrations/nav-apps-seed.up.sql", + "./migrations/nav-menu-items-template.sql": "./migrations/nav-menu-items-template.sql" }, "sideEffects": [ "**/*.css" @@ -72,24 +83,28 @@ }, "dependencies": { "@limitless/ui": "file:../limitless-ui", - "next-auth": "^5.0.0-beta.25", - "next-intl": "^4.6.1", "zod": "^3.23.0" }, "peerDependencies": { + "@gsc/chat": "*", "bootstrap": "^5.3.3", "next": ">=15.0.0", + "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.6.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, "peerDependenciesMeta": { - "bootstrap": { "optional": true } + "bootstrap": { "optional": true }, + "@gsc/chat": { "optional": true } }, "devDependencies": { "@types/node": "^20.11.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "next": "16.1.1", + "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.6.1", "typescript": "^5.4.0" } -} \ No newline at end of file +} diff --git a/src/chrome/AdminShell.tsx b/src/chrome/AdminShell.tsx new file mode 100644 index 0000000..6cb524b --- /dev/null +++ b/src/chrome/AdminShell.tsx @@ -0,0 +1,1111 @@ +"use client"; + +import React, { useMemo, useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useLocale, useTranslations } from "next-intl"; +import { ChatBubble, ChatOverlay } from "@gsc/chat"; + +import { LogoutButton } from "./LogoutButton"; +import { Search } from "./header/Search"; +import { BrowseApps } from "./header/BrowseApps"; +import { Messages } from "./header/Messages"; +import { HeaderContacts } from "./header/HeaderContacts"; +import { HeaderCustomers } from "./header/HeaderCustomers"; +import { useChromeLabels } from "./labels"; +import type { + AdminShellProps, + ActivityFeedItem, + Brand, + ChromeFeatures, + ChromeLabels, + ChromeSlots, + ChromeUser, + DbMenuItem, +} from "./types"; + +const SIDEBAR_COLLAPSED_KEY = "admin-sidebar-collapsed"; + +type BreadcrumbItem = { + label: React.ReactNode; + href?: string; +}; + +type AdminSidebarChild = { + key: string; + label: string; + href?: string; + iconClass?: string; + active?: boolean; +}; + +type AdminSidebarItem = { + key: string; + label: string; + href?: string; + iconClass?: string; + active?: boolean; + isOpen?: boolean; + onToggle?: () => void; + children?: AdminSidebarChild[]; +}; + +const ICON_SPACING_CLASS = /^m[trblsexy]?(?:-(?:sm|md|lg|xl|xxl))?-(?:0|1|2|3|4|5|auto)$/; + +const DEFAULT_FEATURES: Required = { + search: true, + searchHistory: true, + searchOptions: true, + browseApps: true, + messages: true, + notifications: true, + subbar: true, + subbarSupport: true, + subbarSettings: true, + pageHeader: true, + pageHeaderCustomers: false, + pageHeaderContacts: false, + activityPanel: false, + chat: false, + footer: true, +}; + +function normalizeTranslationKey(key: string): string { + const parts = key.split("."); + if (parts.length <= 2) return key; + const namespace = parts[0]; + const rest = parts.slice(1); + const camelCased = + rest[0] + + rest + .slice(1) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(""); + return `${namespace}.${camelCased}`; +} + +function normalizeIconClass(iconClass: string | null | undefined) { + if (!iconClass) return undefined; + const tokens = iconClass + .split(/\s+/) + .filter((cls) => cls && !ICON_SPACING_CLASS.test(cls)); + if (tokens.length === 0) return undefined; + // Phosphor 2.x uses compound selectors (`.ph.ph-house:before`); the base + // `ph` class must be present alongside the glyph class. DB seeds store + // only the glyph (e.g. "ph-house") — prepend `ph` so the rule matches. + if (tokens.some((cls) => cls.startsWith("ph-")) && !tokens.includes("ph")) { + tokens.unshift("ph"); + } + return tokens.join(" "); +} + +/** + * Prepend `/{locale}` to internal absolute paths. Leaves external URLs + * (http(s)://…), already-prefixed paths, and the "#" sentinel alone. + * Menu-item URLs in the DB are stored locale-agnostic (e.g. `/contacts`); + * routing configs like `localePrefix: 'always'` require `/{locale}/contacts`. + */ +function withLocale(url: string, locale: string): string { + if (!url || url === "#") return url; + if (/^[a-z]+:\/\//i.test(url)) return url; + if (!url.startsWith("/")) return url; + if (url === `/${locale}` || url.startsWith(`/${locale}/`)) return url; + return `/${locale}${url}`; +} + +/** Strip a leading `/{locale}` segment so breadcrumbs/titles work on the app-level path. */ +function stripLocale(pathname: string, locale: string): string { + if (pathname === `/${locale}`) return "/"; + if (pathname.startsWith(`/${locale}/`)) return pathname.slice(locale.length + 1); + return pathname; +} + +function mapSidebarItems( + menu: DbMenuItem[], + pathname: string, + locale: string, + openSubmenus: Set, + toggleSubmenu: (key: string) => void, + t: (key: string) => string, +): AdminSidebarItem[] { + return menu.map((item) => { + const hasChildren = item.children && item.children.length > 0; + const itemHref = withLocale(item.url, locale); + const isActive = + item.url !== "#" + ? pathname.startsWith(itemHref) + : item.children?.some((child) => + pathname.startsWith(withLocale(child.url, locale)), + ) ?? false; + const isOpen = openSubmenus.has(item.key); + + return { + key: item.key, + label: t(normalizeTranslationKey(item.translationKey)), + href: item.url === "#" ? undefined : itemHref, + iconClass: normalizeIconClass(item.icon), + active: isActive, + isOpen, + onToggle: hasChildren ? () => toggleSubmenu(item.key) : undefined, + children: hasChildren + ? item.children!.map((child): AdminSidebarChild => { + const childHref = withLocale(child.url, locale); + return { + key: child.key, + label: t(normalizeTranslationKey(child.translationKey)), + href: child.url === "#" ? undefined : childHref, + iconClass: normalizeIconClass(child.icon), + active: child.url !== "#" ? pathname.startsWith(childHref) : false, + }; + }) + : undefined, + }; + }); +} + +function buildBreadcrumbs(pathname: string): BreadcrumbItem[] { + const pathData = pathname.split("/").filter(Boolean); + const breadcrumbs: BreadcrumbItem[] = [ + { label: , href: "/" }, + ]; + + if (pathData.length === 0) { + breadcrumbs.push({ label: "Dashboard" }); + } else if (pathData.length === 1) { + breadcrumbs.push({ + label: pathData[0].charAt(0).toUpperCase() + pathData[0].slice(1).toLowerCase(), + }); + } else { + breadcrumbs.push({ + label: + pathData[pathData.length - 2].charAt(0).toUpperCase() + + pathData[pathData.length - 2].slice(1).toLowerCase(), + }); + breadcrumbs.push({ + label: + pathData[pathData.length - 1].charAt(0).toUpperCase() + + pathData[pathData.length - 1].slice(1).toLowerCase(), + }); + } + + return breadcrumbs; +} + +function deriveTitleFromPath(pathname: string, defaultLabel: string): string { + const pathData = pathname.split("/").filter(Boolean); + if (pathData.length === 0) return defaultLabel; + const last = pathData[pathData.length - 1]; + return last.charAt(0).toUpperCase() + last.slice(1).toLowerCase(); +} + +export function AdminShell({ + menus, + apps, + user, + notificationCount, + activity, + brand, + features, + slots, + onSignOut, + labels: labelOverrides, + children, +}: AdminShellProps) { + const pathname = usePathname(); + const locale = useLocale(); + const tMenu = useTranslations(); + const labels = useChromeLabels(labelOverrides); + const feat: Required = { ...DEFAULT_FEATURES, ...features }; + const slot: ChromeSlots = slots ?? {}; + // Path with the leading /{locale} stripped — feeds breadcrumb/title logic + // so it never reflects the locale segment as a page name. + const appPath = stripLocale(pathname, locale); + + const [showActivityModal, setShowActivityModal] = useState(false); + const [showChatOverlay, setShowChatOverlay] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [showMobileSidebar, setShowMobileSidebar] = useState(false); + const [openSubmenus, setOpenSubmenus] = useState>(() => { + const initial = new Set(); + menus.sidebar.forEach((item) => { + if (item.children?.some((child) => pathname.startsWith(withLocale(child.url, locale)))) { + initial.add(item.key); + } + }); + return initial; + }); + + useEffect(() => { + setOpenSubmenus((prev) => { + const next = new Set(prev); + menus.sidebar.forEach((item) => { + if (item.children?.some((child) => pathname.startsWith(withLocale(child.url, locale)))) { + next.add(item.key); + } + }); + return next; + }); + }, [pathname, locale, menus.sidebar]); + + const toggleSubmenu = useCallback((key: string) => { + setOpenSubmenus((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + useEffect(() => { + const stored = localStorage.getItem(SIDEBAR_COLLAPSED_KEY); + if (stored !== null) setSidebarCollapsed(stored === "true"); + }, []); + + const isSidebarCollapsed = sidebarCollapsed && !showMobileSidebar; + + useEffect(() => { + document.body.classList.toggle("sidebar-xs", isSidebarCollapsed); + return () => { + document.body.classList.remove("sidebar-xs"); + }; + }, [isSidebarCollapsed]); + + useEffect(() => { + document.body.classList.toggle("sidebar-mobile-main", showMobileSidebar); + return () => { + document.body.classList.remove("sidebar-mobile-main"); + }; + }, [showMobileSidebar]); + + useEffect(() => { + if (showActivityModal) { + document.body.style.overflow = "hidden"; + document.body.style.paddingRight = "17px"; + } else { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + } + return () => { + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; + }; + }, [showActivityModal]); + + const toggleSidebar = () => { + setSidebarCollapsed((prev) => { + const next = !prev; + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next)); + return next; + }); + }; + + const sidebarItems = useMemo( + () => mapSidebarItems(menus.sidebar, pathname, locale, openSubmenus, toggleSubmenu, tMenu), + [menus.sidebar, pathname, locale, openSubmenus, toggleSubmenu, tMenu], + ); + const breadcrumbs = useMemo(() => buildBreadcrumbs(appPath), [appPath]); + const pageTitle = useMemo( + () => deriveTitleFromPath(appPath, labels.dashboard), + [appPath, labels.dashboard], + ); + + return ( + <> + setShowChatOverlay((open) => !open) : undefined} + onSignOut={onSignOut} + /> + + {feat.subbar && ( + + )} + + {feat.pageHeader && ( + + )} + +
+ +
+ {children} +
+
+ + {feat.footer && } + + {feat.activityPanel && ( + setShowActivityModal(false)} + activity={activity} + slot={slot} + labels={labels} + /> + )} + {feat.activityPanel && showActivityModal && ( +
+ )} + + {feat.chat && ( + <> + setShowChatOverlay(false)} + /> + setShowChatOverlay(true)} + /> + + )} + + ); +} + +// ============================================================================ +// Navbar +// ============================================================================ + +function AdminNavbar({ + feat, + slot, + brand, + user, + topbarMenu, + apps, + notificationCount, + labels, + tMenu, + locale, + showMobileSidebar, + setShowMobileSidebar, + showActivityModal, + setShowActivityModal, + onOpenChat, + onSignOut, +}: { + feat: Required; + slot: ChromeSlots; + brand: Brand; + user: ChromeUser; + topbarMenu: DbMenuItem[]; + apps: AdminShellProps["apps"]; + notificationCount?: number; + labels: ChromeLabels; + tMenu: (key: string) => string; + locale: string; + showMobileSidebar: boolean; + setShowMobileSidebar: (show: boolean) => void; + showActivityModal: boolean; + setShowActivityModal: (show: boolean) => void; + onOpenChat?: () => void; + onSignOut?: () => void | Promise; +}) { + const [showNavMenu, setShowNavMenu] = useState(false); + + return ( +
+
+
+ +
+ +
+ + {brand.name} + +
+ + + +
    + + + {slot.navbarExtras} +
+ +
    + {feat.notifications && ( +
  • + +
  • + )} + +
  • setShowNavMenu(false)} + > + { + e.preventDefault(); + setShowNavMenu(!showNavMenu); + }} + > +
    + {user.avatarUrl ? ( + + ) : ( + + {user.displayName?.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase() || "?"} + + )} + +
    + + {user.displayName} + + + +
    + {topbarMenu.map((menuItem) => ( + + {menuItem.icon && ( + + )} + {tMenu(normalizeTranslationKey(menuItem.translationKey))} + + ))} + +
    +
  • +
+
+
+ ); +} + +// ============================================================================ +// SubBar (breadcrumbs + Support + Settings) +// ============================================================================ + +function AdminSubBar({ + breadcrumbs, + subbarMenu, + feat, + slot, + brand, + labels, + tMenu, + locale, +}: { + breadcrumbs: BreadcrumbItem[]; + subbarMenu: DbMenuItem[]; + feat: Required; + slot: ChromeSlots; + brand: Brand; + labels: ChromeLabels; + tMenu: (key: string) => string; + locale: string; +}) { + const [showCollapsedSubMenu, setShowCollapsedSubMenu] = useState(false); + const [showSettings, setShowSettings] = useState(false); + + return ( +
+
+
+
+ {breadcrumbs.map((bc, idx) => ( + + {bc.href ? {bc.label} : bc.label} + + ))} +
+ + +
+ +
+
+ {feat.subbarSupport && ( + + + {labels.support} + + )} + + {feat.subbarSettings && subbarMenu.length > 0 && ( +
setShowSettings(false)} + > + { + e.preventDefault(); + setShowSettings(!showSettings); + }} + > + + {labels.settings} + + + +
+ {subbarMenu.map((menuItem) => ( + + {menuItem.icon && ( + + )} + {tMenu(normalizeTranslationKey(menuItem.translationKey))} + + ))} +
+ + + {labels.allSettings} + +
+
+ )} + + {slot.subbarExtras} +
+
+
+
+ ); +} + +// ============================================================================ +// Page Header +// ============================================================================ + +function AdminPageHeader({ + pageTitle, + feat, + slot, +}: { + pageTitle: React.ReactNode; + feat: Required; + slot: ChromeSlots; +}) { + const [showCollapsed, setShowCollapsed] = useState(false); + const hasRightSide = + feat.pageHeaderCustomers || feat.pageHeaderContacts || slot.pageHeaderExtras; + + return ( +
+
+
+

+ {pageTitle} +

+ {hasRightSide && ( + + )} +
+ + {hasRightSide && ( +
+
+ + {feat.pageHeaderCustomers && feat.pageHeaderContacts && ( +
+ )} + + {slot.pageHeaderExtras} +
+
+ )} +
+
+ ); +} + +// ============================================================================ +// Sidebar +// ============================================================================ + +function AdminSidebar({ + sidebarItems, + sidebarCollapsed, + toggleSidebar, + showMobileSidebar, + setShowMobileSidebar, + pathname, + labels, +}: { + sidebarItems: AdminSidebarItem[]; + sidebarCollapsed: boolean; + toggleSidebar: () => void; + showMobileSidebar: boolean; + setShowMobileSidebar: (show: boolean) => void; + pathname: string; + labels: ChromeLabels; +}) { + const sidebarClasses = [ + "sidebar", + "sidebar-light", + "sidebar-main", + "sidebar-expand-lg", + "align-self-start", + sidebarCollapsed ? "sidebar-main-resized" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+
+
+ {!sidebarCollapsed && ( +
+ {labels.navigation} +
+ )} +
+ + +
+
+
+ +
+
    +
  • + {!sidebarCollapsed ? ( +
    + {labels.main} +
    + ) : ( + + )} +
  • + {sidebarItems.map((item) => ( + + ))} +
+
+
+
+ ); +} + +function AdminSidebarNavItem({ + item, + pathname, + collapsed, +}: { + item: AdminSidebarItem; + pathname: string; + collapsed: boolean; +}) { + const hasChildren = item.children && item.children.length > 0; + const isActive = item.active ?? (item.href ? pathname.startsWith(item.href) : false); + const [isHovered, setIsHovered] = useState(false); + + const icon = item.iconClass ? : null; + + if (hasChildren) { + const showFlyout = collapsed && isHovered; + const showInline = !collapsed && item.isOpen; + const navItemClasses = [ + "nav-item", + "nav-item-submenu", + showInline ? "nav-item-open" : "", + showFlyout ? "nav-group-sub-visible" : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
  • setIsHovered(true) : undefined} + onMouseLeave={collapsed ? () => setIsHovered(false) : undefined} + > + { + event.preventDefault(); + item.onToggle?.(); + }} + onMouseEnter={collapsed ? () => setIsHovered(true) : undefined} + onFocus={collapsed ? () => setIsHovered(true) : undefined} + > + {icon} + {!collapsed && {item.label}} + +
      + {item.children!.map((child) => ( +
    • + {child.href ? ( + + {child.iconClass && } + {child.label} + + ) : ( + + {child.iconClass && } + {child.label} + + )} +
    • + ))} +
    +
  • + ); + } + + return ( +
  • + {item.href ? ( + + {icon} + {!collapsed && {item.label}} + + ) : ( + + {icon} + {!collapsed && {item.label}} + + )} +
  • + ); +} + +// ============================================================================ +// Footer +// ============================================================================ + +function AdminFooter({ + brand, + labels, + slot, +}: { + brand: Brand; + labels: ChromeLabels; + slot: ChromeSlots; +}) { + const currentYear = new Date().getFullYear(); + const yearRange = + currentYear > brand.copyrightStartYear + ? `${brand.copyrightStartYear} - ${currentYear}` + : `${currentYear}`; + + return ( +
    +
    + + © {yearRange}{" "} + + {brand.name} - {brand.product} + + + + +
    +
    + ); +} + +// ============================================================================ +// Activity Modal +// ============================================================================ + +function ActivityModal({ + show, + onClose, + activity, + slot, + labels, +}: { + show: boolean; + onClose: () => void; + activity?: ActivityFeedItem[]; + slot: ChromeSlots; + labels: ChromeLabels; +}) { + const newItems = (activity ?? []).filter((a) => (a.group ?? "new") === "new"); + const olderItems = (activity ?? []).filter((a) => a.group === "older"); + + return ( +
    +
    +
    {labels.activityTitle}
    + +
    + +
    + {slot.activityPanel ?? ( + <> +
    {labels.newNotifications}
    +
    + {newItems.length === 0 ? ( +
    {labels.noNewNotifications}
    + ) : ( + newItems.map((item) => ) + )} +
    +
    {labels.olderNotifications}
    +
    + {olderItems.length === 0 ? ( +
    {labels.noOlderNotifications}
    + ) : ( + olderItems.map((item) => ) + )} +
    + + )} +
    +
    + ); +} + +function ActivityRow({ item }: { item: ActivityFeedItem }) { + return ( +
    +
    + {item.actorAvatarUrl ? ( + + ) : ( + + {item.actorName?.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase() || "?"} + + )} + +
    +
    + {item.message} +
    {item.timestamp}
    +
    +
    + ); +} diff --git a/src/chrome/LogoutButton.tsx b/src/chrome/LogoutButton.tsx new file mode 100644 index 0000000..cf2a4d4 --- /dev/null +++ b/src/chrome/LogoutButton.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { signOut } from "next-auth/react"; + +/** + * Default flow (shared org Keycloak): + * 1. GET /api/auth/logout — host app returns { logoutUrl } pointing at + * Keycloak's end_session endpoint (id_token_hint included). + * 2. next-auth signOut() locally — fires events.signOut for backchannel + * revocation; redirect:false so we control the navigation. + * 3. Navigate to logoutUrl — kills the SSO cookie at Keycloak. + * + * Apps without /api/auth/logout (or that need a different flow) pass + * `onSignOut` to fully replace this behavior. + */ +type LogoutButtonProps = { + label: string; + onSignOut?: () => void | Promise; +}; + +export function LogoutButton({ label, onSignOut }: LogoutButtonProps) { + const handleLogout = async () => { + if (onSignOut) { + await onSignOut(); + return; + } + + let logoutUrl = "/logged-out"; + try { + const res = await fetch("/api/auth/logout"); + const body = await res.json(); + if (body?.logoutUrl) logoutUrl = body.logoutUrl; + } catch { + // fall through with local-only logout + } + await signOut({ redirect: false }); + window.location.href = logoutUrl; + }; + + return ( + + ); +} diff --git a/src/chrome/_ambient.d.ts b/src/chrome/_ambient.d.ts new file mode 100644 index 0000000..057a37f --- /dev/null +++ b/src/chrome/_ambient.d.ts @@ -0,0 +1,17 @@ +// Ambient declarations for peer dependencies we don't install in the kit. +// `@gsc/chat` is provided by the consuming app — declared here so the kit +// can typecheck against the surface we use without owning a hard dep. + +declare module "@gsc/chat" { + import type { FC } from "react"; + + export const ChatBubble: FC<{ + isOpen: boolean; + onClick: () => void; + }>; + + export const ChatOverlay: FC<{ + isOpen: boolean; + onClose: () => void; + }>; +} diff --git a/src/chrome/header/BrowseApps.tsx b/src/chrome/header/BrowseApps.tsx new file mode 100644 index 0000000..c8b41e5 --- /dev/null +++ b/src/chrome/header/BrowseApps.tsx @@ -0,0 +1,110 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { AppListItem, ChromeLabels } from "../types"; + +type BrowseAppsProps = { + show?: boolean; + apps: AppListItem[]; + viewAllUrl?: string; // default "/apps" + labels: Pick; +}; + +export function BrowseApps({ show = true, apps, viewAllUrl = "/apps", labels }: BrowseAppsProps) { + const [open, setOpen] = useState(false); + + const close = () => { + if (open) setOpen(false); + }; + + const enabled = apps.filter((a) => a.enabled).sort((a, b) => a.sortOrder - b.sortOrder); + + return ( + <> + {/* Mobile search icon (always rendered alongside browse-apps trigger) */} +
  • + + + +
  • + + {show && ( +
  • + { + e.preventDefault(); + setOpen(!open); + }} + > + + + +
    +
    +
    {labels.browseApps}
    + + {labels.viewAll} + + +
    + +
    + {enabled.map((app, idx) => { + const isLast = idx === enabled.length - 1; + const isEvenColumn = idx % 2 === 0; + const itemClasses = [ + "dropdown-item", + "text-wrap", + "h-100", + "align-items-start", + isEvenColumn ? "border-end-sm" : "", + isLast ? "" : "border-bottom", + "p-3", + ] + .filter(Boolean) + .join(" "); + + return ( +
    + +
    + {app.iconUrl ? ( + + ) : app.iconClass ? ( +
    + +
    + ) : null} +
    {app.name}
    + {app.description && ( +
    {app.description}
    + )} +
    + +
    + ); + })} +
    +
    +
  • + )} + + ); +} diff --git a/src/chrome/header/HeaderContacts.tsx b/src/chrome/header/HeaderContacts.tsx new file mode 100644 index 0000000..f2b347e --- /dev/null +++ b/src/chrome/header/HeaderContacts.tsx @@ -0,0 +1,12 @@ +"use client"; + +// Placeholder for parity with gscAdmin's contacts header zone. Renders nothing +// today; an app may pass slots.pageHeaderExtras to render its own contacts UI. +type HeaderContactsProps = { + show?: boolean; +}; + +export function HeaderContacts({ show = true }: HeaderContactsProps) { + if (!show) return null; + return null; +} diff --git a/src/chrome/header/HeaderCustomers.tsx b/src/chrome/header/HeaderCustomers.tsx new file mode 100644 index 0000000..ad44e34 --- /dev/null +++ b/src/chrome/header/HeaderCustomers.tsx @@ -0,0 +1,98 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +export type CustomerOption = { + id: number | string; + name: string; + logo: string; + description?: string; +}; + +type HeaderCustomersProps = { + show?: boolean; + customers?: CustomerOption[]; +}; + +export function HeaderCustomers({ show = true, customers = [] }: HeaderCustomersProps) { + const [open, setOpen] = useState(false); + const [selectedName, setSelectedName] = useState(""); + const [selectedLogo, setSelectedLogo] = useState(""); + + const toggle = () => setOpen((v) => !v); + + const select = (c: CustomerOption) => { + setSelectedName(c.name); + setSelectedLogo(c.logo); + setOpen(false); + }; + + if (!show || customers.length === 0) return null; + + const current = customers[0]; + + return ( +
    + { + e.preventDefault(); + toggle(); + }} + > + +
    +
    Customer
    +
    {selectedName || current.name}
    +
    + + +
    +
    +
    Customers
    + + View all + + +
    + + {customers.map((c) => ( + { + e.preventDefault(); + select(c); + }} + key={c.id} + > + +
    +
    {c.name}
    + {c.description && ( +
    {c.description}
    + )} +
    + + ))} +
    +
    + ); +} diff --git a/src/chrome/header/Messages.tsx b/src/chrome/header/Messages.tsx new file mode 100644 index 0000000..207396e --- /dev/null +++ b/src/chrome/header/Messages.tsx @@ -0,0 +1,24 @@ +"use client"; + +type MessagesProps = { + show?: boolean; + onOpenChat?: () => void; +}; + +export function Messages({ show = true, onOpenChat }: MessagesProps) { + if (!show) return null; + + return ( +
  • + +
  • + ); +} diff --git a/src/chrome/header/Search.tsx b/src/chrome/header/Search.tsx new file mode 100644 index 0000000..32edd7c --- /dev/null +++ b/src/chrome/header/Search.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { SearchHistory } from "./SearchHistory"; +import { SearchOptions } from "./SearchOptions"; + +type SearchProps = { + show?: boolean; + showHistory?: boolean; + showOptions?: boolean; +}; + +export function Search({ show = true, showHistory = true, showOptions = true }: SearchProps) { + if (!show) return null; + + return ( + + ); +} diff --git a/src/chrome/header/SearchHistory.tsx b/src/chrome/header/SearchHistory.tsx new file mode 100644 index 0000000..eaa32c4 --- /dev/null +++ b/src/chrome/header/SearchHistory.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; + +export function SearchHistory() { + const [showSearchHistory, setShowSearchHistory] = useState(false); + + const close = () => { + if (showSearchHistory) setShowSearchHistory(false); + }; + + return ( +
    + setShowSearchHistory(!showSearchHistory)} + /> +
    + +
    + {showSearchHistory && ( +
    +
    +
    + +
    + Type to search... +
    +
    + )} +
    + ); +} diff --git a/src/chrome/header/SearchOptions.tsx b/src/chrome/header/SearchOptions.tsx new file mode 100644 index 0000000..cc9bea9 --- /dev/null +++ b/src/chrome/header/SearchOptions.tsx @@ -0,0 +1,103 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +export function SearchOptions() { + const [showSearchOptions, setShowSearchOptions] = useState(false); + + const close = () => { + if (showSearchOptions) setShowSearchOptions(false); + }; + + return ( +
    + setShowSearchOptions(!showSearchOptions)} + > + + + +
    +
    +
    Search options
    + + + +
    + +
    + Category + + + +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + ); +} diff --git a/src/chrome/header/index.ts b/src/chrome/header/index.ts new file mode 100644 index 0000000..f5f8547 --- /dev/null +++ b/src/chrome/header/index.ts @@ -0,0 +1,7 @@ +export { Search } from "./Search"; +export { SearchHistory } from "./SearchHistory"; +export { SearchOptions } from "./SearchOptions"; +export { Messages } from "./Messages"; +export { BrowseApps } from "./BrowseApps"; +export { HeaderContacts } from "./HeaderContacts"; +export { HeaderCustomers, type CustomerOption } from "./HeaderCustomers"; diff --git a/src/chrome/index.ts b/src/chrome/index.ts new file mode 100644 index 0000000..cab4cde --- /dev/null +++ b/src/chrome/index.ts @@ -0,0 +1,25 @@ +export { AdminShell } from "./AdminShell"; +export { LogoutButton } from "./LogoutButton"; +export { + Search, + SearchHistory, + SearchOptions, + Messages, + BrowseApps, + HeaderContacts, + HeaderCustomers, + type CustomerOption, +} from "./header"; +export { useChromeLabels, DEFAULT_CHROME_LABELS } from "./labels"; +export type { + AdminShellProps, + ActivityFeedItem, + AppListItem, + Brand, + ChromeFeatures, + ChromeLabels, + ChromeSlots, + ChromeUser, + DbMenuItem, + MenuData, +} from "./types"; diff --git a/src/chrome/labels.ts b/src/chrome/labels.ts new file mode 100644 index 0000000..5fd9bfc --- /dev/null +++ b/src/chrome/labels.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { ChromeLabels } from "./types"; + +/** + * Default English chrome labels. Used as fallback when next-intl namespace + * "chrome" doesn't resolve a key and no per-key override is passed in props. + */ +export const DEFAULT_CHROME_LABELS: ChromeLabels = { + navigation: "Navigation", + main: "MAIN", + dashboard: "Dashboard", + support: "Support", + settings: "Settings", + allSettings: "All Settings", + logout: "Logout", + docs: "Docs", + browseApps: "Browse apps", + viewAll: "View all", + activityTitle: "Activity", + newNotifications: "New notifications", + olderNotifications: "Older notifications", + noOlderNotifications: "No older notifications", + noNewNotifications: "No new notifications", + expandSidebar: "Expand sidebar", + collapseSidebar: "Collapse sidebar", + closeSidebar: "Close sidebar", +}; + +/** + * Resolve chrome labels with three-level precedence: + * 1. `overrides` prop (per-key escape hatch) + * 2. next-intl namespace "chrome" message + * 3. hardcoded English default + * + * Apps add `"chrome": { ... }` to their next-intl messages to translate. + */ +export function useChromeLabels(overrides?: Partial): ChromeLabels { + let t: ((key: string) => string) | null = null; + try { + t = useTranslations("chrome"); + } catch { + t = null; + } + + const resolved = { ...DEFAULT_CHROME_LABELS }; + + if (t) { + for (const key of Object.keys(resolved) as (keyof ChromeLabels)[]) { + try { + const v = t(key); + // next-intl returns the key path when the message is missing — treat + // that as "no translation" so we fall back to the English default. + if (v && v !== key && !v.startsWith("chrome.")) { + resolved[key] = v; + } + } catch { + // Missing key — keep the default. + } + } + } + + if (overrides) { + for (const k of Object.keys(overrides) as (keyof ChromeLabels)[]) { + const v = overrides[k]; + if (v != null) resolved[k] = v; + } + } + + return resolved; +} diff --git a/src/chrome/types.ts b/src/chrome/types.ts new file mode 100644 index 0000000..dece69a --- /dev/null +++ b/src/chrome/types.ts @@ -0,0 +1,174 @@ +import type { ReactNode } from "react"; + +// ============================================================================ +// Menu data (Prisma-shaped, returned by app's getMenuItemsByType()) +// ============================================================================ + +export type DbMenuItem = { + id: string; + key: string; + translationKey: string; + url: string; + icon: string | null; + sortOrder: number; + isActive: boolean; + isSystemRequired: boolean; + children?: DbMenuItem[]; +}; + +export type MenuData = { + sidebar: DbMenuItem[]; + topbar: DbMenuItem[]; + subbar: DbMenuItem[]; +}; + +// ============================================================================ +// Browse-apps panel data (returned by app's getApps()) +// ============================================================================ + +export type AppListItem = { + key: string; + name: string; + description?: string; + url: string; + iconClass?: string | null; // e.g. "ph-phone" + iconUrl?: string | null; // image URL (preferred when set) + iconBg?: string | null; // background utility class for iconClass square + sortOrder: number; + enabled: boolean; +}; + +// ============================================================================ +// Activity panel data (when features.activityPanel:true) +// Optional — apps may pass `slots.activityPanel` to render their own body. +// ============================================================================ + +export type ActivityFeedItem = { + id: string; + actorName: string; + actorAvatarUrl?: string; + message: ReactNode; // freeform body, may contain JSX + timestamp: string; // pre-formatted display string (e.g. "2 hours ago") + group?: "new" | "older"; // default "new" +}; + +// ============================================================================ +// Brand (replaces site-informations.json) +// ============================================================================ + +export type Brand = { + name: string; // "CRM" + product: string; // "GoSec CRM" + logoUrl: string; // full navbar logo + logoSmallUrl?: string; // optional compact logo + websiteUrl: string; // footer brand link + supportUrl: string; // subbar Support + footer Support link + docsUrl: string; // footer docs link + copyrightStartYear: number; // e.g. 2023 +}; + +// ============================================================================ +// User +// ============================================================================ + +export type ChromeUser = { + displayName: string; + email?: string; + avatarUrl?: string; + roles?: string[]; // future: role-gated menu filtering +}; + +// ============================================================================ +// Feature toggles +// ============================================================================ + +export type ChromeFeatures = { + // Navbar + search?: boolean; // default true + searchHistory?: boolean; // default true + searchOptions?: boolean; // default true + browseApps?: boolean; // default true + messages?: boolean; // default true + notifications?: boolean; // default true + // Subbar + subbar?: boolean; // default true + subbarSupport?: boolean; // default true + subbarSettings?: boolean; // default true + // Page header + pageHeader?: boolean; // default true + pageHeaderCustomers?: boolean; // default false (CRM-specific) + pageHeaderContacts?: boolean; // default false (CRM-specific) + // Overlays + activityPanel?: boolean; // default false + chat?: boolean; // default false + // Footer + footer?: boolean; // default true +}; + +// ============================================================================ +// Slots (app-rendered content; overrides built-in renderers) +// ============================================================================ + +export type ChromeSlots = { + pageTitle?: ReactNode; + pageHeaderExtras?: ReactNode; + subbarExtras?: ReactNode; + activityPanel?: ReactNode; // overrides built-in activity body + navbarExtras?: ReactNode; + footerExtras?: ReactNode; +}; + +// ============================================================================ +// i18n labels (chrome strings, overridable per-key) +// Defaults shipped in labels.ts; apps may also set keys under next-intl +// namespace "chrome". +// ============================================================================ + +export type ChromeLabels = { + navigation: string; // sidebar header "Navigation" + main: string; // sidebar group header "MAIN" + dashboard: string; // default page title for "/" + support: string; // subbar / footer "Support" + settings: string; // subbar "Settings" + allSettings: string; // subbar dropdown "All Settings" + logout: string; // user dropdown "Logout" + docs: string; // footer "Docs" + browseApps: string; // browse-apps "Browse apps" + viewAll: string; // browse-apps "View all" + activityTitle: string; // activity panel header + newNotifications: string; // activity panel "New notifications" + olderNotifications: string; // activity panel "Older notifications" + noOlderNotifications: string; // activity panel empty older state + noNewNotifications: string; // activity panel empty new state + expandSidebar: string; // aria-label + collapseSidebar: string; // aria-label + closeSidebar: string; // aria-label +}; + +// ============================================================================ +// AdminShell props +// ============================================================================ + +export type AdminShellProps = { + // Data + menus: MenuData; + apps: AppListItem[]; + user: ChromeUser; + notificationCount?: number; + activity?: ActivityFeedItem[]; + + // Brand (required) + brand: Brand; + + // Feature toggles + features?: ChromeFeatures; + + // Slots + slots?: ChromeSlots; + + // Behavior + onSignOut?: () => void | Promise; + labels?: Partial; + + children: ReactNode; +}; diff --git a/src/index.ts b/src/index.ts index c55888d..edce352 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export * from "./layout/index"; export * from "./shell/index"; +export * from "./chrome/index"; export * from "./data/index"; export * from "./forms/index"; export * from "./feedback/index";