@chiselandco/nexus 2.2.7 → 2.5.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.
Files changed (40) hide show
  1. package/README.md +274 -286
  2. package/dist/FilterSidebar.d.ts +20 -0
  3. package/dist/FilterSidebar.d.ts.map +1 -0
  4. package/dist/FilterSidebar.js +266 -0
  5. package/dist/FilteredPortfolio.d.ts +45 -0
  6. package/dist/FilteredPortfolio.d.ts.map +1 -0
  7. package/dist/FilteredPortfolio.js +134 -0
  8. package/dist/GalleryCarousel.d.ts +9 -2
  9. package/dist/GalleryCarousel.d.ts.map +1 -1
  10. package/dist/GalleryCarousel.js +363 -63
  11. package/dist/ProjectDetail.d.ts +3 -1
  12. package/dist/ProjectDetail.d.ts.map +1 -1
  13. package/dist/ProjectDetail.js +33 -18
  14. package/dist/ProjectMenu.d.ts +8 -3
  15. package/dist/ProjectMenu.d.ts.map +1 -1
  16. package/dist/ProjectMenu.js +12 -16
  17. package/dist/ProjectMenuClient.d.ts +4 -2
  18. package/dist/ProjectMenuClient.d.ts.map +1 -1
  19. package/dist/ProjectMenuClient.js +6 -7
  20. package/dist/ProjectPortfolio.d.ts +3 -1
  21. package/dist/ProjectPortfolio.d.ts.map +1 -1
  22. package/dist/ProjectPortfolio.js +4 -4
  23. package/dist/ProjectPortfolioClient.d.ts +3 -1
  24. package/dist/ProjectPortfolioClient.d.ts.map +1 -1
  25. package/dist/ProjectPortfolioClient.js +4 -6
  26. package/dist/SimilarProjects.d.ts +3 -1
  27. package/dist/SimilarProjects.d.ts.map +1 -1
  28. package/dist/SimilarProjects.js +11 -9
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2 -0
  32. package/dist/types.d.ts +2 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/package.json +3 -2
  35. package/dist/ProjectFilters.d.ts +0 -11
  36. package/dist/ProjectFilters.d.ts.map +0 -1
  37. package/dist/ProjectFilters.js +0 -49
  38. package/dist/ProjectGrid.d.ts +0 -10
  39. package/dist/ProjectGrid.d.ts.map +0 -1
  40. package/dist/ProjectGrid.js +0 -8
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # @chiselandco/nexus
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 it needs with zero client-side waterfall requests.
3
+ Self-contained project portfolio components for Next.js App Router. Pass a `clientSlug`, `apiBase`, and `apiKey` — each component fetches, caches, and renders everything it needs with no client-side waterfall requests.
4
+
5
+ **Version:** 2.4.0
6
+
7
+ ---
4
8
 
5
9
  ## Requirements
6
10
 
@@ -9,6 +13,8 @@ A suite of self-contained project portfolio components for Next.js App Router. D
9
13
 
10
14
  No other dependencies required.
11
15
 
16
+ ---
17
+
12
18
  ## Installation
13
19
 
14
20
  ```bash
@@ -19,23 +25,24 @@ npm install @chiselandco/nexus
19
25
 
20
26
  ## Quick Start
21
27
 
22
- Here is the most common full setup — a projects grid page, a detail page with similar projects, and a megamenu in the nav.
28
+ The most common full setup — a filterable projects grid, a detail page with similar projects, and a megamenu in the nav.
23
29
 
24
30
  ```tsx
25
31
  // app/projects/page.tsx
26
- import { ProjectPortfolio } from "@chiselandco/nexus"
32
+ import { FilteredPortfolio } from "@chiselandco/nexus"
27
33
 
28
34
  export default async function ProjectsPage({
29
35
  searchParams,
30
36
  }: {
31
- searchParams: { [key: string]: string | string[] | undefined }
37
+ searchParams: Promise<Record<string, string | string[] | undefined>>
32
38
  }) {
33
39
  return (
34
- <ProjectPortfolio
40
+ <FilteredPortfolio
35
41
  clientSlug="your-client-slug"
36
42
  apiBase="https://your-api.com"
43
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
37
44
  basePath="/projects"
38
- searchParams={searchParams}
45
+ searchParams={await searchParams}
39
46
  />
40
47
  )
41
48
  }
@@ -45,26 +52,29 @@ export default async function ProjectsPage({
45
52
  // app/projects/[slug]/page.tsx
46
53
  import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
47
54
 
48
- export default async function ProjectPage({ params }: { params: { slug: string } }) {
55
+ export default async function ProjectPage({
56
+ params,
57
+ }: {
58
+ params: Promise<{ slug: string }>
59
+ }) {
60
+ const { slug } = await params
61
+ const apiKey = process.env.YOUR_CLIENT_API_KEY!
62
+
49
63
  return (
50
64
  <>
51
65
  <ProjectDetail
52
- slug={params.slug}
66
+ slug={slug}
53
67
  clientSlug="your-client-slug"
54
68
  apiBase="https://your-api.com"
69
+ apiKey={apiKey}
55
70
  backPath="/projects"
71
+ backLabel="All Projects"
56
72
  />
57
-
58
- {/* Hardcode the slugs you want shown — update per client request */}
59
73
  <SimilarProjects
60
- projectSlugs={[
61
- "jacob-javits-convention-center",
62
- "tillamook-bay-community-college",
63
- "lcisd-liberty-hill-high-school",
64
- ]}
65
- excludeSlug={params.slug}
74
+ excludeSlug={slug}
66
75
  clientSlug="your-client-slug"
67
76
  apiBase="https://your-api.com"
77
+ apiKey={apiKey}
68
78
  basePath="/projects"
69
79
  />
70
80
  </>
@@ -79,18 +89,18 @@ import { createMenuHandler } from "@chiselandco/nexus"
79
89
  export const GET = createMenuHandler({
80
90
  clientSlug: "your-client-slug",
81
91
  apiBase: "https://your-api.com",
92
+ apiKey: process.env.YOUR_CLIENT_API_KEY!,
82
93
  })
83
94
  ```
84
95
 
85
96
  ```tsx
86
- // components/Nav.tsx — "use client" component in your header
97
+ // components/Nav.tsx
87
98
  "use client"
88
99
  import { ProjectMenuClient } from "@chiselandco/nexus"
89
100
 
90
101
  export function Nav() {
91
102
  return (
92
103
  <nav>
93
- {/* ... other nav items ... */}
94
104
  <ProjectMenuClient
95
105
  dataUrl="/api/chisel-menu"
96
106
  basePath="/projects"
@@ -105,25 +115,25 @@ export function Nav() {
105
115
 
106
116
  ## Components
107
117
 
108
- ### `ProjectPortfolio`
118
+ ### `FilteredPortfolio`
109
119
 
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`.
120
+ Server component. The recommended primary projects grid. Fetches all projects once, reads `filter[key]=` URL params server-side to narrow results using AND-across-fields / OR-within-field logic, then renders a project count toolbar with a `FilterSidebar` trigger above a responsive card grid.
111
121
 
112
122
  ```tsx
113
- // app/projects/page.tsx
114
- import { ProjectPortfolio } from "@chiselandco/nexus"
123
+ import { FilteredPortfolio } from "@chiselandco/nexus"
115
124
 
116
125
  export default async function ProjectsPage({
117
126
  searchParams,
118
127
  }: {
119
- searchParams: { [key: string]: string | string[] | undefined }
128
+ searchParams: Promise<Record<string, string | string[] | undefined>>
120
129
  }) {
121
130
  return (
122
- <ProjectPortfolio
131
+ <FilteredPortfolio
123
132
  clientSlug="your-client-slug"
124
133
  apiBase="https://your-api.com"
134
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
125
135
  basePath="/projects"
126
- searchParams={searchParams}
136
+ searchParams={await searchParams}
127
137
  />
128
138
  )
129
139
  }
@@ -131,103 +141,71 @@ export default async function ProjectsPage({
131
141
 
132
142
  | Prop | Type | Required | Default | Description |
133
143
  |---|---|---|---|---|
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
144
+ | `clientSlug` | `string` | Yes | — | Client identifier passed to the API |
145
+ | `apiBase` | `string` | Yes | — | Base URL of the Chisel API |
146
+ | `apiKey` | `string` | Yes | | Client API key always pass via environment variable, never hardcode |
147
+ | `searchParams` | `Record<string, string \| string[] \| undefined>` | Yes | | Resolved Next.js `searchParams` await it before passing in Next.js 16+ |
148
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail card links |
149
+ | `filterKeys` | `string[]` | No | All eligible fields | Ordered list of field keys to expose in the filter drawer |
150
+ | `font` | `string` | No | System font | Font family string |
151
+ | `noCache` | `boolean` | No | `false` | Sets `cache: "no-store"` — useful during development |
152
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds |
141
153
 
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.
154
+ #### URL param format
143
155
 
144
- Filter URLs follow this pattern the key matches the custom field key in the schema:
156
+ Filters are written to and read from URL params in the form `filter[fieldKey]=slug1,slug2`. Multiple values within a field are OR'd; multiple fields are AND'd.
145
157
 
146
158
  ```
147
- /projects?filter[type]=commercial
148
- /projects?filter[type]=educational-facilities
159
+ /projects?filter[application]=hospitality,education&filter[systems]=spacematic
149
160
  ```
150
161
 
151
- When active filters are applied, a filter banner is shown above the grid with a "Clear filters" link back to `basePath`.
152
-
153
162
  ---
154
163
 
155
- ### `ProjectPortfolioClient`
156
-
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.
158
-
159
- ```tsx
160
- // Works in any component — no RSC required
161
- import { ProjectPortfolioClient } from "@chiselandco/nexus"
162
-
163
- export default function ProjectsPage() {
164
- return (
165
- <ProjectPortfolioClient
166
- clientSlug="your-client-slug"
167
- apiBase="https://your-api.com"
168
- basePath="/projects"
169
- />
170
- )
171
- }
172
- ```
164
+ ### `FilterSidebar`
173
165
 
174
- #### With a custom filter UI
166
+ Client component (`"use client"`). Renders an "Advanced Filters" trigger that opens a right-side drawer with one section per filterable field. Pills are solid black when active and outlined when inactive. Filter state is written to URL params so filtered views are shareable and survive page refresh.
175
167
 
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.
168
+ Used internally by `FilteredPortfolio` only import it standalone if you need to build a custom layout around it.
177
169
 
178
170
  ```tsx
179
- "use client"
180
- import { useState } from "react"
181
- import { ProjectPortfolioClient } from "@chiselandco/nexus"
171
+ import { FilterSidebar } from "@chiselandco/nexus"
182
172
 
183
- export default function ProjectsPage() {
184
- const [filters, setFilters] = useState<Record<string, string>>({})
185
-
186
- return (
187
- <>
188
- {/* Your own filter UI */}
189
- <select onChange={(e) => setFilters({ type: e.target.value })}>
190
- <option value="">All Types</option>
191
- <option value="commercial">Commercial</option>
192
- <option value="educational-facilities">Educational</option>
193
- </select>
194
-
195
- <ProjectPortfolioClient
196
- clientSlug="your-client-slug"
197
- apiBase="https://your-api.com"
198
- basePath="/projects"
199
- filters={filters}
200
- />
201
- </>
202
- )
203
- }
173
+ <FilterSidebar
174
+ schema={schema}
175
+ filterKeys={["application", "systems", "material"]}
176
+ triggerLabel="Advanced Filters"
177
+ />
204
178
  ```
205
179
 
206
180
  | Prop | Type | Required | Default | Description |
207
181
  |---|---|---|---|---|
208
- | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
209
- | `apiBase` | `string` | Yes | | Base URL of the projects API |
210
- | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
211
- | `filters` | `Record<string, string>` | No | `{}` | Active filters keyed by custom field key — filtering is instant, no API call on change |
212
- | `columns` | `2 \| 3` | No | `3` | Number of columns in the project grid |
213
- | `font` | `string` | No | System font stack | Font family string applied to all text |
182
+ | `schema` | `CustomFieldSchema[]` | Yes | — | Field schema from the API only `select` and `multi-select` fields with options are used |
183
+ | `filterKeys` | `string[]` | No | All eligible fields | Ordered list of field keys to show in the drawer |
184
+ | `triggerLabel` | `string` | No | `"Advanced Filters"` | Label for the trigger link |
185
+ | `font` | `string` | No | `"inherit"` | Font family string |
214
186
 
215
187
  ---
216
188
 
217
189
  ### `ProjectDetail`
218
190
 
219
- A full server-rendered project detail page. Fetches a single project by slug and renders a hero image, a dynamic stats bar, a "Project Overview" section with description and specs sidebar, a photo gallery, and a back link.
191
+ Server component. Fetches a single project by slug and renders a hero image, a stats bar, a project overview section with description and specs sidebar, and a `GalleryCarousel` with filterable media tag pills.
220
192
 
221
193
  ```tsx
222
194
  // app/projects/[slug]/page.tsx
223
195
  import { ProjectDetail } from "@chiselandco/nexus"
224
196
 
225
- export default async function ProjectPage({ params }: { params: { slug: string } }) {
197
+ export default async function ProjectPage({
198
+ params,
199
+ }: {
200
+ params: Promise<{ slug: string }>
201
+ }) {
202
+ const { slug } = await params
226
203
  return (
227
204
  <ProjectDetail
228
- slug={params.slug}
205
+ slug={slug}
229
206
  clientSlug="your-client-slug"
230
207
  apiBase="https://your-api.com"
208
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
231
209
  backPath="/projects"
232
210
  backLabel="All Projects"
233
211
  />
@@ -237,24 +215,22 @@ export default async function ProjectPage({ params }: { params: { slug: string }
237
215
 
238
216
  #### Stats bar
239
217
 
240
- The stats bar below the hero is driven by an explicit ordered key list. Fields are shown in this order when present in the schema and populated on the project:
218
+ The stats bar below the hero is schema-driven. Fields are shown in this order when present and populated:
241
219
 
242
- | Schema key | Label shown |
220
+ | Schema key | Label |
243
221
  |---|---|
244
222
  | `location` | Location |
245
- | `type` (badge field) | field `name` from schema |
223
+ | `type` | field `name` from schema |
246
224
  | `coverage` | Coverage |
247
225
  | `year-completed` | Completed |
248
226
  | `architect` | Architect |
249
227
  | `general-contractor` | General Contractor |
250
228
 
251
- If a field doesn't exist in the schema for a given client, or the project has no value for it, that stat is silently omitted. The column count adjusts automatically — 2 columns on mobile, 3 on tablet, up to 6 on desktop.
252
-
253
- #### Project Overview specs sidebar
229
+ #### Specs sidebar
254
230
 
255
- The "Project Overview" section renders the project description on the left and a specs sidebar on the right (amber accent border). The sidebar shows the following fields when populated, in this order:
231
+ The "Project Overview" section renders a specs sidebar when any of these fields are populated:
256
232
 
257
- | Schema key | Label shown |
233
+ | Schema key | Label |
258
234
  |---|---|
259
235
  | `systems-used` | Systems Used |
260
236
  | `systems` | Track Systems |
@@ -263,69 +239,93 @@ The "Project Overview" section renders the project description on the left and a
263
239
  | `finishes` | Finishes |
264
240
  | `specifications` | Specifications |
265
241
 
266
- Each group renders values as outlined pills. Fields with no value for the current project are omitted. Both the stats bar and the specs sidebar are fully schema-driven — if a key doesn't exist in a client's schema it is simply not shown, making `ProjectDetail` safe to reuse across clients with wildly different field configurations.
242
+ Both the stats bar and specs sidebar are fully schema-driven — fields not present in a client's schema are silently omitted, making `ProjectDetail` safe to use across clients with different field configurations.
243
+
244
+ #### Media enrichment
245
+
246
+ `ProjectDetail` automatically fetches `custom_field_values` from the list endpoint (where they are available) and merges them onto the single-project media items before passing them to `GalleryCarousel`. No extra work is needed.
267
247
 
268
248
  | Prop | Type | Required | Default | Description |
269
249
  |---|---|---|---|---|
270
250
  | `slug` | `string` | Yes | — | The project slug to load |
271
251
  | `clientSlug` | `string` | Yes | — | The client slug that owns this project |
272
252
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
253
+ | `apiKey` | `string` | Yes | — | Client API key — always pass via environment variable, never hardcode |
273
254
  | `backPath` | `string` | No | `"/projects"` | Path for the back navigation link |
274
255
  | `backLabel` | `string` | No | `"All Projects"` | Label for the back navigation link |
275
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
276
- | `noCache` | `boolean` | No | `false` | Bypasses the Next.js Data Cache and sets `cache: "no-store"`. Useful during development or for frequently updated projects. |
256
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds |
257
+ | `noCache` | `boolean` | No | `false` | Sets `cache: "no-store"` useful during development |
277
258
 
278
259
  ---
279
260
 
280
261
  ### `GalleryCarousel`
281
262
 
282
- 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.
263
+ Client component (`"use client"`). Image carousel with previous/next arrows, a counter badge, a scrollable thumbnail strip, URL-synced image filters, and automatic media tag pills. Used internally by `ProjectDetail` but can be used standalone.
283
264
 
284
265
  ```tsx
285
266
  "use client"
286
267
  import { GalleryCarousel } from "@chiselandco/nexus"
287
268
 
288
- // `media` is the array of image objects returned by the projects API
289
- export function ProjectGallery({ media, title }: { media: Media[]; title: string }) {
269
+ export function ProjectGallery({ media, schema, title }) {
290
270
  return (
291
271
  <GalleryCarousel
292
272
  images={media}
293
273
  projectTitle={title}
274
+ schema={schema}
294
275
  />
295
276
  )
296
277
  }
297
278
  ```
298
279
 
299
- The main image and thumbnails are standardised to a `16/9` aspect ratio — no external CSS required.
280
+ #### Media tag pills
281
+
282
+ When a media item has `custom_field_values` set, `GalleryCarousel` renders frosted-glass pills in the bottom-left corner of the active image. Each pill shows the field name and resolved value — e.g. `System: Speed-Rail with Mesh Infill`, `Finish: Black Anodized`. Pills update as the user navigates between images.
283
+
284
+ #### Image filtering
285
+
286
+ When images have `custom_field_values`, a filter bar appears above the gallery. Each field that appears on at least one image is shown as a row of pill buttons. Selecting a pill narrows both the main image and the thumbnail strip to only matching images. Multiple fields can be filtered simultaneously (AND logic). Filter state is written to the URL so filtered views are shareable:
287
+
288
+ ```
289
+ /projects/jacob-javits?filter[system]=Structural Glass&filter[finish]=Black Anodized
290
+ ```
300
291
 
301
292
  | Prop | Type | Required | Default | Description |
302
293
  |---|---|---|---|---|
303
294
  | `images` | `Media[]` | Yes | — | Array of media objects from the projects API |
304
295
  | `projectTitle` | `string` | Yes | — | Used as the alt text fallback for the main image |
296
+ | `schema` | `CustomFieldSchema[]` | No | `[]` | Client custom fields schema — used to resolve slug values to labels for pills and filter options |
305
297
 
306
298
  ---
307
299
 
308
300
  ### `SimilarProjects`
309
301
 
310
- 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.
302
+ Server component. Fetches all projects for a client, optionally filters to those matching provided field values, excludes the current project, and renders a section of matching results.
311
303
 
312
304
  ```tsx
313
305
  // app/projects/[slug]/page.tsx
314
306
  import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
315
307
 
316
- export default async function ProjectPage({ params }: { params: { slug: string } }) {
308
+ export default async function ProjectPage({
309
+ params,
310
+ }: {
311
+ params: Promise<{ slug: string }>
312
+ }) {
313
+ const { slug } = await params
314
+ const apiKey = process.env.YOUR_CLIENT_API_KEY!
315
+
317
316
  return (
318
317
  <>
319
318
  <ProjectDetail
320
- slug={params.slug}
319
+ slug={slug}
321
320
  clientSlug="your-client-slug"
322
321
  apiBase="https://your-api.com"
322
+ apiKey={apiKey}
323
323
  />
324
324
  <SimilarProjects
325
- filters={{ type: "commercial" }}
326
- excludeSlug={params.slug}
325
+ excludeSlug={slug}
327
326
  clientSlug="your-client-slug"
328
327
  apiBase="https://your-api.com"
328
+ apiKey={apiKey}
329
329
  basePath="/projects"
330
330
  />
331
331
  </>
@@ -333,133 +333,93 @@ export default async function ProjectPage({ params }: { params: { slug: string }
333
333
  }
334
334
  ```
335
335
 
336
- #### Deriving filters from the current project
336
+ #### Filtering by field value
337
337
 
338
- 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:
338
+ Pass `filters` to match projects that share a field value with the current project. The field values from the current project can be derived from the API response:
339
339
 
340
340
  ```tsx
341
- // app/projects/[slug]/page.tsx
342
- import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
343
-
344
- async function getProject(slug: string, clientSlug: string, apiBase: string) {
345
- const res = await fetch(
346
- `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=YOUR_API_KEY`,
347
- { next: { revalidate: 86400 } }
348
- )
349
- return res.ok ? (await res.json())?.data : null
350
- }
341
+ const apiKey = process.env.YOUR_CLIENT_API_KEY!
342
+ const res = await fetch(
343
+ `${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=${apiKey}`,
344
+ { next: { revalidate: 86400 } }
345
+ )
346
+ const project = res.ok ? (await res.json())?.data : null
347
+ // type may be a plain string or single-element array
348
+ const typeVal = project?.custom_field_values?.type
349
+ const projectType = Array.isArray(typeVal) ? typeVal[0] : typeVal ?? null
351
350
 
352
- export default async function ProjectPage({ params }: { params: { slug: string } }) {
353
- const project = await getProject(params.slug, "your-client-slug", "https://your-api.com")
354
- const projectType = project?.custom_field_values?.type ?? null
355
-
356
- return (
357
- <>
358
- <ProjectDetail slug={params.slug} clientSlug="your-client-slug" apiBase="https://your-api.com" />
359
- {projectType && (
360
- <SimilarProjects
361
- filters={{ type: projectType }}
362
- excludeSlug={params.slug}
363
- clientSlug="your-client-slug"
364
- apiBase="https://your-api.com"
365
- basePath="/projects"
366
- />
367
- )}
368
- </>
369
- )
370
- }
351
+ <SimilarProjects
352
+ filters={projectType ? { type: projectType } : {}}
353
+ excludeSlug={slug}
354
+ clientSlug="your-client-slug"
355
+ apiBase="https://your-api.com"
356
+ apiKey={apiKey}
357
+ basePath="/projects"
358
+ />
371
359
  ```
372
360
 
373
- #### Manually specifying projects (recommended for quick setup)
361
+ #### Manually specifying projects
374
362
 
375
- 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.
376
-
377
- 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.
363
+ Pass `projectSlugs` to hand-pick exactly which projects appear. This overrides `filters` entirely and is the simplest approach when you want curated results. `excludeSlug` is still respected.
378
364
 
379
365
  ```tsx
380
- // app/projects/[slug]/page.tsx
381
- import { ProjectDetail, SimilarProjects } from "@chiselandco/nexus"
382
-
383
- export default async function ProjectPage({ params }: { params: { slug: string } }) {
384
- return (
385
- <>
386
- {/* All your other page content above */}
387
- <ProjectDetail
388
- slug={params.slug}
389
- clientSlug="your-client-slug"
390
- apiBase="https://your-api.com"
391
- backPath="/projects"
392
- />
393
-
394
- {/* Similar projects hardcoded — update slugs per client request */}
395
- <SimilarProjects
396
- projectSlugs={[
397
- "jacob-javits-convention-center",
398
- "tillamook-bay-community-college",
399
- "lcisd-liberty-hill-high-school",
400
- ]}
401
- excludeSlug={params.slug}
402
- clientSlug="your-client-slug"
403
- apiBase="https://your-api.com"
404
- basePath="/projects"
405
- />
406
- </>
407
- )
408
- }
366
+ <SimilarProjects
367
+ projectSlugs={[
368
+ "jacob-javits-convention-center",
369
+ "tillamook-bay-community-college",
370
+ "lcisd-liberty-hill-high-school",
371
+ ]}
372
+ excludeSlug={slug}
373
+ clientSlug="your-client-slug"
374
+ apiBase="https://your-api.com"
375
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
376
+ basePath="/projects"
377
+ />
409
378
  ```
410
379
 
411
- > `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.
412
-
413
- 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`.
414
-
415
380
  #### Card variant
416
381
 
417
- Use `variant="card"` to render the same baseball-card style used in `ProjectPortfolio` instead of the default list style:
382
+ Use `variant="card"` to render baseball-card style instead of the default list style:
418
383
 
419
384
  ```tsx
420
- <SimilarProjects
421
- filters={{ type: "commercial" }}
422
- excludeSlug={params.slug}
423
- clientSlug="your-client-slug"
424
- apiBase="https://your-api.com"
425
- basePath="/projects"
426
- variant="card"
427
- />
385
+ <SimilarProjects variant="card" ... />
428
386
  ```
429
387
 
430
388
  | Prop | Type | Required | Default | Description |
431
389
  |---|---|---|---|---|
432
390
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
433
391
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
434
- | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
435
- | `excludeSlug` | `string` | No | | Slug of a project to exclude from results (e.g. the current project) |
392
+ | `apiKey` | `string` | Yes | | Client API key always pass via environment variable, never hardcode |
393
+ | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter by. All filters must match (AND logic) |
394
+ | `excludeSlug` | `string` | No | — | Project slug to exclude from results |
436
395
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
437
- | `projectSlugs` | `string[]` | No | — | Explicit list of project slugs to show, in order. When provided, overrides `filters` entirely. e.g. `["jacob-javits", "tillamook-bay"]` |
396
+ | `projectSlugs` | `string[]` | No | — | Explicit ordered list of slugs to show. Overrides `filters` when provided |
438
397
  | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
439
- | `title` | `string` | No | `"Similar Projects"` | Heading text for the section. e.g. `"Featured Projects"` |
440
- | `subtitle` | `string` | No | `"More Work"` | Small uppercase label above the heading. e.g. `"Our Work"` |
441
- | `variant` | `"list" \| "card"` | No | `"list"` | `"list"` uses the border-bottom separator style; `"card"` renders full baseball-card style matching `ProjectPortfolio` |
442
- | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
443
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
444
- | `noCache` | `boolean` | No | `false` | Bypasses the Next.js Data Cache and sets `cache: "no-store"`. Useful during development or for frequently updated projects. |
398
+ | `title` | `string` | No | `"Similar Projects"` | Section heading |
399
+ | `subtitle` | `string` | No | `"More Work"` | Small uppercase label above the heading |
400
+ | `variant` | `"list" \| "card"` | No | `"list"` | Display style |
401
+ | `font` | `string` | No | System font stack | Font family string |
402
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds |
403
+ | `noCache` | `boolean` | No | `false` | Sets `cache: "no-store"` useful during development |
445
404
 
446
405
  ---
447
406
 
448
407
  ### `ProjectMenu`
449
408
 
450
- 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.
409
+ Server component. Megamenu that shows featured projects as compact cards on the left and "Browse By" filter links on the right. Drop it directly into a navigation dropdown.
451
410
 
452
411
  ```tsx
453
- // components/MegaMenu.tsx
412
+ // components/MegaMenu.tsx — must be a Server Component
454
413
  import { ProjectMenu } from "@chiselandco/nexus"
455
414
 
456
- // Must be a Server Component — do NOT add "use client"
457
415
  export async function ProjectsMegaMenu() {
458
416
  return (
459
417
  <ProjectMenu
460
418
  clientSlug="your-client-slug"
461
419
  apiBase="https://your-api.com"
420
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
462
421
  basePath="/projects"
422
+ viewAllPath="/projects"
463
423
  subtitle="Our systems are installed in every geographic region of the U.S."
464
424
  maxProjects={6}
465
425
  />
@@ -467,14 +427,13 @@ export async function ProjectsMegaMenu() {
467
427
  }
468
428
  ```
469
429
 
470
- #### With a curated menu
471
-
472
- Pass `menuId` to show a specific curated set of projects instead of all projects. The value should be the menu **slug** (not the UUID) — available slugs can be retrieved from `GET /api/v1/clients/{clientSlug}/menus`. The Browse By filters on the right always reflect the full schema regardless of which menu is active.
430
+ Pass `menuId` to show a specific curated set of projects instead of all projects:
473
431
 
474
432
  ```tsx
475
433
  <ProjectMenu
476
434
  clientSlug="your-client-slug"
477
435
  apiBase="https://your-api.com"
436
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
478
437
  menuId="main-nav"
479
438
  basePath="/projects"
480
439
  />
@@ -484,28 +443,25 @@ Pass `menuId` to show a specific curated set of projects instead of all projects
484
443
  |---|---|---|---|---|
485
444
  | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
486
445
  | `apiBase` | `string` | Yes | — | Base URL of the projects API |
487
- | `menuId` | `string` | No | — | Slug of a curated menu. When provided, fetches from `/menus/{slug}` instead of all projects. Filters always shown regardless. |
446
+ | `apiKey` | `string` | Yes | — | Client API key always pass via environment variable, never hardcode |
447
+ | `menuId` | `string` | No | — | Slug of a curated menu. When provided fetches from `/menus/{slug}`. Browse By filters always reflect the full schema. |
488
448
  | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
489
449
  | `viewAllPath` | `string` | No | Same as `basePath` | Path for the "View All Projects" link |
490
- | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
491
- | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
450
+ | `subtitle` | `string` | No | — | Description shown above the project cards |
451
+ | `font` | `string` | No | System font stack | Font family string |
492
452
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
493
- | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
494
- | `noCache` | `boolean` | No | `false` | Bypasses the Next.js Data Cache and sets `cache: "no-store"`. Useful during development or for frequently updated projects. |
453
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds |
454
+ | `noCache` | `boolean` | No | `false` | Sets `cache: "no-store"` useful during development |
495
455
 
496
456
  ---
497
457
 
498
458
  ### `ProjectMenuClient` + `createMenuHandler`
499
459
 
500
- `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.
460
+ Client component (`"use client"`). Use when your nav or header is a client component. Fetches and caches data on first mount the API is never called twice on re-hover or remount.
501
461
 
502
- There are two ways to set it up:
462
+ #### Option 1 `dataUrl` + `createMenuHandler` (recommended)
503
463
 
504
- ---
505
-
506
- #### Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)
507
-
508
- 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.
464
+ Create one API route. Data is server-cached for 24 hours.
509
465
 
510
466
  ```ts
511
467
  // app/api/chisel-menu/route.ts
@@ -514,22 +470,10 @@ import { createMenuHandler } from "@chiselandco/nexus"
514
470
  export const GET = createMenuHandler({
515
471
  clientSlug: "your-client-slug",
516
472
  apiBase: "https://your-api.com",
473
+ apiKey: process.env.YOUR_CLIENT_API_KEY!,
517
474
  })
518
475
  ```
519
476
 
520
- For a curated menu, pass `menuId` to the handler:
521
-
522
- ```ts
523
- // app/api/chisel-menu/route.ts
524
- export const GET = createMenuHandler({
525
- clientSlug: "your-client-slug",
526
- apiBase: "https://your-api.com",
527
- menuId: "main-nav",
528
- })
529
- ```
530
-
531
- Then pass `dataUrl` to the component:
532
-
533
477
  ```tsx
534
478
  // components/Nav.tsx
535
479
  "use client"
@@ -541,18 +485,16 @@ export function Nav() {
541
485
  dataUrl="/api/chisel-menu"
542
486
  basePath="/projects"
543
487
  viewAllPath="/projects"
544
- subtitle="Explore our portfolio of projects."
488
+ subtitle="Explore our portfolio."
545
489
  maxProjects={6}
546
490
  />
547
491
  )
548
492
  }
549
493
  ```
550
494
 
551
- ---
552
-
553
- #### Option 2 — Direct fetch (quick setup / non-Next.js environments)
495
+ #### Option 2 — Direct fetch (quick setup)
554
496
 
555
- 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.
497
+ No API route needed. The component fetches directly from the upstream API on first mount. Note: this exposes the API call to the client browser.
556
498
 
557
499
  ```tsx
558
500
  "use client"
@@ -563,6 +505,7 @@ export function Nav() {
563
505
  <ProjectMenuClient
564
506
  clientSlug="your-client-slug"
565
507
  apiBase="https://your-api.com"
508
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
566
509
  basePath="/projects"
567
510
  viewAllPath="/projects"
568
511
  />
@@ -570,101 +513,146 @@ export function Nav() {
570
513
  }
571
514
  ```
572
515
 
573
- With a curated menu:
574
-
575
- ```tsx
576
- <ProjectMenuClient
577
- clientSlug="your-client-slug"
578
- apiBase="https://your-api.com"
579
- menuId="main-nav"
580
- basePath="/projects"
581
- viewAllPath="/projects"
582
- />
583
- ```
584
-
585
- ---
586
-
587
516
  | Prop | Type | Required | Default | Description |
588
517
  |---|---|---|---|---|
589
- | `dataUrl` | `string` | No* | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
518
+ | `dataUrl` | `string` | No* | — | URL of a local API route created with `createMenuHandler()` recommended for production |
590
519
  | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
591
520
  | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
592
- | `menuId` | `string` | No | — | Slug of a curated menu. Fetches from `/menus/{slug}` for projects. Filters are always shown regardless. |
593
- | `noCache` | `boolean` | No | `false` | Bypasses the module-level data cache and sets `cache: "no-store"` on all fetch calls. Useful during development or when projects update frequently. |
521
+ | `apiKey` | `string` | No* | — | Client API key for direct fetch mode |
522
+ | `menuId` | `string` | No | | Slug of a curated menu |
594
523
  | `basePath` | `string` | Yes | — | Base path for project detail links |
595
524
  | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
596
- | `subtitle` | `string` | No | — | Description shown above the project cards (hidden on mobile) |
525
+ | `subtitle` | `string` | No | — | Description shown above the project cards |
597
526
  | `font` | `string` | No | System font stack | Font family string |
598
- | `maxProjects` | `number` | No | `6` | Maximum number of projects to display (capped at 3 on mobile) |
527
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
528
+ | `noCache` | `boolean` | No | `false` | Bypasses the module-level data cache |
599
529
 
600
- *One of `dataUrl` or `clientSlug + apiBase` must be provided.
530
+ *One of `dataUrl` or `clientSlug + apiBase + apiKey` must be provided.
601
531
 
602
532
  ---
603
533
 
604
- ## Server vs Client Components
534
+ ### `ProjectPortfolio`
605
535
 
606
- All top-level components except `ProjectMenuClient`, `ProjectPortfolioClient`, and `GalleryCarousel` are **async Server Components**. They must be rendered in a server context:
536
+ Server component. Fetches all projects and renders a responsive baseball card grid (1 col mobile / 2 col tablet / 3 col desktop). Supports URL-driven filtering via `searchParams`. Prefer `FilteredPortfolio` for new projects it includes the full filter sidebar and is the recommended default.
607
537
 
608
538
  ```tsx
609
- // CORRECT
610
- export default async function Page() {
611
- return <ProjectPortfolio clientSlug="..." apiBase="..." />
539
+ // app/projects/page.tsx
540
+ import { ProjectPortfolio } from "@chiselandco/nexus"
541
+
542
+ export default async function ProjectsPage({
543
+ searchParams,
544
+ }: {
545
+ searchParams: Promise<Record<string, string | string[] | undefined>>
546
+ }) {
547
+ return (
548
+ <ProjectPortfolio
549
+ clientSlug="your-client-slug"
550
+ apiBase="https://your-api.com"
551
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
552
+ basePath="/projects"
553
+ searchParams={await searchParams}
554
+ />
555
+ )
612
556
  }
557
+ ```
613
558
 
614
- // WRONG causes a runtime error
559
+ | Prop | Type | Required | Default | Description |
560
+ |---|---|---|---|---|
561
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
562
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
563
+ | `apiKey` | `string` | Yes | — | Client API key — always pass via environment variable, never hardcode |
564
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
565
+ | `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params — pass Next.js `searchParams` directly |
566
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds |
567
+ | `noCache` | `boolean` | No | `false` | Sets `cache: "no-store"` — useful during development |
568
+
569
+ ---
570
+
571
+ ### `ProjectPortfolioClient`
572
+
573
+ Client component (`"use client"`). Same grid as `ProjectPortfolio` but renders client-side. Fetches all projects once on mount (module-level cached). Use this inside a client component tree or when you want to build a custom filter UI that filters in memory.
574
+
575
+ ```tsx
615
576
  "use client"
616
- export default function Page() {
617
- return <ProjectPortfolio clientSlug="..." apiBase="..." />
577
+ import { useState } from "react"
578
+ import { ProjectPortfolioClient } from "@chiselandco/nexus"
579
+
580
+ export default function ProjectsPage() {
581
+ const [filters, setFilters] = useState<Record<string, string>>({})
582
+
583
+ return (
584
+ <>
585
+ <select onChange={(e) => setFilters({ type: e.target.value })}>
586
+ <option value="">All Types</option>
587
+ <option value="commercial">Commercial</option>
588
+ </select>
589
+ <ProjectPortfolioClient
590
+ clientSlug="your-client-slug"
591
+ apiBase="https://your-api.com"
592
+ apiKey={process.env.YOUR_CLIENT_API_KEY!}
593
+ basePath="/projects"
594
+ filters={filters}
595
+ />
596
+ </>
597
+ )
618
598
  }
619
599
  ```
620
600
 
621
- 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.
601
+ | Prop | Type | Required | Default | Description |
602
+ |---|---|---|---|---|
603
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
604
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
605
+ | `apiKey` | `string` | Yes | — | Client API key — always pass via environment variable, never hardcode |
606
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
607
+ | `filters` | `Record<string, string>` | No | `{}` | Active filters — filtering is instant, no API call on change |
608
+ | `columns` | `2 \| 3` | No | `3` | Number of grid columns |
609
+ | `font` | `string` | No | System font stack | Font family string |
622
610
 
623
- | Component | Type | Use when |
611
+ ---
612
+
613
+ ## Server vs Client components
614
+
615
+ | Component | Type | Notes |
624
616
  |---|---|---|
625
- | `ProjectPortfolio` | Server | Page-level grid, URL-driven filters |
626
- | `ProjectPortfolioClient` | Client | Inside a client tree, custom filter UI |
627
- | `ProjectDetail` | Server | Project detail page |
628
- | `GalleryCarousel` | Client | Standalone image gallery |
629
- | `ProjectMenu` | Server | Server-rendered nav dropdown |
630
- | `ProjectMenuClient` | Client | Client-rendered nav/header |
617
+ | `FilteredPortfolio` | Server | Recommended default for project grids |
618
+ | `FilterSidebar` | Client | Used internally by `FilteredPortfolio` |
619
+ | `ProjectPortfolio` | Server | Simpler grid without sidebar |
620
+ | `ProjectPortfolioClient` | Client | For use inside client trees |
621
+ | `ProjectDetail` | Server | Full project detail page |
622
+ | `GalleryCarousel` | Client | Used internally by `ProjectDetail` |
631
623
  | `SimilarProjects` | Server | After `ProjectDetail` on detail pages |
624
+ | `ProjectMenu` | Server | Server-rendered nav megamenu |
625
+ | `ProjectMenuClient` | Client | Client-rendered nav megamenu |
626
+
627
+ All server components must be rendered in a server context. If your parent component uses `"use client"`, use the client variants or pass server components as `children` from a server parent.
632
628
 
633
629
  ---
634
630
 
635
631
  ## Caching
636
632
 
637
- | Component | Server Cache | Client Cache |
633
+ | Component | Server cache | Client cache |
638
634
  |---|---|---|
639
- | `ProjectMenu` (RSC) | 24h via `next.revalidate` | — |
635
+ | `FilteredPortfolio` | 24h via `next.revalidate` | — |
636
+ | `ProjectPortfolio` | 24h via `next.revalidate` | — |
637
+ | `ProjectDetail` | 24h via `next.revalidate` | — |
638
+ | `SimilarProjects` | 24h via `next.revalidate` | — |
639
+ | `ProjectMenu` | 24h via `next.revalidate` | — |
640
640
  | `ProjectMenuClient` + `createMenuHandler` | 24h (route handler) | Per-session module cache |
641
641
  | `ProjectMenuClient` (direct fetch) | None | Per-session module cache |
642
- | `ProjectPortfolio` (RSC) | 24h via `next.revalidate` | — |
643
642
  | `ProjectPortfolioClient` | None | Per-session module cache |
644
- | `ProjectDetail` (RSC) | 24h via `next.revalidate` | — |
645
- | `SimilarProjects` (RSC) | 24h via `next.revalidate` | — |
646
643
 
647
- #### Bypassing the cache
648
-
649
- | Method | Scope | Use case |
650
- |---|---|---|
651
- | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
652
- | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
653
- | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache | CMS webhook on content publish |
654
- | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
644
+ Pass `noCache={true}` on any server component to bypass the cache during development. To invalidate the server cache from a CMS webhook:
655
645
 
656
646
  ```ts
657
- // CMS webhook — invalidate server cache on publish
658
647
  import { revalidateTag } from "next/cache"
659
648
  revalidateTag("chisel-menu-your-client-slug")
660
-
661
- // For a curated menu, the tag includes the menuId
649
+ // For a curated menu:
662
650
  revalidateTag("chisel-menu-your-client-slug-main-nav")
663
651
  ```
664
652
 
665
653
  ---
666
654
 
667
- ## Publishing to npm
655
+ ## Publishing
668
656
 
669
657
  ```bash
670
658
  npm login