feat(chrome)!: v0.4.0 — AdminShell + headers as /chrome sub-export
New `./chrome` entrypoint exporting `<AdminShell>` 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
`<i className="ph-…">` 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) <noreply@anthropic.com>
This commit is contained in:
1111
src/chrome/AdminShell.tsx
Normal file
1111
src/chrome/AdminShell.tsx
Normal file
File diff suppressed because it is too large
Load Diff
46
src/chrome/LogoutButton.tsx
Normal file
46
src/chrome/LogoutButton.tsx
Normal file
@@ -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<void>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<button type="button" onClick={handleLogout} className="dropdown-item">
|
||||
<i className="ph-sign-out me-2"></i>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
src/chrome/_ambient.d.ts
vendored
Normal file
17
src/chrome/_ambient.d.ts
vendored
Normal file
@@ -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;
|
||||
}>;
|
||||
}
|
||||
110
src/chrome/header/BrowseApps.tsx
Normal file
110
src/chrome/header/BrowseApps.tsx
Normal file
@@ -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<ChromeLabels, "browseApps" | "viewAll">;
|
||||
};
|
||||
|
||||
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) */}
|
||||
<li className="nav-item d-lg-none">
|
||||
<Link
|
||||
href="#navbar_search"
|
||||
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
|
||||
>
|
||||
<i className="ph ph-magnifying-glass"></i>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{show && (
|
||||
<li
|
||||
className="nav-item nav-item-dropdown-lg dropdown"
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<Link
|
||||
href="#"
|
||||
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<i className="ph ph-squares-four"></i>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className={
|
||||
open
|
||||
? "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0 show"
|
||||
: "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0"
|
||||
}
|
||||
>
|
||||
<div className="d-flex align-items-center border-bottom p-3">
|
||||
<h6 className="mb-0">{labels.browseApps}</h6>
|
||||
<Link href={viewAllUrl} className="ms-auto">
|
||||
{labels.viewAll}
|
||||
<i className="ph ph-arrow-circle-right ms-1"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row row-cols-1 row-cols-sm-2 g-0">
|
||||
{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 (
|
||||
<div className="col" key={app.key}>
|
||||
<Link href={app.url} className={itemClasses}>
|
||||
<div>
|
||||
{app.iconUrl ? (
|
||||
<img src={app.iconUrl} className="h-40px mb-2" alt="" />
|
||||
) : app.iconClass ? (
|
||||
<div
|
||||
className={`d-flex align-items-center justify-content-center h-40px w-40px rounded mb-2 ${app.iconBg ?? "bg-secondary-lt"}`}
|
||||
>
|
||||
<i className={`${app.iconClass} fs-2`}></i>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="fw-semibold my-1">{app.name}</div>
|
||||
{app.description && (
|
||||
<div className="text-muted">{app.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/chrome/header/HeaderContacts.tsx
Normal file
12
src/chrome/header/HeaderContacts.tsx
Normal file
@@ -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;
|
||||
}
|
||||
98
src/chrome/header/HeaderCustomers.tsx
Normal file
98
src/chrome/header/HeaderCustomers.tsx
Normal file
@@ -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 (
|
||||
<div className="dropdown w-100 w-sm-auto">
|
||||
<Link
|
||||
href="#"
|
||||
className="d-flex align-items-center text-body lh-1 dropdown-toggle py-sm-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={selectedLogo || current.logo}
|
||||
className="w-32px h-32px me-2"
|
||||
alt=""
|
||||
/>
|
||||
<div className="me-auto me-lg-1">
|
||||
<div className="fs-sm text-muted mb-1">Customer</div>
|
||||
<div className="fw-semibold">{selectedName || current.name}</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className={
|
||||
open
|
||||
? "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0 show"
|
||||
: "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0"
|
||||
}
|
||||
onMouseLeave={toggle}
|
||||
>
|
||||
<div className="d-flex align-items-center p-3">
|
||||
<h6 className="fw-semibold mb-0">Customers</h6>
|
||||
<Link href="#" className="ms-auto">
|
||||
View all
|
||||
<i className="ph ph-arrow-circle-right ms-1"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{customers.map((c) => (
|
||||
<Link
|
||||
href="#"
|
||||
className={
|
||||
selectedName === c.name
|
||||
? "dropdown-item py-2 active"
|
||||
: "dropdown-item py-2"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
select(c);
|
||||
}}
|
||||
key={c.id}
|
||||
>
|
||||
<img src={c.logo} className="w-32px h-32px me-2" alt="" />
|
||||
<div>
|
||||
<div className="fw-semibold">{c.name}</div>
|
||||
{c.description && (
|
||||
<div className="fs-sm text-muted">{c.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/chrome/header/Messages.tsx
Normal file
24
src/chrome/header/Messages.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
type MessagesProps = {
|
||||
show?: boolean;
|
||||
onOpenChat?: () => void;
|
||||
};
|
||||
|
||||
export function Messages({ show = true, onOpenChat }: MessagesProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<li className="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
className="navbar-nav-link navbar-nav-link-icon rounded-pill border-0 bg-transparent position-relative"
|
||||
title="Open chat"
|
||||
aria-label="Open chat"
|
||||
onClick={onOpenChat}
|
||||
>
|
||||
<i className="ph ph-chat-circle"></i>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
26
src/chrome/header/Search.tsx
Normal file
26
src/chrome/header/Search.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="navbar-collapse justify-content-center flex-lg-1 order-2 order-lg-1 collapse"
|
||||
id="navbar_search"
|
||||
>
|
||||
<div className="navbar-search flex-fill position-relative mt-2 mt-lg-0 mx-lg-3">
|
||||
{showHistory && <SearchHistory />}
|
||||
{showOptions && <SearchOptions />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/chrome/header/SearchHistory.tsx
Normal file
39
src/chrome/header/SearchHistory.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className="form-control-feedback form-control-feedback-start flex-grow-1"
|
||||
data-color-theme="dark"
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-transparent rounded-pill"
|
||||
placeholder="Search"
|
||||
onClick={() => setShowSearchHistory(!showSearchHistory)}
|
||||
/>
|
||||
<div className="form-control-feedback-icon">
|
||||
<i className="ph ph-magnifying-glass"></i>
|
||||
</div>
|
||||
{showSearchHistory && (
|
||||
<div className="dropdown-menu w-100 show" data-color-theme="light">
|
||||
<div className="dropdown-item text-muted">
|
||||
<div className="text-center w-32px me-3">
|
||||
<i className="ph ph-magnifying-glass"></i>
|
||||
</div>
|
||||
<span>Type to search...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/chrome/header/SearchOptions.tsx
Normal file
103
src/chrome/header/SearchOptions.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<Link
|
||||
href="#"
|
||||
className="navbar-nav-link align-items-center justify-content-center w-40px h-32px rounded-pill position-absolute end-0 top-50 translate-middle-y p-0 me-1"
|
||||
onClick={() => setShowSearchOptions(!showSearchOptions)}
|
||||
>
|
||||
<i className="ph ph-faders-horizontal"></i>
|
||||
</Link>
|
||||
|
||||
<div
|
||||
className={
|
||||
showSearchOptions
|
||||
? "dropdown-menu w-100 p-3 show"
|
||||
: "dropdown-menu w-100 p-3"
|
||||
}
|
||||
onMouseLeave={close}
|
||||
>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h6 className="mb-0">Search options</h6>
|
||||
<Link href="#" className="text-body rounded-pill ms-auto">
|
||||
<i className="ph ph-clock-counter-clockwise"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<span className="d-block form-label">Category</span>
|
||||
<label className="form-check form-check-inline">
|
||||
<input type="checkbox" className="form-check-input" />
|
||||
<span className="form-check-label">Invoices</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input type="checkbox" className="form-check-input" />
|
||||
<span className="form-check-label">Files</span>
|
||||
</label>
|
||||
<label className="form-check form-check-inline">
|
||||
<input type="checkbox" className="form-check-input" />
|
||||
<span className="form-check-label">Users</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Addition</label>
|
||||
<div className="input-group">
|
||||
<select className="form-select w-auto flex-grow-0">
|
||||
<option value="1">has</option>
|
||||
<option value="2">has not</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Enter the word(s)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Status</label>
|
||||
<div className="input-group">
|
||||
<select className="form-select w-auto flex-grow-0">
|
||||
<option value="1">is</option>
|
||||
<option value="2">is not</option>
|
||||
</select>
|
||||
<select className="form-select">
|
||||
<option value="1">Active</option>
|
||||
<option value="2">Inactive</option>
|
||||
<option value="3">New</option>
|
||||
<option value="4">Expired</option>
|
||||
<option value="5">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex">
|
||||
<button type="button" className="btn btn-light">
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<div className="ms-auto">
|
||||
<button type="button" className="btn btn-light">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary ms-2">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/chrome/header/index.ts
Normal file
7
src/chrome/header/index.ts
Normal file
@@ -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";
|
||||
25
src/chrome/index.ts
Normal file
25
src/chrome/index.ts
Normal file
@@ -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";
|
||||
72
src/chrome/labels.ts
Normal file
72
src/chrome/labels.ts
Normal file
@@ -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>): 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;
|
||||
}
|
||||
174
src/chrome/types.ts
Normal file
174
src/chrome/types.ts
Normal file
@@ -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<void>;
|
||||
labels?: Partial<ChromeLabels>;
|
||||
|
||||
children: ReactNode;
|
||||
};
|
||||
Reference in New Issue
Block a user