project-portfolio 2.0.1 → 2.1.0

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
@@ -1,6 +1,6 @@
1
1
  # project-portfolio
2
2
 
3
- A suite of self-contained project portfolio components for Next.js App Router. Drop in a `clientSlug` and `apiBase` — each component fetches, caches, and renders everything on the server with zero client-side waterfall requests.
3
+ A suite of self-contained project portfolio components for Next.js App Router. Drop in a `clientSlug` and `apiBase` — each component fetches, caches, and renders everything it needs with zero client-side waterfall requests.
4
4
 
5
5
  ## Requirements
6
6
 
@@ -17,39 +17,97 @@ npm install project-portfolio
17
17
 
18
18
  ---
19
19
 
20
- ## Components
21
-
22
- ### `ProjectPortfolio`
20
+ ## Quick Start
23
21
 
24
- A full project grid page. Fetches all projects for a client and renders them as responsive cards (1 column on mobile, 2 on tablet, 3 on desktop).
22
+ Here is the most common full setup a projects grid page, a detail page with similar projects, and a megamenu in the nav.
25
23
 
26
24
  ```tsx
27
25
  // app/projects/page.tsx
28
26
  import { ProjectPortfolio } from "project-portfolio"
29
27
 
30
- // Must be a Server Component — do NOT add "use client"
31
- export default async function ProjectsPage() {
28
+ export default async function ProjectsPage({
29
+ searchParams,
30
+ }: {
31
+ searchParams: { [key: string]: string | string[] | undefined }
32
+ }) {
32
33
  return (
33
34
  <ProjectPortfolio
34
35
  clientSlug="your-client-slug"
35
36
  apiBase="https://your-api.com"
36
37
  basePath="/projects"
38
+ searchParams={searchParams}
37
39
  />
38
40
  )
39
41
  }
40
42
  ```
41
43
 
42
- | Prop | Type | Required | Default | Description |
43
- |---|---|---|---|---|
44
- | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
45
- | `apiBase` | `string` | Yes | — | Base URL of the projects API |
46
- | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
47
- | `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params forwarded to the API — pass Next.js `searchParams` directly |
48
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
44
+ ```tsx
45
+ // app/projects/[slug]/page.tsx
46
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
47
+
48
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
49
+ return (
50
+ <>
51
+ <ProjectDetail
52
+ slug={params.slug}
53
+ clientSlug="your-client-slug"
54
+ apiBase="https://your-api.com"
55
+ backPath="/projects"
56
+ />
57
+
58
+ {/* Hardcode the slugs you want shown — update per client request */}
59
+ <SimilarProjects
60
+ projectSlugs={[
61
+ "jacob-javits-convention-center",
62
+ "tillamook-bay-community-college",
63
+ "lcisd-liberty-hill-high-school",
64
+ ]}
65
+ excludeSlug={params.slug}
66
+ clientSlug="your-client-slug"
67
+ apiBase="https://your-api.com"
68
+ basePath="/projects"
69
+ />
70
+ </>
71
+ )
72
+ }
73
+ ```
49
74
 
50
- #### URL-driven filter integration
75
+ ```ts
76
+ // app/api/chisel-menu/route.ts
77
+ import { createMenuHandler } from "project-portfolio"
51
78
 
52
- `ProjectPortfolio` works together with `ProjectMenu` to form a complete filter flow. When a user clicks a filter link in the megamenu, they are navigated to the project grid with filter query params appended to the URL. Pass Next.js `searchParams` to `ProjectPortfolio` so it can forward them to the API:
79
+ export const GET = createMenuHandler({
80
+ clientSlug: "your-client-slug",
81
+ apiBase: "https://your-api.com",
82
+ })
83
+ ```
84
+
85
+ ```tsx
86
+ // components/Nav.tsx — "use client" component in your header
87
+ "use client"
88
+ import { ProjectMenuClient } from "project-portfolio"
89
+
90
+ export function Nav() {
91
+ return (
92
+ <nav>
93
+ {/* ... other nav items ... */}
94
+ <ProjectMenuClient
95
+ dataUrl="/api/chisel-menu"
96
+ basePath="/projects"
97
+ viewAllPath="/projects"
98
+ />
99
+ </nav>
100
+ )
101
+ }
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Components
107
+
108
+ ### `ProjectPortfolio`
109
+
110
+ A full server-rendered project grid page. Fetches all projects for a client and renders them as responsive baseball cards (1 column on mobile, 2 on tablet, 3 on desktop). Supports URL-driven filtering via `searchParams`.
53
111
 
54
112
  ```tsx
55
113
  // app/projects/page.tsx
@@ -71,12 +129,23 @@ export default async function ProjectsPage({
71
129
  }
72
130
  ```
73
131
 
132
+ | Prop | Type | Required | Default | Description |
133
+ |---|---|---|---|---|
134
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
135
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
136
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
137
+ | `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params forwarded to the API — pass Next.js `searchParams` directly |
138
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
139
+
140
+ #### URL-driven filtering
141
+
142
+ When a user clicks a "Browse By" filter link in the menu, they land on the grid with query params in the URL. Pass `searchParams` from the page and `ProjectPortfolio` forwards them to the API automatically.
143
+
74
144
  Filter URLs follow this pattern — the key matches the custom field key in the schema:
75
145
 
76
146
  ```
77
- /projects?type=commercial
78
- /projects?type=educational-facilities
79
- /projects?system=spacematic
147
+ /projects?filter[type]=commercial
148
+ /projects?filter[type]=educational-facilities
80
149
  ```
81
150
 
82
151
  When active filters are applied, a filter banner is shown above the grid with a "Clear filters" link back to `basePath`.
@@ -85,7 +154,7 @@ When active filters are applied, a filter banner is shown above the grid with a
85
154
 
86
155
  ### `ProjectPortfolioClient`
87
156
 
88
- A `"use client"` component that fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. It ships with **no filter UI** build your own dropdowns, buttons, or search inputs and drive the component with the `filters` prop.
157
+ A `"use client"` version of the project grid. Fetches all projects once on mount (module-level cached — no re-fetch on remount) and filters them locally in memory. Use this when you need the grid inside a client component tree, or when you want to build your own custom filter UI.
89
158
 
90
159
  ```tsx
91
160
  // Works in any component — no RSC required
@@ -102,7 +171,9 @@ export default function ProjectsPage() {
102
171
  }
103
172
  ```
104
173
 
105
- Wire up your own filter UI with the `filters` prop:
174
+ #### With a custom filter UI
175
+
176
+ Wire up your own dropdowns, buttons, or search inputs using the `filters` prop. Filter changes are instant — no API call on each change, all filtering happens in memory.
106
177
 
107
178
  ```tsx
108
179
  "use client"
@@ -121,7 +192,6 @@ export default function ProjectsPage() {
121
192
  <option value="educational-facilities">Educational</option>
122
193
  </select>
123
194
 
124
- {/* Component receives filters and applies them in memory */}
125
195
  <ProjectPortfolioClient
126
196
  clientSlug="your-client-slug"
127
197
  apiBase="https://your-api.com"
@@ -142,19 +212,16 @@ export default function ProjectsPage() {
142
212
  | `columns` | `2 \| 3` | No | `3` | Number of columns in the project grid |
143
213
  | `font` | `string` | No | System font stack | Font family string applied to all text |
144
214
 
145
- **How filtering works:** Pass any `Record<string, string>` of field key/value pairs and the component filters the already-fetched project list in memory. All filter logic uses AND matching with case-insensitive comparison. The field keys must match the custom field keys in the API schema.
146
-
147
215
  ---
148
216
 
149
217
  ### `ProjectDetail`
150
218
 
151
- A full project detail page. Fetches a single project by slug and renders its gallery, custom fields, related projects, and a back link.
219
+ A full server-rendered project detail page. Fetches a single project by slug and renders its image gallery, custom fields, and a back link.
152
220
 
153
221
  ```tsx
154
222
  // app/projects/[slug]/page.tsx
155
223
  import { ProjectDetail } from "project-portfolio"
156
224
 
157
- // Must be a Server Component — do NOT add "use client"
158
225
  export default async function ProjectPage({ params }: { params: { slug: string } }) {
159
226
  return (
160
227
  <ProjectDetail
@@ -179,47 +246,48 @@ export default async function ProjectPage({ params }: { params: { slug: string }
179
246
 
180
247
  ---
181
248
 
182
- ### `ProjectMenu`
249
+ ### `GalleryCarousel`
183
250
 
184
- A compact megamenu component. Shows featured projects as list-style cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation megamenu dropdown.
251
+ A `"use client"` image carousel with previous/next arrows, a counter badge, and a scrollable thumbnail strip. Used internally by `ProjectDetail` but can also be used standalone if you fetch your own project data.
185
252
 
186
253
  ```tsx
187
- // components/navigation/MegaMenu.tsx
188
- import { ProjectMenu } from "project-portfolio"
254
+ "use client"
255
+ import { GalleryCarousel } from "project-portfolio"
189
256
 
190
- // Must be a Server Component do NOT add "use client"
191
- export async function ProjectsMegaMenu() {
257
+ // `media` is the array of image objects returned by the projects API
258
+ export function ProjectGallery({ media, title }: { media: Media[]; title: string }) {
192
259
  return (
193
- <ProjectMenu
194
- clientSlug="your-client-slug"
195
- apiBase="https://your-api.com"
196
- basePath="/projects"
197
- subtitle="Our systems are installed in every geographic region of the U.S. and in a variety of applications."
198
- maxProjects={6}
260
+ <GalleryCarousel
261
+ images={media}
262
+ projectTitle={title}
199
263
  />
200
264
  )
201
265
  }
202
266
  ```
203
267
 
268
+ The parent component is responsible for injecting the `.chisel-gallery-main-img` CSS class to control the main image height:
269
+
270
+ ```css
271
+ .chisel-gallery-main-img {
272
+ height: 400px; /* adjust to your layout */
273
+ }
274
+ @media (min-width: 768px) {
275
+ .chisel-gallery-main-img {
276
+ height: 560px;
277
+ }
278
+ }
279
+ ```
280
+
204
281
  | Prop | Type | Required | Default | Description |
205
282
  |---|---|---|---|---|
206
- | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
207
- | `apiBase` | `string` | Yes | — | Base URL of the projects API |
208
- | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
209
- | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
210
- | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
211
- | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
212
- | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
213
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
214
- | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
215
-
216
- The filter options in the right sidebar are driven automatically by the `is_filterable` fields returned from the API schema — no manual configuration needed.
283
+ | `images` | `Media[]` | Yes | — | Array of media objects from the projects API |
284
+ | `projectTitle` | `string` | Yes | — | Used as the alt text fallback for the main image |
217
285
 
218
286
  ---
219
287
 
220
288
  ### `SimilarProjects`
221
289
 
222
- A standalone similar projects section. Fetches all projects for a client, filters to the same type as the current project, and renders up to 3 matching cards. Designed to be placed after `ProjectDetail` on a project detail page.
290
+ A server-rendered similar projects section. Fetches all projects for a client, filters to those matching the provided field values, excludes the current project, and renders up to 3 matching results. Designed to be placed after `ProjectDetail` on a project detail page.
223
291
 
224
292
  ```tsx
225
293
  // app/projects/[slug]/page.tsx
@@ -245,32 +313,176 @@ export default async function ProjectPage({ params }: { params: { slug: string }
245
313
  }
246
314
  ```
247
315
 
248
- `filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
316
+ #### Deriving filters from the current project
317
+
318
+ In most real-world cases you want to match similar projects based on the current project's own field values. Fetch the project first, then pass its field values as filters:
319
+
320
+ ```tsx
321
+ // app/projects/[slug]/page.tsx
322
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
323
+
324
+ async function getProject(slug: string, clientSlug: string, apiBase: string) {
325
+ const res = await fetch(
326
+ `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=YOUR_API_KEY`,
327
+ { next: { revalidate: 86400 } }
328
+ )
329
+ return res.ok ? (await res.json())?.data : null
330
+ }
331
+
332
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
333
+ const project = await getProject(params.slug, "your-client-slug", "https://your-api.com")
334
+ const projectType = project?.custom_field_values?.type ?? null
335
+
336
+ return (
337
+ <>
338
+ <ProjectDetail slug={params.slug} clientSlug="your-client-slug" apiBase="https://your-api.com" />
339
+ {projectType && (
340
+ <SimilarProjects
341
+ filters={{ type: projectType }}
342
+ excludeSlug={params.slug}
343
+ clientSlug="your-client-slug"
344
+ apiBase="https://your-api.com"
345
+ basePath="/projects"
346
+ />
347
+ )}
348
+ </>
349
+ )
350
+ }
351
+ ```
352
+
353
+ #### Manually specifying projects (recommended for quick setup)
354
+
355
+ Pass `projectSlugs` to hand-pick exactly which projects appear, in the order you specify. This overrides `filters` entirely — useful when you want to curate the section rather than rely on field matching, or when you need a reliable result quickly without worrying about field values matching.
356
+
357
+ This is the recommended approach for most client sites. The slugs are hardcoded in the page file. If a client wants different projects shown, update the slugs and redeploy.
358
+
359
+ ```tsx
360
+ // app/projects/[slug]/page.tsx
361
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
362
+
363
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
364
+ return (
365
+ <>
366
+ {/* All your other page content above */}
367
+ <ProjectDetail
368
+ slug={params.slug}
369
+ clientSlug="your-client-slug"
370
+ apiBase="https://your-api.com"
371
+ backPath="/projects"
372
+ />
373
+
374
+ {/* Similar projects hardcoded — update slugs per client request */}
375
+ <SimilarProjects
376
+ projectSlugs={[
377
+ "jacob-javits-convention-center",
378
+ "tillamook-bay-community-college",
379
+ "lcisd-liberty-hill-high-school",
380
+ ]}
381
+ excludeSlug={params.slug}
382
+ clientSlug="your-client-slug"
383
+ apiBase="https://your-api.com"
384
+ basePath="/projects"
385
+ />
386
+ </>
387
+ )
388
+ }
389
+ ```
390
+
391
+ > `excludeSlug` is still respected even in `projectSlugs` mode — if the current page's slug appears in the list it is automatically removed so a project never links to itself.
392
+
393
+ To update which projects appear, find the `projectSlugs` array in the page file and swap in the new slugs. Project slugs are visible in the URL when browsing the portfolio: `/projects/jacob-javits-convention-center` → slug is `jacob-javits-convention-center`.
394
+
395
+ #### Card variant
396
+
397
+ Use `variant="card"` to render the same baseball-card style used in `ProjectPortfolio` instead of the default list style:
398
+
399
+ ```tsx
400
+ <SimilarProjects
401
+ filters={{ type: "commercial" }}
402
+ excludeSlug={params.slug}
403
+ clientSlug="your-client-slug"
404
+ apiBase="https://your-api.com"
405
+ basePath="/projects"
406
+ variant="card"
407
+ />
408
+ ```
249
409
 
250
410
  | Prop | Type | Required | Default | Description |
251
411
  |---|---|---|---|---|
412
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
413
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
252
414
  | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
253
415
  | `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
416
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
417
+ | `projectSlugs` | `string[]` | No | — | Explicit list of project slugs to show, in order. When provided, overrides `filters` entirely. e.g. `["jacob-javits", "tillamook-bay"]` |
418
+ | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
419
+ | `variant` | `"list" \| "card"` | No | `"list"` | `"list"` uses the border-bottom separator style; `"card"` renders full baseball-card style matching `ProjectPortfolio` |
420
+ | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
421
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
422
+
423
+ ---
424
+
425
+ ### `ProjectMenu`
426
+
427
+ A server-rendered megamenu component. Shows featured projects as compact cards on the left and "Browse By" filter links on the right. Designed to be dropped directly into a navigation dropdown.
428
+
429
+ ```tsx
430
+ // components/MegaMenu.tsx
431
+ import { ProjectMenu } from "project-portfolio"
432
+
433
+ // Must be a Server Component — do NOT add "use client"
434
+ export async function ProjectsMegaMenu() {
435
+ return (
436
+ <ProjectMenu
437
+ clientSlug="your-client-slug"
438
+ apiBase="https://your-api.com"
439
+ basePath="/projects"
440
+ subtitle="Our systems are installed in every geographic region of the U.S."
441
+ maxProjects={6}
442
+ />
443
+ )
444
+ }
445
+ ```
446
+
447
+ #### With a curated menu
448
+
449
+ Pass `menuId` to show a specific curated set of projects instead of all projects. The Browse By filters on the right always reflect the full schema regardless of which menu is active. Available menu IDs can be retrieved from `GET /api/v1/clients/{clientSlug}/menus`.
450
+
451
+ ```tsx
452
+ <ProjectMenu
453
+ clientSlug="your-client-slug"
454
+ apiBase="https://your-api.com"
455
+ menuId="main-nav"
456
+ basePath="/projects"
457
+ />
458
+ ```
459
+
460
+ | Prop | Type | Required | Default | Description |
461
+ |---|---|---|---|---|
254
462
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
255
463
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
464
+ | `menuId` | `string` | No | — | ID of a curated menu. When provided, fetches from `/menus/{menuId}` instead of all projects. Filters always shown regardless. |
256
465
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
257
- | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
466
+ | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
467
+ | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
258
468
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
259
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
469
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
470
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
471
+ | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
260
472
 
261
473
  ---
262
474
 
263
475
  ### `ProjectMenuClient` + `createMenuHandler`
264
476
 
265
- `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.
477
+ `ProjectMenuClient` is a `"use client"` megamenu component. Use it when your nav or header is a client component. It fetches and caches data on first mount so the API is never called twice on re-hover or remount.
266
478
 
267
- There are two ways to use it:
479
+ There are two ways to set it up:
268
480
 
269
481
  ---
270
482
 
271
- **Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
483
+ #### Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)
272
484
 
273
- 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.
485
+ Create one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
274
486
 
275
487
  ```ts
276
488
  // app/api/chisel-menu/route.ts
@@ -282,29 +494,66 @@ export const GET = createMenuHandler({
282
494
  })
283
495
  ```
284
496
 
497
+ For a curated menu, pass `menuId` to the handler:
498
+
499
+ ```ts
500
+ // app/api/chisel-menu/route.ts
501
+ export const GET = createMenuHandler({
502
+ clientSlug: "your-client-slug",
503
+ apiBase: "https://your-api.com",
504
+ menuId: "main-nav",
505
+ })
506
+ ```
507
+
508
+ Then pass `dataUrl` to the component:
509
+
285
510
  ```tsx
286
- // In your nav/header — works in any "use client" component
511
+ // components/Nav.tsx
512
+ "use client"
287
513
  import { ProjectMenuClient } from "project-portfolio"
288
514
 
289
- <ProjectMenuClient
290
- dataUrl="/api/chisel-menu"
291
- basePath="/projects"
292
- viewAllPath="/projects"
293
- />
515
+ export function Nav() {
516
+ return (
517
+ <ProjectMenuClient
518
+ dataUrl="/api/chisel-menu"
519
+ basePath="/projects"
520
+ viewAllPath="/projects"
521
+ subtitle="Explore our portfolio of projects."
522
+ maxProjects={6}
523
+ />
524
+ )
525
+ }
294
526
  ```
295
527
 
296
528
  ---
297
529
 
298
- **Option 2 — Direct fetch (quick setup / non-Next.js environments)**
530
+ #### Option 2 — Direct fetch (quick setup / non-Next.js environments)
299
531
 
300
- No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session.
532
+ No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session. Note: this exposes the API call to the client browser.
301
533
 
302
534
  ```tsx
535
+ "use client"
303
536
  import { ProjectMenuClient } from "project-portfolio"
304
537
 
538
+ export function Nav() {
539
+ return (
540
+ <ProjectMenuClient
541
+ clientSlug="your-client-slug"
542
+ apiBase="https://your-api.com"
543
+ basePath="/projects"
544
+ viewAllPath="/projects"
545
+ />
546
+ )
547
+ }
548
+ ```
549
+
550
+ With a curated menu:
551
+
552
+ ```tsx
305
553
  <ProjectMenuClient
306
554
  clientSlug="your-client-slug"
307
555
  apiBase="https://your-api.com"
556
+ menuId="main-nav"
308
557
  basePath="/projects"
309
558
  viewAllPath="/projects"
310
559
  />
@@ -314,22 +563,23 @@ import { ProjectMenuClient } from "project-portfolio"
314
563
 
315
564
  | Prop | Type | Required | Default | Description |
316
565
  |---|---|---|---|---|
317
- | `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
566
+ | `dataUrl` | `string` | No* | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
318
567
  | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
319
568
  | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
569
+ | `menuId` | `string` | No | — | ID of a curated menu. Fetches from `/menus/{menuId}` for projects. Filters are always shown regardless. |
320
570
  | `basePath` | `string` | Yes | — | Base path for project detail links |
321
571
  | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
322
572
  | `subtitle` | `string` | No | — | Description shown above the project cards (hidden on mobile) |
323
573
  | `font` | `string` | No | System font stack | Font family string |
324
574
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display (capped at 3 on mobile) |
325
575
 
326
- *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.
576
+ *One of `dataUrl` or `clientSlug + apiBase` must be provided.
327
577
 
328
578
  ---
329
579
 
330
- ## Important: Server Component Usage
580
+ ## Server vs Client Components
331
581
 
332
- All top-level components (`ProjectPortfolio`, `ProjectDetail`, `ProjectMenu`, `SimilarProjects`) are **async Server Components**. They must be rendered in a server context:
582
+ All top-level components except `ProjectMenuClient`, `ProjectPortfolioClient`, and `GalleryCarousel` are **async Server Components**. They must be rendered in a server context:
333
583
 
334
584
  ```tsx
335
585
  // CORRECT
@@ -337,58 +587,63 @@ export default async function Page() {
337
587
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
338
588
  }
339
589
 
340
- // WRONG — causes infinite client-side fetch loop
590
+ // WRONG — causes a runtime error
341
591
  "use client"
342
592
  export default function Page() {
343
593
  return <ProjectPortfolio clientSlug="..." apiBase="..." />
344
594
  }
345
595
  ```
346
596
 
347
- If a parent component uses `"use client"`, these components cannot be rendered inside it directly. Pass them as children from a server component instead.
597
+ If your parent component uses `"use client"`, use the client variants instead (`ProjectMenuClient`, `ProjectPortfolioClient`) or pass the server components as `children` from a server parent.
598
+
599
+ | Component | Type | Use when |
600
+ |---|---|---|
601
+ | `ProjectPortfolio` | Server | Page-level grid, URL-driven filters |
602
+ | `ProjectPortfolioClient` | Client | Inside a client tree, custom filter UI |
603
+ | `ProjectDetail` | Server | Project detail page |
604
+ | `GalleryCarousel` | Client | Standalone image gallery |
605
+ | `ProjectMenu` | Server | Server-rendered nav dropdown |
606
+ | `ProjectMenuClient` | Client | Client-rendered nav/header |
607
+ | `SimilarProjects` | Server | After `ProjectDetail` on detail pages |
348
608
 
349
609
  ---
350
610
 
351
611
  ## Caching
352
612
 
353
- Data is cached at multiple levels depending on which component is used.
354
-
355
- **Server Components (`ProjectMenu`, `ProjectPortfolio`, `ProjectDetail`, `SimilarProjects`)**
356
- 1. **Per-render** `React.cache()` deduplicates duplicate calls within the same render pass
357
- 2. **Cross-request** — `next: { revalidate: 86400 }` (24 hours) caches responses in Next.js's Data Cache across all users and sessions
358
-
359
- **`ProjectMenuClient` with `createMenuHandler` (recommended)**
360
- 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
361
- 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
362
-
363
- **`ProjectMenuClient` with direct fetch / `ProjectPortfolioClient`**
364
- 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. Filter changes on `ProjectPortfolioClient` never trigger a fetch — filtering is done entirely in memory
613
+ | Component | Server Cache | Client Cache |
614
+ |---|---|---|
615
+ | `ProjectMenu` (RSC) | 24h via `next.revalidate` | — |
616
+ | `ProjectMenuClient` + `createMenuHandler` | 24h (route handler) | Per-session module cache |
617
+ | `ProjectMenuClient` (direct fetch) | None | Per-session module cache |
618
+ | `ProjectPortfolio` (RSC) | 24h via `next.revalidate` | — |
619
+ | `ProjectPortfolioClient` | None | Per-session module cache |
620
+ | `ProjectDetail` (RSC) | 24h via `next.revalidate` | |
621
+ | `SimilarProjects` (RSC) | 24h via `next.revalidate` | |
365
622
 
366
- **Bypassing the cache**
623
+ #### Bypassing the cache
367
624
 
368
625
  | Method | Scope | Use case |
369
626
  |---|---|---|
370
627
  | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
371
628
  | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
372
- | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
629
+ | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache | CMS webhook on content publish |
373
630
  | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
374
631
 
375
632
  ```ts
376
- // CMS webhook example — invalidate server cache immediately on publish
633
+ // CMS webhook — invalidate server cache on publish
377
634
  import { revalidateTag } from "next/cache"
378
635
  revalidateTag("chisel-menu-your-client-slug")
379
- ```
380
636
 
381
- No third-party caching libraries are required.
637
+ // For a curated menu, the tag includes the menuId
638
+ revalidateTag("chisel-menu-your-client-slug-main-nav")
639
+ ```
382
640
 
383
641
  ---
384
642
 
385
643
  ## Publishing to npm
386
644
 
387
645
  ```bash
388
- # Log in to npm
389
646
  npm login
390
-
391
- # Build and publish
392
647
  cd package
393
648
  npm run build
394
649
  npm publish --access public
@@ -4,6 +4,11 @@ export interface ProjectMenuProps {
4
4
  clientSlug: string;
5
5
  /** API base URL e.g. "https://nexus.chiselandco.com" */
6
6
  apiBase: string;
7
+ /**
8
+ * Optional menu ID to fetch a specific curated menu instead of all projects.
9
+ * When provided, fetches from /api/v1/clients/{clientSlug}/menus/{menuId}
10
+ */
11
+ menuId?: string;
7
12
  /** Base path for project detail links. Defaults to "/projects" */
8
13
  basePath?: string;
9
14
  /** Path for the "View All" link. Defaults to basePath */
@@ -46,14 +51,17 @@ export interface ProjectMenuProps {
46
51
  * import { revalidateTag } from "next/cache"
47
52
  * revalidateTag("chisel-menu-my-client")
48
53
  */
49
- export declare function createMenuHandler({ clientSlug, apiBase, revalidate, }: {
54
+ export declare function createMenuHandler({ clientSlug, apiBase, menuId, revalidate, }: {
50
55
  clientSlug: string;
51
56
  apiBase: string;
57
+ /** Optional menu ID to fetch a specific curated menu instead of all projects */
58
+ menuId?: string;
52
59
  revalidate?: number;
53
60
  }): (request: Request) => Promise<Response>;
54
- export declare function fetchProjectMenuData({ apiBase, clientSlug, revalidate, noCache, }: {
61
+ export declare function fetchProjectMenuData({ apiBase, clientSlug, menuId, revalidate, noCache, }: {
55
62
  apiBase: string;
56
63
  clientSlug: string;
64
+ menuId?: string;
57
65
  revalidate?: number;
58
66
  noCache?: boolean;
59
67
  }): Promise<{
@@ -67,5 +75,5 @@ export declare function fetchProjectMenuData({ apiBase, clientSlug, revalidate,
67
75
  filterFieldName: string;
68
76
  fieldOptionsMap: Record<string, Record<string, string>>;
69
77
  }>;
70
- export declare function ProjectMenu({ clientSlug, apiBase, basePath, viewAllPath, subtitle, font, maxProjects, revalidate, noCache, }: ProjectMenuProps): Promise<import("react/jsx-runtime").JSX.Element>;
78
+ export declare function ProjectMenu({ clientSlug, apiBase, menuId, basePath, viewAllPath, subtitle, font, maxProjects, revalidate, noCache, }: ProjectMenuProps): Promise<import("react/jsx-runtime").JSX.Element>;
71
79
  //# sourceMappingURL=ProjectMenu.d.ts.map
@@ -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,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"}
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;;;OAGG;IACH,MAAM,CAAC,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,MAAM,EACN,UAAkB,GACnB,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,aACoC,OAAO,uBAsE3C;AAED,wBAAsB,oBAAoB,CAAC,EACzC,OAAO,EACP,UAAU,EACV,MAAM,EACN,UAAkB,EAClB,OAAe,GAChB,EAAE;IACD,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,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;AA8DD,wBAAsB,WAAW,CAAC,EAChC,UAAU,EACV,OAAO,EACP,MAAM,EACN,QAAsB,EACtB,WAAW,EACX,QAAQ,EACR,IAA0E,EAC1E,WAAe,EACf,UAAkB,EAClB,OAAe,GAChB,EAAE,gBAAgB,oDAiClB"}
@@ -26,27 +26,41 @@ const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
26
26
  * import { revalidateTag } from "next/cache"
27
27
  * revalidateTag("chisel-menu-my-client")
28
28
  */
29
- export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, }) {
29
+ export function createMenuHandler({ clientSlug, apiBase, menuId, revalidate = 86400, }) {
30
30
  return async function GET(request) {
31
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
31
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
32
32
  const bypass = process.env.CHISEL_CACHE_BYPASS === "true" ||
33
33
  new URL(request.url).searchParams.has("bust");
34
+ const cacheTag = menuId ? `chisel-menu-${clientSlug}-${menuId}` : `chisel-menu-${clientSlug}`;
34
35
  const fetchOpts = bypass
35
36
  ? { cache: "no-store" }
36
- : { next: { revalidate, tags: [`chisel-menu-${clientSlug}`] } };
37
+ : { next: { revalidate, tags: [cacheTag] } };
37
38
  try {
38
- const [projectsRes, fieldsRes] = await Promise.all([
39
+ // Always fetch /projects for schema, and /fields for options
40
+ // If menuId is provided, also fetch the menu endpoint for curated projects
41
+ const fetches = [
39
42
  fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
40
43
  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 : [];
44
+ ];
45
+ if (menuId) {
46
+ fetches.push(fetch(`${apiBase}/api/v1/clients/${clientSlug}/menus/${menuId}?api_key=${API_KEY}`, fetchOpts));
47
+ }
48
+ const responses = await Promise.all(fetches);
49
+ const [projectsRes, fieldsRes] = responses;
50
+ const menuRes = menuId ? responses[2] : null;
51
+ const projectsJson = projectsRes.ok ? await projectsRes.json() : {};
52
+ const menuJson = menuRes && menuRes.ok ? await menuRes.json() : null;
53
+ // If menuId provided, use menu projects; otherwise use all projects
54
+ const projects = menuJson
55
+ ? ((_b = (_a = menuJson.projects) !== null && _a !== void 0 ? _a : menuJson.data) !== null && _b !== void 0 ? _b : [])
56
+ : ((_c = projectsJson === null || projectsJson === void 0 ? void 0 : projectsJson.data) !== null && _c !== void 0 ? _c : []);
57
+ // Always get schema from /projects response
58
+ const schema = (_e = (_d = projectsJson === null || projectsJson === void 0 ? void 0 : projectsJson.client) === null || _d === void 0 ? void 0 : _d.custom_fields_schema) !== null && _e !== void 0 ? _e : [];
45
59
  const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
46
60
  const fieldOptionsMap = {};
47
- for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
61
+ for (const field of ((_f = fieldsJson.fields) !== null && _f !== void 0 ? _f : [])) {
48
62
  const map = {};
49
- for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
63
+ for (const opt of ((_g = field.options) !== null && _g !== void 0 ? _g : [])) {
50
64
  if (typeof opt === "object" && opt.id && opt.label) {
51
65
  map[opt.id] = opt.label;
52
66
  map[opt.label] = opt.label;
@@ -54,9 +68,9 @@ export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, })
54
68
  }
55
69
  fieldOptionsMap[field.key] = map;
56
70
  }
57
- const filterField = (_f = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _f !== void 0 ? _f : null;
71
+ const filterField = (_h = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _h !== void 0 ? _h : null;
58
72
  const filterOptions = filterField
59
- ? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
73
+ ? ((_j = filterField.options) !== null && _j !== void 0 ? _j : []).map((opt) => {
60
74
  var _a, _b;
61
75
  if (typeof opt === "string")
62
76
  return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
@@ -67,19 +81,19 @@ export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, })
67
81
  projects,
68
82
  schema,
69
83
  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",
84
+ filterFieldKey: (_k = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _k !== void 0 ? _k : null,
85
+ filterFieldName: (_l = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _l !== void 0 ? _l : "Project Type",
72
86
  fieldOptionsMap,
73
87
  });
74
88
  }
75
- catch (_k) {
89
+ catch (_m) {
76
90
  return Response.json({ projects: [], schema: [], filterOptions: [], filterFieldKey: null, filterFieldName: "Project Type", fieldOptionsMap: {} }, { status: 500 });
77
91
  }
78
92
  };
79
93
  }
80
- export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 86400, noCache = false, }) {
94
+ export async function fetchProjectMenuData({ apiBase, clientSlug, menuId, revalidate = 86400, noCache = false, }) {
81
95
  var _a, _b, _c, _d;
82
- const raw = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
96
+ const raw = await _fetchMenuData(apiBase, clientSlug, menuId, revalidate, noCache);
83
97
  const filterField = (_a = raw.schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
84
98
  const filterOptions = filterField
85
99
  ? ((_b = filterField.options) !== null && _b !== void 0 ? _b : []).map((opt) => {
@@ -99,27 +113,40 @@ export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 8
99
113
  fieldOptionsMap: raw.fieldOptionsMap,
100
114
  };
101
115
  }
102
- const _fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = false) => {
103
- var _a, _b, _c, _d, _e;
116
+ const _fetchMenuData = cache(async (apiBase, clientSlug, menuId, revalidate, noCache = false) => {
117
+ var _a, _b, _c, _d, _e, _f, _g;
104
118
  const fetchOpts = noCache
105
119
  ? { cache: "no-store" }
106
120
  : { next: { revalidate } };
107
121
  try {
108
- const [res, fieldsRes] = await Promise.all([
122
+ // Always fetch /projects for schema, and /fields for options
123
+ // If menuId is provided, also fetch the menu endpoint for curated projects
124
+ const fetches = [
109
125
  fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
110
126
  fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`, fetchOpts),
111
- ]);
112
- if (!res.ok)
127
+ ];
128
+ if (menuId) {
129
+ fetches.push(fetch(`${apiBase}/api/v1/clients/${clientSlug}/menus/${menuId}?api_key=${API_KEY}`, fetchOpts));
130
+ }
131
+ const responses = await Promise.all(fetches);
132
+ const [projectsRes, fieldsRes] = responses;
133
+ const menuRes = menuId ? responses[2] : null;
134
+ if (!projectsRes.ok)
113
135
  return { projects: [], schema: [], fieldOptionsMap: {} };
114
- const json = await res.json();
115
- const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
116
- 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 : [];
136
+ const projectsJson = await projectsRes.json();
137
+ const menuJson = menuRes && menuRes.ok ? await menuRes.json() : null;
138
+ // If menuId provided, use menu projects; otherwise use all projects
139
+ const projects = menuJson
140
+ ? ((_b = (_a = menuJson.projects) !== null && _a !== void 0 ? _a : menuJson.data) !== null && _b !== void 0 ? _b : [])
141
+ : ((_c = projectsJson === null || projectsJson === void 0 ? void 0 : projectsJson.data) !== null && _c !== void 0 ? _c : []);
142
+ // Always get schema from /projects response
143
+ const schema = (_e = (_d = projectsJson === null || projectsJson === void 0 ? void 0 : projectsJson.client) === null || _d === void 0 ? void 0 : _d.custom_fields_schema) !== null && _e !== void 0 ? _e : [];
117
144
  // Build fieldOptionsMap: { fieldKey: { id: label, label: label } }
118
145
  const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
119
146
  const fieldOptionsMap = {};
120
- for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
147
+ for (const field of ((_f = fieldsJson.fields) !== null && _f !== void 0 ? _f : [])) {
121
148
  const map = {};
122
- for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
149
+ for (const opt of ((_g = field.options) !== null && _g !== void 0 ? _g : [])) {
123
150
  if (typeof opt === "object" && opt.id && opt.label) {
124
151
  map[opt.id] = opt.label;
125
152
  map[opt.label] = opt.label;
@@ -129,13 +156,13 @@ const _fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = f
129
156
  }
130
157
  return { projects, schema, fieldOptionsMap };
131
158
  }
132
- catch (_f) {
159
+ catch (_h) {
133
160
  return { projects: [], schema: [], fieldOptionsMap: {} };
134
161
  }
135
162
  });
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, }) {
163
+ export async function ProjectMenu({ clientSlug, apiBase, menuId, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 86400, noCache = false, }) {
137
164
  var _a, _b, _c, _d;
138
- const { projects, schema, fieldOptionsMap } = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
165
+ const { projects, schema, fieldOptionsMap } = await _fetchMenuData(apiBase, clientSlug, menuId, revalidate, noCache);
139
166
  // Find the filterable select field (badge_overlay is our category field)
140
167
  const filterField = (_a = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
141
168
  // Build filter option list from schema
@@ -14,6 +14,11 @@ export interface ProjectMenuClientProps {
14
14
  */
15
15
  clientSlug?: string;
16
16
  apiBase?: string;
17
+ /**
18
+ * Optional menu ID to fetch a specific curated menu instead of all projects.
19
+ * When provided, fetches from /api/v1/clients/{clientSlug}/menus/{menuId}
20
+ */
21
+ menuId?: string;
17
22
  projects?: Project[];
18
23
  schema?: CustomFieldSchema[];
19
24
  filterOptions?: {
@@ -29,5 +34,5 @@ export interface ProjectMenuClientProps {
29
34
  font?: string;
30
35
  maxProjects?: number;
31
36
  }
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;
37
+ export declare function ProjectMenuClient({ dataUrl, clientSlug, apiBase, menuId, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp, fieldOptionsMap: fieldOptionsMapProp, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
33
38
  //# 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;;;;;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,2CAqcxB"}
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;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IAEf,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,MAAM,EACN,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,2CAwfxB"}
@@ -17,7 +17,7 @@ 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
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
+ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, menuId, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp = "Project Type", fieldOptionsMap: fieldOptionsMapProp = {}, subtitle, basePath, viewAllPath, font = DEFAULT_FONT, maxProjects = 6, }) {
21
21
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
22
22
  const [filtersOpen, setFiltersOpen] = useState(false);
23
23
  const [hoveredCard, setHoveredCard] = useState(null);
@@ -30,9 +30,9 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
30
30
  if (!hasDataUrl && !hasDirectFetch)
31
31
  return;
32
32
  let cancelled = false;
33
- const cacheKey = dataUrl !== null && dataUrl !== void 0 ? dataUrl : `${clientSlug}:${apiBase}`;
33
+ const cacheKey = dataUrl !== null && dataUrl !== void 0 ? dataUrl : `${clientSlug}:${apiBase}:${menuId !== null && menuId !== void 0 ? menuId : "all"}`;
34
34
  async function fetchAndCache() {
35
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
35
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3;
36
36
  // dataUrl mode: fetch from local API route (server-cached, no API key exposed)
37
37
  if (dataUrl) {
38
38
  const res = await fetch(dataUrl);
@@ -46,19 +46,62 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
46
46
  fieldOptionsMap: (_f = json.fieldOptionsMap) !== null && _f !== void 0 ? _f : {},
47
47
  };
48
48
  }
49
- // Direct fetch mode: fetch from the upstream API directly
49
+ // Menu endpoint mode: fetch from /menus/{menuId} when menuId is provided
50
+ // Also fetch /projects to get schema for filters (menu endpoint may not return it)
51
+ if (menuId) {
52
+ const [menuRes, projectsRes, fieldsRes] = await Promise.all([
53
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/menus/${menuId}?api_key=${API_KEY}`),
54
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
55
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
56
+ ]);
57
+ const menuJson = menuRes.ok ? await menuRes.json() : {};
58
+ const projectsJson = projectsRes.ok ? await projectsRes.json() : {};
59
+ const projects = (_h = (_g = menuJson === null || menuJson === void 0 ? void 0 : menuJson.projects) !== null && _g !== void 0 ? _g : menuJson === null || menuJson === void 0 ? void 0 : menuJson.data) !== null && _h !== void 0 ? _h : [];
60
+ // Get schema from menu response first, fallback to projects response
61
+ const schema = (_o = (_l = (_k = (_j = menuJson === null || menuJson === void 0 ? void 0 : menuJson.client) === null || _j === void 0 ? void 0 : _j.custom_fields_schema) !== null && _k !== void 0 ? _k : menuJson === null || menuJson === void 0 ? void 0 : menuJson.schema) !== null && _l !== void 0 ? _l : (_m = projectsJson === null || projectsJson === void 0 ? void 0 : projectsJson.client) === null || _m === void 0 ? void 0 : _m.custom_fields_schema) !== null && _o !== void 0 ? _o : [];
62
+ const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
63
+ const fieldOptionsMap = {};
64
+ for (const field of ((_p = fieldsJson.fields) !== null && _p !== void 0 ? _p : [])) {
65
+ const map = {};
66
+ for (const opt of ((_q = field.options) !== null && _q !== void 0 ? _q : [])) {
67
+ if (typeof opt === "object" && opt.id && opt.label) {
68
+ map[opt.id] = opt.label;
69
+ map[opt.label] = opt.label;
70
+ }
71
+ }
72
+ fieldOptionsMap[field.key] = map;
73
+ }
74
+ const filterField = (_r = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _r !== void 0 ? _r : null;
75
+ const filterOptions = filterField
76
+ ? ((_s = filterField.options) !== null && _s !== void 0 ? _s : []).map((opt) => {
77
+ var _a, _b;
78
+ if (typeof opt === "string")
79
+ return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
80
+ return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
81
+ })
82
+ : [];
83
+ return {
84
+ projects,
85
+ schema,
86
+ filterOptions,
87
+ filterFieldKey: (_t = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _t !== void 0 ? _t : null,
88
+ filterFieldName: (_u = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _u !== void 0 ? _u : "Project Type",
89
+ fieldOptionsMap,
90
+ };
91
+ }
92
+ // Direct fetch mode: fetch from the upstream API directly (all projects)
50
93
  const [projectsRes, fieldsRes] = await Promise.all([
51
94
  fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
52
95
  fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
53
96
  ]);
54
97
  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 : [];
98
+ const projects = (_v = json === null || json === void 0 ? void 0 : json.data) !== null && _v !== void 0 ? _v : [];
99
+ const schema = (_x = (_w = json === null || json === void 0 ? void 0 : json.client) === null || _w === void 0 ? void 0 : _w.custom_fields_schema) !== null && _x !== void 0 ? _x : [];
57
100
  const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
58
101
  const fieldOptionsMap = {};
59
- for (const field of ((_k = fieldsJson.fields) !== null && _k !== void 0 ? _k : [])) {
102
+ for (const field of ((_y = fieldsJson.fields) !== null && _y !== void 0 ? _y : [])) {
60
103
  const map = {};
61
- for (const opt of ((_l = field.options) !== null && _l !== void 0 ? _l : [])) {
104
+ for (const opt of ((_z = field.options) !== null && _z !== void 0 ? _z : [])) {
62
105
  if (typeof opt === "object" && opt.id && opt.label) {
63
106
  map[opt.id] = opt.label;
64
107
  map[opt.label] = opt.label;
@@ -66,9 +109,9 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
66
109
  }
67
110
  fieldOptionsMap[field.key] = map;
68
111
  }
69
- const filterField = (_m = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _m !== void 0 ? _m : null;
112
+ const filterField = (_0 = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _0 !== void 0 ? _0 : null;
70
113
  const filterOptions = filterField
71
- ? ((_o = filterField.options) !== null && _o !== void 0 ? _o : []).map((opt) => {
114
+ ? ((_1 = filterField.options) !== null && _1 !== void 0 ? _1 : []).map((opt) => {
72
115
  var _a, _b;
73
116
  if (typeof opt === "string")
74
117
  return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
@@ -79,8 +122,8 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
79
122
  projects,
80
123
  schema,
81
124
  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",
125
+ filterFieldKey: (_2 = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _2 !== void 0 ? _2 : null,
126
+ filterFieldName: (_3 = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _3 !== void 0 ? _3 : "Project Type",
84
127
  fieldOptionsMap,
85
128
  };
86
129
  }
@@ -95,7 +138,7 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
95
138
  // silently fail — render nothing
96
139
  });
97
140
  return () => { cancelled = true; };
98
- }, [dataUrl, clientSlug, apiBase]);
141
+ }, [dataUrl, clientSlug, apiBase, menuId]);
99
142
  // Resolve data: prefer self-fetched, fall back to props
100
143
  const projects = (_b = (_a = fetched === null || fetched === void 0 ? void 0 : fetched.projects) !== null && _a !== void 0 ? _a : projectsProp) !== null && _b !== void 0 ? _b : [];
101
144
  const schema = (_d = (_c = fetched === null || fetched === void 0 ? void 0 : fetched.schema) !== null && _c !== void 0 ? _c : schemaProp) !== null && _d !== void 0 ? _d : [];
@@ -254,19 +297,13 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
254
297
  .chisel-menu-filter-link {
255
298
  all: unset;
256
299
  cursor: pointer;
257
- display: flex;
258
- align-items: center;
259
- justify-content: space-between;
300
+ display: block;
260
301
  font-size: 14px;
261
302
  color: #52525b;
262
303
  font-weight: 400;
263
304
  padding: 7px 0;
264
305
  text-decoration: none;
265
306
  transition: color 0.15s;
266
- border-bottom: 1px solid #f4f4f5;
267
- }
268
- .chisel-menu-filter-link:last-child {
269
- border-bottom: none;
270
307
  }
271
308
  .chisel-menu-filter-link:hover {
272
309
  color: #18181b;
@@ -314,18 +351,24 @@ export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: proj
314
351
  const tagLabels = tags.map((t) => { var _a; return (_a = optMap[t]) !== null && _a !== void 0 ? _a : t; }).slice(0, 2);
315
352
  return (_jsx("p", { style: { fontSize: "11px", color: ACCENT, margin: 0, fontFamily: font, lineHeight: 1.4 }, children: tagLabels.join(" · ") }));
316
353
  })()] })] }, project.id));
317
- }) }))] }), _jsx("div", { className: "chisel-menu-divider-v" }), _jsxs("div", { className: "chisel-menu-right", children: [filterOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("p", { style: {
354
+ }) }))] }), _jsx("div", { className: "chisel-menu-divider-v" }), _jsxs("div", { className: "chisel-menu-right", children: [filterOptions.length > 0 && (_jsxs(_Fragment, { children: [_jsx("p", { style: {
318
355
  fontSize: "11px",
319
356
  fontWeight: 700,
320
357
  textTransform: "uppercase",
321
358
  letterSpacing: "0.12em",
322
359
  color: "#71717a",
360
+ margin: "0 0 10px 0",
361
+ }, children: "Browse By" }), _jsx("p", { style: {
362
+ fontSize: "15px",
363
+ fontWeight: 700,
364
+ color: "#18181b",
323
365
  margin: "0 0 4px 0",
324
- }, children: ["Browse By ", filterFieldName] }), _jsx("div", { style: { display: "flex", flexDirection: "column", marginBottom: "20px" }, children: filterOptions.map((opt) => {
366
+ fontFamily: font,
367
+ }, children: filterFieldName }), _jsx("div", { style: { display: "flex", flexDirection: "column", marginBottom: "20px" }, children: filterOptions.map((opt) => {
325
368
  const href = filterFieldKey
326
369
  ? `${basePath}?filter[${filterFieldKey}]=${encodeURIComponent(opt.id)}`
327
370
  : basePath;
328
- return (_jsxs("a", { href: href, className: "chisel-menu-filter-link", style: { fontFamily: font }, children: [opt.label, _jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0, opacity: 0.4 }, children: _jsx("path", { d: "M9 18l6-6-6-6" }) })] }, opt.id));
371
+ return (_jsx("a", { href: href, className: "chisel-menu-filter-link", style: { fontFamily: font }, children: opt.label }, opt.id));
329
372
  }) }), _jsx("div", { style: { height: "1px", backgroundColor: "#e4e4e7", marginBottom: "20px" } })] })), _jsxs("a", { href: viewAllPath, style: {
330
373
  all: "unset",
331
374
  cursor: "pointer",
@@ -20,6 +20,18 @@ export interface SimilarProjectsProps {
20
20
  maxItems?: number;
21
21
  /** Seconds to cache. Defaults to 60 */
22
22
  revalidate?: number;
23
+ /**
24
+ * Visual layout for the project cards.
25
+ * - "list" (default) — image + text with border-bottom separator
26
+ * - "card" — full baseball-card style matching the ProjectPortfolio grid
27
+ */
28
+ variant?: "list" | "card";
29
+ /**
30
+ * Explicit list of project slugs to display, in order.
31
+ * When provided, overrides `filters` entirely — only these projects are shown.
32
+ * e.g. projectSlugs={["jacob-javits", "tillamook-bay-community-college"]}
33
+ */
34
+ projectSlugs?: string[];
23
35
  }
24
- export declare function SimilarProjects({ filters, excludeSlug, clientSlug, apiBase, basePath, maxItems, revalidate, }: SimilarProjectsProps): Promise<import("react/jsx-runtime").JSX.Element | null>;
36
+ export declare function SimilarProjects({ filters, excludeSlug, clientSlug, apiBase, basePath, maxItems, revalidate, variant, projectSlugs, }: SimilarProjectsProps): Promise<import("react/jsx-runtime").JSX.Element | null>;
25
37
  //# sourceMappingURL=SimilarProjects.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SimilarProjects.d.ts","sourceRoot":"","sources":["../src/SimilarProjects.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA8CD,wBAAsB,eAAe,CAAC,EACpC,OAAY,EACZ,WAAW,EACX,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,QAAY,EACZ,UAAe,GAChB,EAAE,oBAAoB,2DAgHtB"}
1
+ {"version":3,"file":"SimilarProjects.d.ts","sourceRoot":"","sources":["../src/SimilarProjects.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;CACxB;AA6DD,wBAAsB,eAAe,CAAC,EACpC,OAAY,EACZ,WAAW,EACX,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,QAAY,EACZ,UAAe,EACf,OAAgB,EAChB,YAAY,GACb,EAAE,oBAAoB,2DA8JtB"}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cache } from "react";
3
+ import { ProjectCard } from "./ProjectCard";
3
4
  // ─── Helpers ─────────────────────────────────────────────────────────────────
4
5
  function parseMultiValue(raw) {
5
6
  if (Array.isArray(raw))
@@ -19,40 +20,82 @@ function dedupeByKey(arr) {
19
20
  }
20
21
  // ─── Data fetching ────────────────────────────────────────────────────────────
21
22
  const fetchSimilarData = cache(async (apiBase, clientSlug, revalidate) => {
22
- var _a, _b, _c;
23
+ var _a, _b, _c, _d, _e;
23
24
  const fetchOpts = revalidate > 0 ? { next: { revalidate } } : {};
25
+ const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
24
26
  try {
25
- const res = await fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR`, fetchOpts);
27
+ const [res, fieldsRes] = await Promise.all([
28
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
29
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`, fetchOpts),
30
+ ]);
26
31
  const json = res.ok ? await res.json() : null;
27
32
  const allProjects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
28
33
  const schema = dedupeByKey((_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 : []);
29
- return { allProjects, schema };
34
+ const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
35
+ const fieldOptionsMap = {};
36
+ for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
37
+ const map = {};
38
+ for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
39
+ if (typeof opt === "object" && opt.id && opt.label) {
40
+ map[opt.id] = opt.label;
41
+ map[opt.label] = opt.label;
42
+ }
43
+ }
44
+ fieldOptionsMap[field.key] = map;
45
+ }
46
+ return { allProjects, schema, fieldOptionsMap };
30
47
  }
31
- catch (_d) {
32
- return { allProjects: [], schema: [] };
48
+ catch (_f) {
49
+ return { allProjects: [], schema: [], fieldOptionsMap: {} };
33
50
  }
34
51
  });
35
52
  // ─── Component ────────────────────────────────────────────────────────────────
36
- export async function SimilarProjects({ filters = {}, excludeSlug, clientSlug, apiBase, basePath = "/projects", maxItems = 3, revalidate = 60, }) {
37
- const { allProjects, schema } = await fetchSimilarData(apiBase, clientSlug, revalidate);
53
+ export async function SimilarProjects({ filters = {}, excludeSlug, clientSlug, apiBase, basePath = "/projects", maxItems = 3, revalidate = 60, variant = "list", projectSlugs, }) {
54
+ const { allProjects, schema, fieldOptionsMap } = await fetchSimilarData(apiBase, clientSlug, revalidate);
38
55
  const badgeField = schema.find((f) => f.display_position === "badge_overlay");
39
56
  const locationField = schema.find((f) => f.type === "location");
40
57
  const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
41
- const filterEntries = Object.entries(filters);
42
- const similar = allProjects
43
- .filter((p) => {
44
- // Exclude the specified slug if provided
45
- if (excludeSlug && p.slug === excludeSlug)
46
- return false;
47
- // Every filter key/value must match the project's custom field values
48
- return filterEntries.every(([key, value]) => {
49
- const fieldValues = parseMultiValue(p.custom_field_values[key]);
50
- return fieldValues.some((v) => v.toLowerCase() === value.toLowerCase());
51
- });
52
- })
53
- .slice(0, maxItems);
58
+ let similar;
59
+ if (projectSlugs && projectSlugs.length > 0) {
60
+ // Manual mode — show exactly these slugs in the order provided
61
+ const projectMap = new Map(allProjects.map((p) => [p.slug, p]));
62
+ similar = projectSlugs
63
+ .filter((slug) => slug !== excludeSlug)
64
+ .map((slug) => projectMap.get(slug))
65
+ .filter((p) => p !== undefined)
66
+ .slice(0, maxItems);
67
+ }
68
+ else {
69
+ // Filter mode — match by custom field values
70
+ const filterEntries = Object.entries(filters);
71
+ similar = allProjects
72
+ .filter((p) => {
73
+ if (excludeSlug && p.slug === excludeSlug)
74
+ return false;
75
+ return filterEntries.every(([key, value]) => {
76
+ const fieldValues = parseMultiValue(p.custom_field_values[key]);
77
+ return fieldValues.some((v) => v.toLowerCase() === value.toLowerCase());
78
+ });
79
+ })
80
+ .slice(0, maxItems);
81
+ }
54
82
  if (similar.length === 0)
55
83
  return null;
84
+ const header = (_jsxs("div", { className: "chisel-similar-header", children: [_jsxs("div", { children: [_jsx("p", { style: { fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", color: "oklch(0.78 0.16 85)", margin: "0 0 6px 0" }, children: "More Work" }), _jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "clamp(20px, 4vw, 28px)", margin: 0, fontFamily: font }, children: "Similar Projects" })] }), _jsxs("a", { href: basePath, style: { color: "#18181b", fontWeight: 600, fontSize: "15px", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "6px", fontFamily: font, flexShrink: 0 }, children: ["View All", _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M9 18l6-6-6-6" }) })] })] }));
85
+ // ── Card variant ─────────────────────────────────────────────────────────────
86
+ if (variant === "card") {
87
+ return (_jsxs("section", { style: { borderTop: "1px solid #e4e4e7", maxWidth: "1280px", margin: "0 auto", padding: "3rem 1rem 2rem", boxSizing: "border-box", fontFamily: font }, children: [_jsx("style", { children: `
88
+ .chisel-similar-header { display: flex; flex-direction: column; gap: 12px; margin-bottom: 2rem; }
89
+ @media (min-width: 640px) { .chisel-similar-header { flex-direction: row; align-items: flex-end; justify-content: space-between; } }
90
+ .chisel-similar-card-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
91
+ @media (min-width: 640px) { .chisel-similar-card-grid { grid-template-columns: repeat(2, 1fr); } }
92
+ @media (min-width: 1024px) { .chisel-similar-card-grid { grid-template-columns: repeat(3, 1fr); } }
93
+ .chisel-project-card-img { height: 180px; }
94
+ @media (min-width: 640px) { .chisel-project-card-img { height: 200px; } }
95
+ @media (min-width: 1024px) { .chisel-project-card-img { height: 220px; } }
96
+ ` }), header, _jsx("div", { className: "chisel-similar-card-grid", children: similar.map((p, i) => (_jsx(ProjectCard, { project: p, schema: schema, fieldOptionsMap: fieldOptionsMap, basePath: basePath, priority: i === 0, variant: "card" }, p.id))) })] }));
97
+ }
98
+ // ── List variant (default) ────────────────────────────────────────────────────
56
99
  return (_jsxs("section", { style: { borderTop: "1px solid #e4e4e7", maxWidth: "1280px", margin: "0 auto", padding: "3rem 1rem 2rem", boxSizing: "border-box", fontFamily: font }, children: [_jsx("style", { children: `
57
100
  .chisel-similar-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
58
101
  @media (min-width: 640px) { .chisel-similar-grid { grid-template-columns: repeat(2, 1fr); } }
@@ -60,7 +103,7 @@ export async function SimilarProjects({ filters = {}, excludeSlug, clientSlug, a
60
103
  .chisel-similar-header { display: flex; flex-direction: column; gap: 12px; margin-bottom: 2rem; }
61
104
  @media (min-width: 640px) { .chisel-similar-header { flex-direction: row; align-items: flex-end; justify-content: space-between; } }
62
105
  .chisel-similar-img { height: 56vw; min-height: 160px; max-height: 220px; }
63
- ` }), _jsxs("div", { className: "chisel-similar-header", children: [_jsxs("div", { children: [_jsx("p", { style: { fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", color: "oklch(0.78 0.16 85)", margin: "0 0 6px 0" }, children: "More Work" }), _jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "clamp(20px, 4vw, 28px)", margin: 0, fontFamily: font }, children: "Similar Projects" })] }), _jsxs("a", { href: basePath, style: { color: "#18181b", fontWeight: 600, fontSize: "15px", textDecoration: "none", display: "inline-flex", alignItems: "center", gap: "6px", fontFamily: font, flexShrink: 0 }, children: ["View All", _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M9 18l6-6-6-6" }) })] })] }), _jsx("div", { className: "chisel-similar-grid", children: similar.map((p) => {
106
+ ` }), header, _jsx("div", { className: "chisel-similar-grid", children: similar.map((p) => {
64
107
  var _a, _b, _c, _d, _e;
65
108
  const imgUrl = (_d = (_a = p.image_url) !== null && _a !== void 0 ? _a : (_c = (_b = p.media) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.url) !== null && _d !== void 0 ? _d : null;
66
109
  const badge = badgeField
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-portfolio",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectPortfolioClient (with built-in filtering), ProjectDetail, SimilarProjects, ProjectMenu, ProjectMenuClient, and GalleryCarousel. Pass a clientSlug and apiBase — done.",
5
5
  "keywords": ["nextjs", "react", "portfolio", "projects", "megamenu", "gallery", "filtering"],
6
6
  "license": "MIT",