project-portfolio 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,7 +147,8 @@ export async function ProjectsMegaMenu() {
147
147
  | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
148
148
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
149
149
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
150
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
150
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
151
+ | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
151
152
 
152
153
  The filter options in the right sidebar are driven automatically by the `is_filterable` fields returned from the API schema — no manual configuration needed.
153
154
 
@@ -170,7 +171,8 @@ export default async function ProjectPage({ params }: { params: { slug: string }
170
171
  apiBase="https://your-api.com"
171
172
  />
172
173
  <SimilarProjects
173
- currentSlug={params.slug}
174
+ filters={{ type: "commercial" }}
175
+ excludeSlug={params.slug}
174
176
  clientSlug="your-client-slug"
175
177
  apiBase="https://your-api.com"
176
178
  basePath="/projects"
@@ -180,63 +182,85 @@ export default async function ProjectPage({ params }: { params: { slug: string }
180
182
  }
181
183
  ```
182
184
 
185
+ `filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
186
+
183
187
  | Prop | Type | Required | Default | Description |
184
188
  |---|---|---|---|---|
185
- | `currentSlug` | `string` | Yes | | Slug of the current project excluded from results |
189
+ | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
190
+ | `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
186
191
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
187
192
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
188
193
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
194
+ | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
189
195
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
190
196
  | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
191
197
 
192
198
  ---
193
199
 
194
- ### `ProjectMenuClient`
200
+ ### `ProjectMenuClient` + `createMenuHandler`
201
+
202
+ `ProjectMenuClient` is a `"use client"` component that works in any React tree — no RSC, no `Suspense` wrapper, no server file needed. It fetches its own data and caches it in memory for the lifetime of the page session so the API is never called twice on a hover/re-hover.
203
+
204
+ There are two ways to use it:
205
+
206
+ ---
195
207
 
196
- A self-fetching client component version of `ProjectMenu`. Drop it anywhere including inside `"use client"` components, existing navigation systems, WordPress, Webflow, or any React tree. It fetches its own data on mount when given `clientSlug` and `apiBase`.
208
+ **Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
209
+
210
+ Set up one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
211
+
212
+ ```ts
213
+ // app/api/chisel-menu/route.ts
214
+ import { createMenuHandler } from "project-portfolio"
215
+
216
+ export const GET = createMenuHandler({
217
+ clientSlug: "your-client-slug",
218
+ apiBase: "https://your-api.com",
219
+ })
220
+ ```
197
221
 
198
222
  ```tsx
199
- // Works inside ANY component no RSC required
200
- "use client"
223
+ // In your nav/headerworks in any "use client" component
201
224
  import { ProjectMenuClient } from "project-portfolio"
202
225
 
203
- export function MegaMenu() {
204
- return (
205
- <ProjectMenuClient
206
- clientSlug="your-client-slug"
207
- apiBase="https://your-api.com"
208
- basePath="/projects"
209
- viewAllPath="/projects"
210
- />
211
- )
212
- }
226
+ <ProjectMenuClient
227
+ dataUrl="/api/chisel-menu"
228
+ basePath="/projects"
229
+ viewAllPath="/projects"
230
+ />
231
+ ```
232
+
233
+ ---
234
+
235
+ **Option 2 — Direct fetch (quick setup / non-Next.js environments)**
236
+
237
+ No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session.
238
+
239
+ ```tsx
240
+ import { ProjectMenuClient } from "project-portfolio"
241
+
242
+ <ProjectMenuClient
243
+ clientSlug="your-client-slug"
244
+ apiBase="https://your-api.com"
245
+ basePath="/projects"
246
+ viewAllPath="/projects"
247
+ />
213
248
  ```
214
249
 
250
+ ---
251
+
215
252
  | Prop | Type | Required | Default | Description |
216
253
  |---|---|---|---|---|
217
- | `clientSlug` | `string` | Yes* | — | Client slug triggers self-fetching mode when provided with `apiBase` |
218
- | `apiBase` | `string` | Yes* | — | API base URL triggers self-fetching mode when provided with `clientSlug` |
254
+ | `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
255
+ | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
256
+ | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
219
257
  | `basePath` | `string` | Yes | — | Base path for project detail links |
220
258
  | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
221
259
  | `subtitle` | `string` | No | — | Description shown above the project cards |
222
260
  | `font` | `string` | No | System font stack | Font family string |
223
261
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
224
262
 
225
- *When not using self-fetching mode, pre-fetched `projects`, `schema`, `filterOptions`, `filterFieldKey`, and `fieldOptionsMap` can be passed directly instead.
226
-
227
- **Advanced: pre-fetch with `fetchProjectMenuData`**
228
-
229
- If you want server-side data fetching without RSC (e.g. in a Next.js API route), use `fetchProjectMenuData` and spread the result into `ProjectMenuClient`:
230
-
231
- ```tsx
232
- import { fetchProjectMenuData } from "project-portfolio"
233
-
234
- // In your API route or server action:
235
- const data = await fetchProjectMenuData({ clientSlug: "...", apiBase: "..." })
236
-
237
- // Then pass to the client component:
238
- <ProjectMenuClient {...data} basePath="/projects" viewAllPath="/projects" />
239
- ```
263
+ *One of `dataUrl` or `clientSlug + apiBase` must be provided. Pre-fetched `projects`, `schema`, `filterOptions`, `filterFieldKey`, and `fieldOptionsMap` can also be passed directly for SSR usage.
240
264
 
241
265
  ---
242
266
 
@@ -263,10 +287,33 @@ If a parent component uses `"use client"`, these components cannot be rendered i
263
287
 
264
288
  ## Caching
265
289
 
266
- Data is cached at two levels:
290
+ Data is cached at multiple levels depending on which component is used.
291
+
292
+ **Server Components (`ProjectMenu`, `ProjectPortfolio`, `ProjectDetail`, `SimilarProjects`)**
293
+ 1. **Per-render** — `React.cache()` deduplicates duplicate calls within the same render pass
294
+ 2. **Cross-request** — `next: { revalidate: 86400 }` (24 hours) caches responses in Next.js's Data Cache across all users and sessions
267
295
 
268
- 1. **Per-render** `React.cache()` deduplicates any duplicate calls within the same render pass
269
- 2. **Cross-request** — `next: { revalidate: N }` caches responses in Next.js's built-in Data Cache
296
+ **`ProjectMenuClient` with `createMenuHandler` (recommended)**
297
+ 3. **Server-side route cache** — the `/api/chisel-menu` route is cached by Next.js for 24 hours. The upstream API is called at most once per day regardless of traffic
298
+ 4. **Module-level client cache** — after the first fetch within a session, the result is held in memory. Re-hovering the menu or remounting the component never triggers another network request
299
+
300
+ **`ProjectMenuClient` with direct fetch**
301
+ 4. **Module-level client cache only** — the upstream API is called once per session per user (on first mount), then cached in memory for the rest of that page session
302
+
303
+ **Bypassing the cache**
304
+
305
+ | Method | Scope | Use case |
306
+ |---|---|---|
307
+ | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
308
+ | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
309
+ | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
310
+ | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
311
+
312
+ ```ts
313
+ // CMS webhook example — invalidate server cache immediately on publish
314
+ import { revalidateTag } from "next/cache"
315
+ revalidateTag("chisel-menu-your-client-slug")
316
+ ```
270
317
 
271
318
  No third-party caching libraries are required.
272
319
 
@@ -14,7 +14,7 @@ export interface ProjectMenuProps {
14
14
  font?: string;
15
15
  /** Max number of projects to show at once. Defaults to 6 */
16
16
  maxProjects?: number;
17
- /** Revalidation period in seconds. Defaults to 60 */
17
+ /** Revalidation period in seconds. Defaults to 86400 (24 hours) */
18
18
  revalidate?: number;
19
19
  /**
20
20
  * Disable all caching. Every request hits the API fresh.
@@ -22,6 +22,35 @@ export interface ProjectMenuProps {
22
22
  */
23
23
  noCache?: boolean;
24
24
  }
25
+ /**
26
+ * createMenuHandler
27
+ *
28
+ * Creates a Next.js route handler that fetches and server-caches the menu data.
29
+ * Drop it into app/api/chisel-menu/route.ts — one line of setup.
30
+ *
31
+ * Features:
32
+ * - Cached for 24 hours by default (override with revalidate option)
33
+ * - Add ?bust=1 to bypass the cache for a single request (dev/testing)
34
+ * - Set CHISEL_CACHE_BYPASS=true in env to disable caching for the whole deployment (staging)
35
+ * - Fetch is tagged with "chisel-menu-{clientSlug}" for revalidateTag() support
36
+ *
37
+ * @example
38
+ * // app/api/chisel-menu/route.ts
39
+ * import { createMenuHandler } from "project-portfolio"
40
+ * export const GET = createMenuHandler({ clientSlug: "my-client", apiBase: "https://nexus.chiselandco.com" })
41
+ *
42
+ * // Bust the cache manually (one request):
43
+ * fetch("/api/chisel-menu?bust=1")
44
+ *
45
+ * // Invalidate from a CMS webhook:
46
+ * import { revalidateTag } from "next/cache"
47
+ * revalidateTag("chisel-menu-my-client")
48
+ */
49
+ export declare function createMenuHandler({ clientSlug, apiBase, revalidate, }: {
50
+ clientSlug: string;
51
+ apiBase: string;
52
+ revalidate?: number;
53
+ }): (request: Request) => Promise<Response>;
25
54
  export declare function fetchProjectMenuData({ apiBase, clientSlug, revalidate, noCache, }: {
26
55
  apiBase: string;
27
56
  clientSlug: string;
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectMenu.d.ts","sourceRoot":"","sources":["../src/ProjectMenu.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAKzD,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,wBAAsB,oBAAoB,CAAC,EACzC,OAAO,EACP,UAAU,EACV,UAAe,EACf,OAAe,GAChB,EAAE;IACD,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,aAAa,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC9C,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACxD,CAAC,CAqBD;AA6CD,wBAAsB,WAAW,CAAC,EAChC,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,WAAW,EACX,QAAQ,EACR,IAA0E,EAC1E,WAAe,EACf,UAAe,EACf,OAAe,GAChB,EAAE,gBAAgB,oDAiClB"}
1
+ {"version":3,"file":"ProjectMenu.d.ts","sourceRoot":"","sources":["../src/ProjectMenu.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAKzD,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,UAAU,EACV,OAAO,EACP,UAAkB,GACnB,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,aACoC,OAAO,uBAsD3C;AAED,wBAAsB,oBAAoB,CAAC,EACzC,OAAO,EACP,UAAU,EACV,UAAkB,EAClB,OAAe,GAChB,EAAE;IACD,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,aAAa,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC9C,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACxD,CAAC,CAqBD;AA6CD,wBAAsB,WAAW,CAAC,EAChC,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,WAAW,EACX,QAAQ,EACR,IAA0E,EAC1E,WAAe,EACf,UAAkB,EAClB,OAAe,GAChB,EAAE,gBAAgB,oDAiClB"}
@@ -2,7 +2,82 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cache } from "react";
3
3
  import { ProjectMenuClient } from "./ProjectMenuClient";
4
4
  const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
5
- export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 60, noCache = false, }) {
5
+ /**
6
+ * createMenuHandler
7
+ *
8
+ * Creates a Next.js route handler that fetches and server-caches the menu data.
9
+ * Drop it into app/api/chisel-menu/route.ts — one line of setup.
10
+ *
11
+ * Features:
12
+ * - Cached for 24 hours by default (override with revalidate option)
13
+ * - Add ?bust=1 to bypass the cache for a single request (dev/testing)
14
+ * - Set CHISEL_CACHE_BYPASS=true in env to disable caching for the whole deployment (staging)
15
+ * - Fetch is tagged with "chisel-menu-{clientSlug}" for revalidateTag() support
16
+ *
17
+ * @example
18
+ * // app/api/chisel-menu/route.ts
19
+ * import { createMenuHandler } from "project-portfolio"
20
+ * export const GET = createMenuHandler({ clientSlug: "my-client", apiBase: "https://nexus.chiselandco.com" })
21
+ *
22
+ * // Bust the cache manually (one request):
23
+ * fetch("/api/chisel-menu?bust=1")
24
+ *
25
+ * // Invalidate from a CMS webhook:
26
+ * import { revalidateTag } from "next/cache"
27
+ * revalidateTag("chisel-menu-my-client")
28
+ */
29
+ export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, }) {
30
+ return async function GET(request) {
31
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
32
+ const bypass = process.env.CHISEL_CACHE_BYPASS === "true" ||
33
+ new URL(request.url).searchParams.has("bust");
34
+ const fetchOpts = bypass
35
+ ? { cache: "no-store" }
36
+ : { next: { revalidate, tags: [`chisel-menu-${clientSlug}`] } };
37
+ try {
38
+ const [projectsRes, fieldsRes] = await Promise.all([
39
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
40
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`, fetchOpts),
41
+ ]);
42
+ const json = projectsRes.ok ? await projectsRes.json() : {};
43
+ const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
44
+ const schema = (_c = (_b = json === null || json === void 0 ? void 0 : json.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : [];
45
+ const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
46
+ const fieldOptionsMap = {};
47
+ for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
48
+ const map = {};
49
+ for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
50
+ if (typeof opt === "object" && opt.id && opt.label) {
51
+ map[opt.id] = opt.label;
52
+ map[opt.label] = opt.label;
53
+ }
54
+ }
55
+ fieldOptionsMap[field.key] = map;
56
+ }
57
+ const filterField = (_f = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _f !== void 0 ? _f : null;
58
+ const filterOptions = filterField
59
+ ? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
60
+ var _a, _b;
61
+ if (typeof opt === "string")
62
+ return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
63
+ return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
64
+ })
65
+ : [];
66
+ return Response.json({
67
+ projects,
68
+ schema,
69
+ filterOptions,
70
+ filterFieldKey: (_h = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _h !== void 0 ? _h : null,
71
+ filterFieldName: (_j = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _j !== void 0 ? _j : "Project Type",
72
+ fieldOptionsMap,
73
+ });
74
+ }
75
+ catch (_k) {
76
+ return Response.json({ projects: [], schema: [], filterOptions: [], filterFieldKey: null, filterFieldName: "Project Type", fieldOptionsMap: {} }, { status: 500 });
77
+ }
78
+ };
79
+ }
80
+ export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 86400, noCache = false, }) {
6
81
  var _a, _b, _c, _d;
7
82
  const raw = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
8
83
  const filterField = (_a = raw.schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
@@ -58,7 +133,7 @@ const _fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = f
58
133
  return { projects: [], schema: [], fieldOptionsMap: {} };
59
134
  }
60
135
  });
61
- export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 60, noCache = false, }) {
136
+ export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 86400, noCache = false, }) {
62
137
  var _a, _b, _c, _d;
63
138
  const { projects, schema, fieldOptionsMap } = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
64
139
  // Find the filterable select field (badge_overlay is our category field)
@@ -1,9 +1,16 @@
1
1
  import type { Project, CustomFieldSchema } from "./types";
2
2
  export interface ProjectMenuClientProps {
3
3
  /**
4
- * Pass clientSlug + apiBase to have the component fetch its own data.
5
- * Use this in non-RSC environments (client components, WordPress, Webflow, etc.)
6
- * Alternatively, pass projects + schema + filterOptions directly for SSR usage.
4
+ * URL of a local API route created with createMenuHandler().
5
+ * Recommended for production data is server-cached for 24h and never
6
+ * exposes the upstream API key to the browser.
7
+ * e.g. dataUrl="/api/chisel-menu"
8
+ */
9
+ dataUrl?: string;
10
+ /**
11
+ * Pass clientSlug + apiBase to have the component fetch its own data directly.
12
+ * Use this for quick setup or non-Next.js environments.
13
+ * For production Next.js apps, prefer dataUrl + createMenuHandler instead.
7
14
  */
8
15
  clientSlug?: string;
9
16
  apiBase?: string;
@@ -22,5 +29,5 @@ export interface ProjectMenuClientProps {
22
29
  font?: string;
23
30
  maxProjects?: number;
24
31
  }
25
- export declare function ProjectMenuClient({ clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp, fieldOptionsMap: fieldOptionsMapProp, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
32
+ export declare function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp, fieldOptionsMap: fieldOptionsMapProp, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
26
33
  //# sourceMappingURL=ProjectMenuClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectMenuClient.d.ts","sourceRoot":"","sources":["../src/ProjectMenuClient.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAa3E,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAC5B,aAAa,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC/C,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAMD,wBAAgB,iBAAiB,CAAC,EAChC,UAAU,EACV,OAAO,EACP,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,UAAU,EAClB,aAAa,EAAE,iBAAiB,EAChC,cAAc,EAAE,kBAAkB,EAClC,eAAe,EAAE,mBAAoC,EACrD,eAAe,EAAE,mBAAwB,EACzC,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,IAAmB,EACnB,WAAe,GAChB,EAAE,sBAAsB,2CA2axB"}
1
+ {"version":3,"file":"ProjectMenuClient.d.ts","sourceRoot":"","sources":["../src/ProjectMenuClient.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAa3E,MAAM,WAAW,sBAAsB;IACrC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAC5B,aAAa,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC/C,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAmBD,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,UAAU,EACV,OAAO,EACP,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,UAAU,EAClB,aAAa,EAAE,iBAAiB,EAChC,cAAc,EAAE,kBAAkB,EAClC,eAAe,EAAE,mBAAoC,EACrD,eAAe,EAAE,mBAAwB,EACzC,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,IAAmB,EACnB,WAAe,GAChB,EAAE,sBAAsB,2CAscxB"}
@@ -16,65 +16,86 @@ function parseMultiValue(raw) {
16
16
  const ACCENT = "oklch(0.78 0.16 85)";
17
17
  const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
18
18
  const DEFAULT_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
19
- export function ProjectMenuClient({ clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp = "Project Type", fieldOptionsMap: fieldOptionsMapProp = {}, subtitle, basePath, viewAllPath, font = DEFAULT_FONT, maxProjects = 6, }) {
19
+ const menuDataCache = new Map();
20
+ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp = "Project Type", fieldOptionsMap: fieldOptionsMapProp = {}, subtitle, basePath, viewAllPath, font = DEFAULT_FONT, maxProjects = 6, }) {
20
21
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
21
22
  const [filtersOpen, setFiltersOpen] = useState(false);
22
23
  const [hoveredCard, setHoveredCard] = useState(null);
23
24
  const [fetched, setFetched] = useState(null);
24
- // Self-fetch mode: when clientSlug + apiBase are provided, fetch on mount
25
+ // Self-fetch mode: fires when dataUrl OR (clientSlug + apiBase) are provided.
26
+ // Uses a module-level Promise cache so repeated mounts never re-hit the API.
25
27
  useEffect(() => {
26
- if (!clientSlug || !apiBase)
28
+ const hasDataUrl = !!dataUrl;
29
+ const hasDirectFetch = !!(clientSlug && apiBase);
30
+ if (!hasDataUrl && !hasDirectFetch)
27
31
  return;
28
32
  let cancelled = false;
29
- async function load() {
30
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
31
- try {
32
- const [projectsRes, fieldsRes] = await Promise.all([
33
- fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
34
- fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
35
- ]);
36
- if (cancelled)
37
- return;
38
- const json = projectsRes.ok ? await projectsRes.json() : {};
39
- const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
40
- const schema = (_c = (_b = json === null || json === void 0 ? void 0 : json.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : [];
41
- const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
42
- const fieldOptionsMap = {};
43
- for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
44
- const map = {};
45
- for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
46
- if (typeof opt === "object" && opt.id && opt.label) {
47
- map[opt.id] = opt.label;
48
- map[opt.label] = opt.label;
49
- }
33
+ const cacheKey = dataUrl !== null && dataUrl !== void 0 ? dataUrl : `${clientSlug}:${apiBase}`;
34
+ async function fetchAndCache() {
35
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
36
+ // dataUrl mode: fetch from local API route (server-cached, no API key exposed)
37
+ if (dataUrl) {
38
+ const res = await fetch(dataUrl);
39
+ const json = res.ok ? await res.json() : {};
40
+ return {
41
+ projects: (_a = json.projects) !== null && _a !== void 0 ? _a : [],
42
+ schema: (_b = json.schema) !== null && _b !== void 0 ? _b : [],
43
+ filterOptions: (_c = json.filterOptions) !== null && _c !== void 0 ? _c : [],
44
+ filterFieldKey: (_d = json.filterFieldKey) !== null && _d !== void 0 ? _d : null,
45
+ filterFieldName: (_e = json.filterFieldName) !== null && _e !== void 0 ? _e : "Project Type",
46
+ fieldOptionsMap: (_f = json.fieldOptionsMap) !== null && _f !== void 0 ? _f : {},
47
+ };
48
+ }
49
+ // Direct fetch mode: fetch from the upstream API directly
50
+ const [projectsRes, fieldsRes] = await Promise.all([
51
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
52
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
53
+ ]);
54
+ const json = projectsRes.ok ? await projectsRes.json() : {};
55
+ const projects = (_g = json === null || json === void 0 ? void 0 : json.data) !== null && _g !== void 0 ? _g : [];
56
+ const schema = (_j = (_h = json === null || json === void 0 ? void 0 : json.client) === null || _h === void 0 ? void 0 : _h.custom_fields_schema) !== null && _j !== void 0 ? _j : [];
57
+ const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
58
+ const fieldOptionsMap = {};
59
+ for (const field of ((_k = fieldsJson.fields) !== null && _k !== void 0 ? _k : [])) {
60
+ const map = {};
61
+ for (const opt of ((_l = field.options) !== null && _l !== void 0 ? _l : [])) {
62
+ if (typeof opt === "object" && opt.id && opt.label) {
63
+ map[opt.id] = opt.label;
64
+ map[opt.label] = opt.label;
50
65
  }
51
- fieldOptionsMap[field.key] = map;
52
66
  }
53
- const filterField = (_f = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _f !== void 0 ? _f : null;
54
- const filterOptions = filterField
55
- ? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
56
- var _a, _b;
57
- if (typeof opt === "string")
58
- return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
59
- return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
60
- })
61
- : [];
62
- setFetched({
63
- projects,
64
- schema,
65
- filterOptions,
66
- filterFieldKey: (_h = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _h !== void 0 ? _h : null,
67
- filterFieldName: (_j = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _j !== void 0 ? _j : "Project Type",
68
- fieldOptionsMap,
69
- });
70
- }
71
- catch (_k) {
72
- // silently fail — render nothing
67
+ fieldOptionsMap[field.key] = map;
73
68
  }
69
+ const filterField = (_m = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _m !== void 0 ? _m : null;
70
+ const filterOptions = filterField
71
+ ? ((_o = filterField.options) !== null && _o !== void 0 ? _o : []).map((opt) => {
72
+ var _a, _b;
73
+ if (typeof opt === "string")
74
+ return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
75
+ return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
76
+ })
77
+ : [];
78
+ return {
79
+ projects,
80
+ schema,
81
+ filterOptions,
82
+ filterFieldKey: (_p = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _p !== void 0 ? _p : null,
83
+ filterFieldName: (_q = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _q !== void 0 ? _q : "Project Type",
84
+ fieldOptionsMap,
85
+ };
86
+ }
87
+ // Store the Promise on first call — reuse it on every subsequent mount
88
+ if (!menuDataCache.has(cacheKey)) {
89
+ menuDataCache.set(cacheKey, fetchAndCache());
74
90
  }
75
- load();
91
+ menuDataCache.get(cacheKey).then((data) => {
92
+ if (!cancelled)
93
+ setFetched(data);
94
+ }).catch(() => {
95
+ // silently fail — render nothing
96
+ });
76
97
  return () => { cancelled = true; };
77
- }, [clientSlug, apiBase]);
98
+ }, [dataUrl, clientSlug, apiBase]);
78
99
  // Resolve data: prefer self-fetched, fall back to props
79
100
  const projects = (_b = (_a = fetched === null || fetched === void 0 ? void 0 : fetched.projects) !== null && _a !== void 0 ? _a : projectsProp) !== null && _b !== void 0 ? _b : [];
80
101
  const schema = (_d = (_c = fetched === null || fetched === void 0 ? void 0 : fetched.schema) !== null && _c !== void 0 ? _c : schemaProp) !== null && _d !== void 0 ? _d : [];
@@ -83,7 +104,7 @@ export function ProjectMenuClient({ clientSlug, apiBase, projects: projectsProp,
83
104
  const filterFieldName = (_j = fetched === null || fetched === void 0 ? void 0 : fetched.filterFieldName) !== null && _j !== void 0 ? _j : filterFieldNameProp;
84
105
  const fieldOptionsMap = (_k = fetched === null || fetched === void 0 ? void 0 : fetched.fieldOptionsMap) !== null && _k !== void 0 ? _k : fieldOptionsMapProp;
85
106
  // Show a minimal skeleton while self-fetching
86
- if (clientSlug && apiBase && !fetched) {
107
+ if ((dataUrl || (clientSlug && apiBase)) && !fetched) {
87
108
  return (_jsx("div", { style: { padding: "2rem 2.5rem", fontFamily: font, color: "#a1a1aa", fontSize: "14px" }, children: "Loading projects..." }));
88
109
  }
89
110
  const displayed = projects.slice(0, maxProjects);
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export type { ProjectDetailProps } from "./ProjectDetail";
5
5
  export { SimilarProjects } from "./SimilarProjects";
6
6
  export type { SimilarProjectsProps } from "./SimilarProjects";
7
7
  export { GalleryCarousel } from "./GalleryCarousel";
8
- export { ProjectMenu, fetchProjectMenuData } from "./ProjectMenu";
8
+ export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
9
9
  export type { ProjectMenuProps } from "./ProjectMenu";
10
10
  export { ProjectMenuClient } from "./ProjectMenuClient";
11
11
  export type { ProjectMenuClientProps } from "./ProjectMenuClient";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AACjE,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAA;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACpF,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAA;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
package/dist/index.js CHANGED
@@ -2,6 +2,6 @@ export { ProjectPortfolio } from "./ProjectPortfolio";
2
2
  export { ProjectDetail } from "./ProjectDetail";
3
3
  export { SimilarProjects } from "./SimilarProjects";
4
4
  export { GalleryCarousel } from "./GalleryCarousel";
5
- export { ProjectMenu, fetchProjectMenuData } from "./ProjectMenu";
5
+ export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
6
6
  export { ProjectMenuClient } from "./ProjectMenuClient";
7
7
  export { ProjectCard } from "./ProjectCard";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-portfolio",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectDetail, ProjectMenu (megamenu), and GalleryCarousel. Pass a clientSlug and apiBase — done.",
5
5
  "keywords": ["nextjs", "react", "portfolio", "projects", "megamenu", "gallery"],
6
6
  "license": "MIT",