project-portfolio 1.9.0 → 2.0.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 +148 -38
- package/dist/ProjectMenu.d.ts +30 -1
- package/dist/ProjectMenu.d.ts.map +1 -1
- package/dist/ProjectMenu.js +77 -2
- package/dist/ProjectMenuClient.d.ts +11 -4
- package/dist/ProjectMenuClient.d.ts.map +1 -1
- package/dist/ProjectMenuClient.js +69 -48
- package/dist/ProjectPortfolioClient.d.ts +21 -0
- package/dist/ProjectPortfolioClient.d.ts.map +1 -0
- package/dist/ProjectPortfolioClient.js +136 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ export default async function ProjectsPage() {
|
|
|
47
47
|
| `searchParams` | `Record<string, string \| string[] \| undefined>` | No | `{}` | Filter params forwarded to the API — pass Next.js `searchParams` directly |
|
|
48
48
|
| `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
|
|
49
49
|
|
|
50
|
-
####
|
|
50
|
+
#### URL-driven filter integration
|
|
51
51
|
|
|
52
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:
|
|
53
53
|
|
|
@@ -83,6 +83,69 @@ When active filters are applied, a filter banner is shown above the grid with a
|
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
86
|
+
### `ProjectPortfolioClient`
|
|
87
|
+
|
|
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.
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
// Works in any component — no RSC required
|
|
92
|
+
import { ProjectPortfolioClient } from "project-portfolio"
|
|
93
|
+
|
|
94
|
+
export default function ProjectsPage() {
|
|
95
|
+
return (
|
|
96
|
+
<ProjectPortfolioClient
|
|
97
|
+
clientSlug="your-client-slug"
|
|
98
|
+
apiBase="https://your-api.com"
|
|
99
|
+
basePath="/projects"
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Wire up your own filter UI with the `filters` prop:
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
"use client"
|
|
109
|
+
import { useState } from "react"
|
|
110
|
+
import { ProjectPortfolioClient } from "project-portfolio"
|
|
111
|
+
|
|
112
|
+
export default function ProjectsPage() {
|
|
113
|
+
const [filters, setFilters] = useState<Record<string, string>>({})
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
{/* Your own filter UI */}
|
|
118
|
+
<select onChange={(e) => setFilters({ type: e.target.value })}>
|
|
119
|
+
<option value="">All Types</option>
|
|
120
|
+
<option value="commercial">Commercial</option>
|
|
121
|
+
<option value="educational-facilities">Educational</option>
|
|
122
|
+
</select>
|
|
123
|
+
|
|
124
|
+
{/* Component receives filters and applies them in memory */}
|
|
125
|
+
<ProjectPortfolioClient
|
|
126
|
+
clientSlug="your-client-slug"
|
|
127
|
+
apiBase="https://your-api.com"
|
|
128
|
+
basePath="/projects"
|
|
129
|
+
filters={filters}
|
|
130
|
+
/>
|
|
131
|
+
</>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
| Prop | Type | Required | Default | Description |
|
|
137
|
+
|---|---|---|---|---|
|
|
138
|
+
| `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
|
|
139
|
+
| `apiBase` | `string` | Yes | — | Base URL of the projects API |
|
|
140
|
+
| `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
|
|
141
|
+
| `filters` | `Record<string, string>` | No | `{}` | Active filters keyed by custom field key — filtering is instant, no API call on change |
|
|
142
|
+
| `columns` | `2 \| 3` | No | `3` | Number of columns in the project grid |
|
|
143
|
+
| `font` | `string` | No | System font stack | Font family string applied to all text |
|
|
144
|
+
|
|
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
|
+
---
|
|
148
|
+
|
|
86
149
|
### `ProjectDetail`
|
|
87
150
|
|
|
88
151
|
A full project detail page. Fetches a single project by slug and renders its gallery, custom fields, related projects, and a back link.
|
|
@@ -147,7 +210,8 @@ export async function ProjectsMegaMenu() {
|
|
|
147
210
|
| `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
|
|
148
211
|
| `font` | `string` | No | System font stack | Font family string applied to all inline styles |
|
|
149
212
|
| `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
|
|
150
|
-
| `revalidate` | `number` | No | `
|
|
213
|
+
| `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
|
|
214
|
+
| `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
|
|
151
215
|
|
|
152
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.
|
|
153
217
|
|
|
@@ -170,7 +234,8 @@ export default async function ProjectPage({ params }: { params: { slug: string }
|
|
|
170
234
|
apiBase="https://your-api.com"
|
|
171
235
|
/>
|
|
172
236
|
<SimilarProjects
|
|
173
|
-
|
|
237
|
+
filters={{ type: "commercial" }}
|
|
238
|
+
excludeSlug={params.slug}
|
|
174
239
|
clientSlug="your-client-slug"
|
|
175
240
|
apiBase="https://your-api.com"
|
|
176
241
|
basePath="/projects"
|
|
@@ -180,63 +245,85 @@ export default async function ProjectPage({ params }: { params: { slug: string }
|
|
|
180
245
|
}
|
|
181
246
|
```
|
|
182
247
|
|
|
248
|
+
`filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
|
|
249
|
+
|
|
183
250
|
| Prop | Type | Required | Default | Description |
|
|
184
251
|
|---|---|---|---|---|
|
|
185
|
-
| `
|
|
252
|
+
| `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
|
|
253
|
+
| `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
|
|
186
254
|
| `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
|
|
187
255
|
| `apiBase` | `string` | Yes | — | Base URL of the projects API |
|
|
188
256
|
| `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
|
|
257
|
+
| `maxItems` | `number` | No | `3` | Maximum number of projects to show |
|
|
189
258
|
| `font` | `string` | No | System font stack | Font family string applied to all inline styles |
|
|
190
259
|
| `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
|
|
191
260
|
|
|
192
261
|
---
|
|
193
262
|
|
|
194
|
-
### `ProjectMenuClient`
|
|
263
|
+
### `ProjectMenuClient` + `createMenuHandler`
|
|
195
264
|
|
|
196
|
-
|
|
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.
|
|
266
|
+
|
|
267
|
+
There are two ways to use it:
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
**Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
|
|
272
|
+
|
|
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.
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
// app/api/chisel-menu/route.ts
|
|
277
|
+
import { createMenuHandler } from "project-portfolio"
|
|
278
|
+
|
|
279
|
+
export const GET = createMenuHandler({
|
|
280
|
+
clientSlug: "your-client-slug",
|
|
281
|
+
apiBase: "https://your-api.com",
|
|
282
|
+
})
|
|
283
|
+
```
|
|
197
284
|
|
|
198
285
|
```tsx
|
|
199
|
-
//
|
|
200
|
-
"use client"
|
|
286
|
+
// In your nav/header — works in any "use client" component
|
|
201
287
|
import { ProjectMenuClient } from "project-portfolio"
|
|
202
288
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
289
|
+
<ProjectMenuClient
|
|
290
|
+
dataUrl="/api/chisel-menu"
|
|
291
|
+
basePath="/projects"
|
|
292
|
+
viewAllPath="/projects"
|
|
293
|
+
/>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
**Option 2 — Direct fetch (quick setup / non-Next.js environments)**
|
|
299
|
+
|
|
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.
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
import { ProjectMenuClient } from "project-portfolio"
|
|
304
|
+
|
|
305
|
+
<ProjectMenuClient
|
|
306
|
+
clientSlug="your-client-slug"
|
|
307
|
+
apiBase="https://your-api.com"
|
|
308
|
+
basePath="/projects"
|
|
309
|
+
viewAllPath="/projects"
|
|
310
|
+
/>
|
|
213
311
|
```
|
|
214
312
|
|
|
313
|
+
---
|
|
314
|
+
|
|
215
315
|
| Prop | Type | Required | Default | Description |
|
|
216
316
|
|---|---|---|---|---|
|
|
217
|
-
| `
|
|
218
|
-
| `
|
|
317
|
+
| `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
|
|
318
|
+
| `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
|
|
319
|
+
| `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
|
|
219
320
|
| `basePath` | `string` | Yes | — | Base path for project detail links |
|
|
220
321
|
| `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
|
|
221
322
|
| `subtitle` | `string` | No | — | Description shown above the project cards |
|
|
222
323
|
| `font` | `string` | No | System font stack | Font family string |
|
|
223
324
|
| `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
|
|
224
325
|
|
|
225
|
-
*
|
|
226
|
-
|
|
227
|
-
**Advanced: pre-fetch with `fetchProjectMenuData`**
|
|
228
|
-
|
|
229
|
-
If you want server-side data fetching without RSC (e.g. in a Next.js API route), use `fetchProjectMenuData` and spread the result into `ProjectMenuClient`:
|
|
230
|
-
|
|
231
|
-
```tsx
|
|
232
|
-
import { fetchProjectMenuData } from "project-portfolio"
|
|
233
|
-
|
|
234
|
-
// In your API route or server action:
|
|
235
|
-
const data = await fetchProjectMenuData({ clientSlug: "...", apiBase: "..." })
|
|
236
|
-
|
|
237
|
-
// Then pass to the client component:
|
|
238
|
-
<ProjectMenuClient {...data} basePath="/projects" viewAllPath="/projects" />
|
|
239
|
-
```
|
|
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.
|
|
240
327
|
|
|
241
328
|
---
|
|
242
329
|
|
|
@@ -263,10 +350,33 @@ If a parent component uses `"use client"`, these components cannot be rendered i
|
|
|
263
350
|
|
|
264
351
|
## Caching
|
|
265
352
|
|
|
266
|
-
Data is cached at
|
|
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
|
|
267
358
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
365
|
+
|
|
366
|
+
**Bypassing the cache**
|
|
367
|
+
|
|
368
|
+
| Method | Scope | Use case |
|
|
369
|
+
|---|---|---|
|
|
370
|
+
| `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
|
|
371
|
+
| `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
|
|
372
|
+
| `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
|
|
373
|
+
| `noCache: true` on `ProjectMenu` | Single render | Debug during development |
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
// CMS webhook example — invalidate server cache immediately on publish
|
|
377
|
+
import { revalidateTag } from "next/cache"
|
|
378
|
+
revalidateTag("chisel-menu-your-client-slug")
|
|
379
|
+
```
|
|
270
380
|
|
|
271
381
|
No third-party caching libraries are required.
|
|
272
382
|
|
package/dist/ProjectMenu.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface ProjectMenuProps {
|
|
|
14
14
|
font?: string;
|
|
15
15
|
/** Max number of projects to show at once. Defaults to 6 */
|
|
16
16
|
maxProjects?: number;
|
|
17
|
-
/** Revalidation period in seconds. Defaults to
|
|
17
|
+
/** Revalidation period in seconds. Defaults to 86400 (24 hours) */
|
|
18
18
|
revalidate?: number;
|
|
19
19
|
/**
|
|
20
20
|
* Disable all caching. Every request hits the API fresh.
|
|
@@ -22,6 +22,35 @@ export interface ProjectMenuProps {
|
|
|
22
22
|
*/
|
|
23
23
|
noCache?: boolean;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* createMenuHandler
|
|
27
|
+
*
|
|
28
|
+
* Creates a Next.js route handler that fetches and server-caches the menu data.
|
|
29
|
+
* Drop it into app/api/chisel-menu/route.ts — one line of setup.
|
|
30
|
+
*
|
|
31
|
+
* Features:
|
|
32
|
+
* - Cached for 24 hours by default (override with revalidate option)
|
|
33
|
+
* - Add ?bust=1 to bypass the cache for a single request (dev/testing)
|
|
34
|
+
* - Set CHISEL_CACHE_BYPASS=true in env to disable caching for the whole deployment (staging)
|
|
35
|
+
* - Fetch is tagged with "chisel-menu-{clientSlug}" for revalidateTag() support
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* // app/api/chisel-menu/route.ts
|
|
39
|
+
* import { createMenuHandler } from "project-portfolio"
|
|
40
|
+
* export const GET = createMenuHandler({ clientSlug: "my-client", apiBase: "https://nexus.chiselandco.com" })
|
|
41
|
+
*
|
|
42
|
+
* // Bust the cache manually (one request):
|
|
43
|
+
* fetch("/api/chisel-menu?bust=1")
|
|
44
|
+
*
|
|
45
|
+
* // Invalidate from a CMS webhook:
|
|
46
|
+
* import { revalidateTag } from "next/cache"
|
|
47
|
+
* revalidateTag("chisel-menu-my-client")
|
|
48
|
+
*/
|
|
49
|
+
export declare function createMenuHandler({ clientSlug, apiBase, revalidate, }: {
|
|
50
|
+
clientSlug: string;
|
|
51
|
+
apiBase: string;
|
|
52
|
+
revalidate?: number;
|
|
53
|
+
}): (request: Request) => Promise<Response>;
|
|
25
54
|
export declare function fetchProjectMenuData({ apiBase, clientSlug, revalidate, noCache, }: {
|
|
26
55
|
apiBase: string;
|
|
27
56
|
clientSlug: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectMenu.d.ts","sourceRoot":"","sources":["../src/ProjectMenu.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAKzD,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,
|
|
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"}
|
package/dist/ProjectMenu.js
CHANGED
|
@@ -2,7 +2,82 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { cache } from "react";
|
|
3
3
|
import { ProjectMenuClient } from "./ProjectMenuClient";
|
|
4
4
|
const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* createMenuHandler
|
|
7
|
+
*
|
|
8
|
+
* Creates a Next.js route handler that fetches and server-caches the menu data.
|
|
9
|
+
* Drop it into app/api/chisel-menu/route.ts — one line of setup.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Cached for 24 hours by default (override with revalidate option)
|
|
13
|
+
* - Add ?bust=1 to bypass the cache for a single request (dev/testing)
|
|
14
|
+
* - Set CHISEL_CACHE_BYPASS=true in env to disable caching for the whole deployment (staging)
|
|
15
|
+
* - Fetch is tagged with "chisel-menu-{clientSlug}" for revalidateTag() support
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // app/api/chisel-menu/route.ts
|
|
19
|
+
* import { createMenuHandler } from "project-portfolio"
|
|
20
|
+
* export const GET = createMenuHandler({ clientSlug: "my-client", apiBase: "https://nexus.chiselandco.com" })
|
|
21
|
+
*
|
|
22
|
+
* // Bust the cache manually (one request):
|
|
23
|
+
* fetch("/api/chisel-menu?bust=1")
|
|
24
|
+
*
|
|
25
|
+
* // Invalidate from a CMS webhook:
|
|
26
|
+
* import { revalidateTag } from "next/cache"
|
|
27
|
+
* revalidateTag("chisel-menu-my-client")
|
|
28
|
+
*/
|
|
29
|
+
export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, }) {
|
|
30
|
+
return async function GET(request) {
|
|
31
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
32
|
+
const bypass = process.env.CHISEL_CACHE_BYPASS === "true" ||
|
|
33
|
+
new URL(request.url).searchParams.has("bust");
|
|
34
|
+
const fetchOpts = bypass
|
|
35
|
+
? { cache: "no-store" }
|
|
36
|
+
: { next: { revalidate, tags: [`chisel-menu-${clientSlug}`] } };
|
|
37
|
+
try {
|
|
38
|
+
const [projectsRes, fieldsRes] = await Promise.all([
|
|
39
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
|
|
40
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`, fetchOpts),
|
|
41
|
+
]);
|
|
42
|
+
const json = projectsRes.ok ? await projectsRes.json() : {};
|
|
43
|
+
const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
|
|
44
|
+
const schema = (_c = (_b = json === null || json === void 0 ? void 0 : json.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : [];
|
|
45
|
+
const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
|
|
46
|
+
const fieldOptionsMap = {};
|
|
47
|
+
for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
|
|
48
|
+
const map = {};
|
|
49
|
+
for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
|
|
50
|
+
if (typeof opt === "object" && opt.id && opt.label) {
|
|
51
|
+
map[opt.id] = opt.label;
|
|
52
|
+
map[opt.label] = opt.label;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
fieldOptionsMap[field.key] = map;
|
|
56
|
+
}
|
|
57
|
+
const filterField = (_f = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _f !== void 0 ? _f : null;
|
|
58
|
+
const filterOptions = filterField
|
|
59
|
+
? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
|
|
60
|
+
var _a, _b;
|
|
61
|
+
if (typeof opt === "string")
|
|
62
|
+
return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
|
|
63
|
+
return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
|
|
64
|
+
})
|
|
65
|
+
: [];
|
|
66
|
+
return Response.json({
|
|
67
|
+
projects,
|
|
68
|
+
schema,
|
|
69
|
+
filterOptions,
|
|
70
|
+
filterFieldKey: (_h = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _h !== void 0 ? _h : null,
|
|
71
|
+
filterFieldName: (_j = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _j !== void 0 ? _j : "Project Type",
|
|
72
|
+
fieldOptionsMap,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (_k) {
|
|
76
|
+
return Response.json({ projects: [], schema: [], filterOptions: [], filterFieldKey: null, filterFieldName: "Project Type", fieldOptionsMap: {} }, { status: 500 });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 86400, noCache = false, }) {
|
|
6
81
|
var _a, _b, _c, _d;
|
|
7
82
|
const raw = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
|
|
8
83
|
const filterField = (_a = raw.schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
|
|
@@ -58,7 +133,7 @@ const _fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = f
|
|
|
58
133
|
return { projects: [], schema: [], fieldOptionsMap: {} };
|
|
59
134
|
}
|
|
60
135
|
});
|
|
61
|
-
export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate =
|
|
136
|
+
export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 86400, noCache = false, }) {
|
|
62
137
|
var _a, _b, _c, _d;
|
|
63
138
|
const { projects, schema, fieldOptionsMap } = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
|
|
64
139
|
// Find the filterable select field (badge_overlay is our category field)
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { Project, CustomFieldSchema } from "./types";
|
|
2
2
|
export interface ProjectMenuClientProps {
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* URL of a local API route created with createMenuHandler().
|
|
5
|
+
* Recommended for production — data is server-cached for 24h and never
|
|
6
|
+
* exposes the upstream API key to the browser.
|
|
7
|
+
* e.g. dataUrl="/api/chisel-menu"
|
|
8
|
+
*/
|
|
9
|
+
dataUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Pass clientSlug + apiBase to have the component fetch its own data directly.
|
|
12
|
+
* Use this for quick setup or non-Next.js environments.
|
|
13
|
+
* For production Next.js apps, prefer dataUrl + createMenuHandler instead.
|
|
7
14
|
*/
|
|
8
15
|
clientSlug?: string;
|
|
9
16
|
apiBase?: string;
|
|
@@ -22,5 +29,5 @@ export interface ProjectMenuClientProps {
|
|
|
22
29
|
font?: string;
|
|
23
30
|
maxProjects?: number;
|
|
24
31
|
}
|
|
25
|
-
export declare function ProjectMenuClient({ clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp, fieldOptionsMap: fieldOptionsMapProp, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
|
|
32
|
+
export declare function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp, fieldOptionsMap: fieldOptionsMapProp, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
|
|
26
33
|
//# sourceMappingURL=ProjectMenuClient.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProjectMenuClient.d.ts","sourceRoot":"","sources":["../src/ProjectMenuClient.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAa3E,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAC5B,aAAa,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC/C,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;
|
|
1
|
+
{"version":3,"file":"ProjectMenuClient.d.ts","sourceRoot":"","sources":["../src/ProjectMenuClient.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAa3E,MAAM,WAAW,sBAAsB;IACrC;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAEhB,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAA;IACpB,MAAM,CAAC,EAAE,iBAAiB,EAAE,CAAA;IAC5B,aAAa,CAAC,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC/C,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAmBD,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,UAAU,EACV,OAAO,EACP,QAAQ,EAAE,YAAY,EACtB,MAAM,EAAE,UAAU,EAClB,aAAa,EAAE,iBAAiB,EAChC,cAAc,EAAE,kBAAkB,EAClC,eAAe,EAAE,mBAAoC,EACrD,eAAe,EAAE,mBAAwB,EACzC,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,IAAmB,EACnB,WAAe,GAChB,EAAE,sBAAsB,2CAscxB"}
|
|
@@ -16,65 +16,86 @@ function parseMultiValue(raw) {
|
|
|
16
16
|
const ACCENT = "oklch(0.78 0.16 85)";
|
|
17
17
|
const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
|
|
18
18
|
const DEFAULT_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
|
19
|
-
|
|
19
|
+
const menuDataCache = new Map();
|
|
20
|
+
export function ProjectMenuClient({ dataUrl, clientSlug, apiBase, projects: projectsProp, schema: schemaProp, filterOptions: filterOptionsProp, filterFieldKey: filterFieldKeyProp, filterFieldName: filterFieldNameProp = "Project Type", fieldOptionsMap: fieldOptionsMapProp = {}, subtitle, basePath, viewAllPath, font = DEFAULT_FONT, maxProjects = 6, }) {
|
|
20
21
|
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
|
|
21
22
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
22
23
|
const [hoveredCard, setHoveredCard] = useState(null);
|
|
23
24
|
const [fetched, setFetched] = useState(null);
|
|
24
|
-
// Self-fetch mode: when clientSlug + apiBase are provided
|
|
25
|
+
// Self-fetch mode: fires when dataUrl OR (clientSlug + apiBase) are provided.
|
|
26
|
+
// Uses a module-level Promise cache so repeated mounts never re-hit the API.
|
|
25
27
|
useEffect(() => {
|
|
26
|
-
|
|
28
|
+
const hasDataUrl = !!dataUrl;
|
|
29
|
+
const hasDirectFetch = !!(clientSlug && apiBase);
|
|
30
|
+
if (!hasDataUrl && !hasDirectFetch)
|
|
27
31
|
return;
|
|
28
32
|
let cancelled = false;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
const cacheKey = dataUrl !== null && dataUrl !== void 0 ? dataUrl : `${clientSlug}:${apiBase}`;
|
|
34
|
+
async function fetchAndCache() {
|
|
35
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
|
|
36
|
+
// dataUrl mode: fetch from local API route (server-cached, no API key exposed)
|
|
37
|
+
if (dataUrl) {
|
|
38
|
+
const res = await fetch(dataUrl);
|
|
39
|
+
const json = res.ok ? await res.json() : {};
|
|
40
|
+
return {
|
|
41
|
+
projects: (_a = json.projects) !== null && _a !== void 0 ? _a : [],
|
|
42
|
+
schema: (_b = json.schema) !== null && _b !== void 0 ? _b : [],
|
|
43
|
+
filterOptions: (_c = json.filterOptions) !== null && _c !== void 0 ? _c : [],
|
|
44
|
+
filterFieldKey: (_d = json.filterFieldKey) !== null && _d !== void 0 ? _d : null,
|
|
45
|
+
filterFieldName: (_e = json.filterFieldName) !== null && _e !== void 0 ? _e : "Project Type",
|
|
46
|
+
fieldOptionsMap: (_f = json.fieldOptionsMap) !== null && _f !== void 0 ? _f : {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Direct fetch mode: fetch from the upstream API directly
|
|
50
|
+
const [projectsRes, fieldsRes] = await Promise.all([
|
|
51
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
|
|
52
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
|
|
53
|
+
]);
|
|
54
|
+
const json = projectsRes.ok ? await projectsRes.json() : {};
|
|
55
|
+
const projects = (_g = json === null || json === void 0 ? void 0 : json.data) !== null && _g !== void 0 ? _g : [];
|
|
56
|
+
const schema = (_j = (_h = json === null || json === void 0 ? void 0 : json.client) === null || _h === void 0 ? void 0 : _h.custom_fields_schema) !== null && _j !== void 0 ? _j : [];
|
|
57
|
+
const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
|
|
58
|
+
const fieldOptionsMap = {};
|
|
59
|
+
for (const field of ((_k = fieldsJson.fields) !== null && _k !== void 0 ? _k : [])) {
|
|
60
|
+
const map = {};
|
|
61
|
+
for (const opt of ((_l = field.options) !== null && _l !== void 0 ? _l : [])) {
|
|
62
|
+
if (typeof opt === "object" && opt.id && opt.label) {
|
|
63
|
+
map[opt.id] = opt.label;
|
|
64
|
+
map[opt.label] = opt.label;
|
|
50
65
|
}
|
|
51
|
-
fieldOptionsMap[field.key] = map;
|
|
52
66
|
}
|
|
53
|
-
|
|
54
|
-
const filterOptions = filterField
|
|
55
|
-
? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
|
|
56
|
-
var _a, _b;
|
|
57
|
-
if (typeof opt === "string")
|
|
58
|
-
return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
|
|
59
|
-
return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
|
|
60
|
-
})
|
|
61
|
-
: [];
|
|
62
|
-
setFetched({
|
|
63
|
-
projects,
|
|
64
|
-
schema,
|
|
65
|
-
filterOptions,
|
|
66
|
-
filterFieldKey: (_h = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _h !== void 0 ? _h : null,
|
|
67
|
-
filterFieldName: (_j = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _j !== void 0 ? _j : "Project Type",
|
|
68
|
-
fieldOptionsMap,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
catch (_k) {
|
|
72
|
-
// silently fail — render nothing
|
|
67
|
+
fieldOptionsMap[field.key] = map;
|
|
73
68
|
}
|
|
69
|
+
const filterField = (_m = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _m !== void 0 ? _m : null;
|
|
70
|
+
const filterOptions = filterField
|
|
71
|
+
? ((_o = filterField.options) !== null && _o !== void 0 ? _o : []).map((opt) => {
|
|
72
|
+
var _a, _b;
|
|
73
|
+
if (typeof opt === "string")
|
|
74
|
+
return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
|
|
75
|
+
return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
|
|
76
|
+
})
|
|
77
|
+
: [];
|
|
78
|
+
return {
|
|
79
|
+
projects,
|
|
80
|
+
schema,
|
|
81
|
+
filterOptions,
|
|
82
|
+
filterFieldKey: (_p = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _p !== void 0 ? _p : null,
|
|
83
|
+
filterFieldName: (_q = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _q !== void 0 ? _q : "Project Type",
|
|
84
|
+
fieldOptionsMap,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Store the Promise on first call — reuse it on every subsequent mount
|
|
88
|
+
if (!menuDataCache.has(cacheKey)) {
|
|
89
|
+
menuDataCache.set(cacheKey, fetchAndCache());
|
|
74
90
|
}
|
|
75
|
-
|
|
91
|
+
menuDataCache.get(cacheKey).then((data) => {
|
|
92
|
+
if (!cancelled)
|
|
93
|
+
setFetched(data);
|
|
94
|
+
}).catch(() => {
|
|
95
|
+
// silently fail — render nothing
|
|
96
|
+
});
|
|
76
97
|
return () => { cancelled = true; };
|
|
77
|
-
}, [clientSlug, apiBase]);
|
|
98
|
+
}, [dataUrl, clientSlug, apiBase]);
|
|
78
99
|
// Resolve data: prefer self-fetched, fall back to props
|
|
79
100
|
const projects = (_b = (_a = fetched === null || fetched === void 0 ? void 0 : fetched.projects) !== null && _a !== void 0 ? _a : projectsProp) !== null && _b !== void 0 ? _b : [];
|
|
80
101
|
const schema = (_d = (_c = fetched === null || fetched === void 0 ? void 0 : fetched.schema) !== null && _c !== void 0 ? _c : schemaProp) !== null && _d !== void 0 ? _d : [];
|
|
@@ -83,7 +104,7 @@ export function ProjectMenuClient({ clientSlug, apiBase, projects: projectsProp,
|
|
|
83
104
|
const filterFieldName = (_j = fetched === null || fetched === void 0 ? void 0 : fetched.filterFieldName) !== null && _j !== void 0 ? _j : filterFieldNameProp;
|
|
84
105
|
const fieldOptionsMap = (_k = fetched === null || fetched === void 0 ? void 0 : fetched.fieldOptionsMap) !== null && _k !== void 0 ? _k : fieldOptionsMapProp;
|
|
85
106
|
// Show a minimal skeleton while self-fetching
|
|
86
|
-
if (clientSlug && apiBase && !fetched) {
|
|
107
|
+
if ((dataUrl || (clientSlug && apiBase)) && !fetched) {
|
|
87
108
|
return (_jsx("div", { style: { padding: "2rem 2.5rem", fontFamily: font, color: "#a1a1aa", fontSize: "14px" }, children: "Loading projects..." }));
|
|
88
109
|
}
|
|
89
110
|
const displayed = projects.slice(0, maxProjects);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ProjectPortfolioClientProps {
|
|
2
|
+
/** Client slug identifying which client's projects to load */
|
|
3
|
+
clientSlug: string;
|
|
4
|
+
/** Base URL of the projects API */
|
|
5
|
+
apiBase: string;
|
|
6
|
+
/** Base path for project detail links. Defaults to "/projects" */
|
|
7
|
+
basePath?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Active filters to apply to the project list.
|
|
10
|
+
* Pass a Record<string, string> of field key/value pairs.
|
|
11
|
+
* Filtering happens in memory — no API call on change.
|
|
12
|
+
* e.g. { type: "commercial" }
|
|
13
|
+
*/
|
|
14
|
+
filters?: Record<string, string>;
|
|
15
|
+
/** Font family string applied to all text. Defaults to system font stack */
|
|
16
|
+
font?: string;
|
|
17
|
+
/** Max columns in the grid. 2 or 3. Defaults to 3 */
|
|
18
|
+
columns?: 2 | 3;
|
|
19
|
+
}
|
|
20
|
+
export declare function ProjectPortfolioClient({ clientSlug, apiBase, basePath, filters, font, columns, }: ProjectPortfolioClientProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
//# sourceMappingURL=ProjectPortfolioClient.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ProjectPortfolioClient.d.ts","sourceRoot":"","sources":["../src/ProjectPortfolioClient.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,2BAA2B;IAC1C,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,4EAA4E;IAC5E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,qDAAqD;IACrD,OAAO,CAAC,EAAE,CAAC,GAAG,CAAC,CAAA;CAChB;AA0DD,wBAAgB,sBAAsB,CAAC,EACrC,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,OAAY,EACZ,IAAmB,EACnB,OAAW,GACZ,EAAE,2BAA2B,2CAsJ7B"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useEffect, useMemo } from "react";
|
|
4
|
+
import { ProjectCard } from "./ProjectCard";
|
|
5
|
+
const portfolioDataCache = new Map();
|
|
6
|
+
const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
|
|
7
|
+
const DEFAULT_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
|
|
8
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
9
|
+
function parseMultiValue(raw) {
|
|
10
|
+
if (Array.isArray(raw))
|
|
11
|
+
return raw.map(String).map((s) => s.replace(/`/g, "").trim()).filter(Boolean);
|
|
12
|
+
if (typeof raw === "string")
|
|
13
|
+
return raw.replace(/`/g, "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
function matchesFilters(project, filters, schema, fieldOptionsMap) {
|
|
17
|
+
return Object.entries(filters).every(([key, value]) => {
|
|
18
|
+
var _a;
|
|
19
|
+
if (!value)
|
|
20
|
+
return true;
|
|
21
|
+
const field = schema.find((f) => f.key === key);
|
|
22
|
+
if (!field)
|
|
23
|
+
return true;
|
|
24
|
+
const raw = project.custom_field_values[key];
|
|
25
|
+
if (field.type === "location") {
|
|
26
|
+
const loc = raw;
|
|
27
|
+
if (!loc)
|
|
28
|
+
return false;
|
|
29
|
+
const locStr = [loc.city, loc.state].filter(Boolean).join(", ").toLowerCase();
|
|
30
|
+
return locStr.includes(value.toLowerCase());
|
|
31
|
+
}
|
|
32
|
+
const values = parseMultiValue(raw);
|
|
33
|
+
const optMap = (_a = fieldOptionsMap[key]) !== null && _a !== void 0 ? _a : {};
|
|
34
|
+
return values.some((v) => {
|
|
35
|
+
var _a;
|
|
36
|
+
const normalizedV = ((_a = optMap[v]) !== null && _a !== void 0 ? _a : v).toLowerCase();
|
|
37
|
+
const normalizedFilter = value.toLowerCase();
|
|
38
|
+
return normalizedV === normalizedFilter || normalizedV.includes(normalizedFilter);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
43
|
+
export function ProjectPortfolioClient({ clientSlug, apiBase, basePath = "/projects", filters = {}, font = DEFAULT_FONT, columns = 3, }) {
|
|
44
|
+
const [data, setData] = useState(null);
|
|
45
|
+
// Self-fetch on mount — uses module-level cache so the API is only called once per page load
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const cacheKey = `${clientSlug}:${apiBase}`;
|
|
48
|
+
async function fetchAndCache() {
|
|
49
|
+
var _a, _b, _c, _d, _e;
|
|
50
|
+
const [projectsRes, fieldsRes] = await Promise.all([
|
|
51
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`),
|
|
52
|
+
fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`),
|
|
53
|
+
]);
|
|
54
|
+
const json = projectsRes.ok ? await projectsRes.json() : {};
|
|
55
|
+
const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
|
|
56
|
+
// Deduplicate schema keys
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
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 : []).filter((f) => {
|
|
59
|
+
if (seen.has(f.key))
|
|
60
|
+
return false;
|
|
61
|
+
seen.add(f.key);
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
|
|
65
|
+
const fieldOptionsMap = {};
|
|
66
|
+
for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
|
|
67
|
+
const map = {};
|
|
68
|
+
for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
|
|
69
|
+
if (typeof opt === "object" && opt.id && opt.label) {
|
|
70
|
+
map[opt.id] = opt.label;
|
|
71
|
+
map[opt.label] = opt.label;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
fieldOptionsMap[field.key] = map;
|
|
75
|
+
}
|
|
76
|
+
return { projects, schema, fieldOptionsMap };
|
|
77
|
+
}
|
|
78
|
+
if (!portfolioDataCache.has(cacheKey)) {
|
|
79
|
+
portfolioDataCache.set(cacheKey, fetchAndCache());
|
|
80
|
+
}
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
portfolioDataCache.get(cacheKey).then((result) => {
|
|
83
|
+
if (!cancelled)
|
|
84
|
+
setData(result);
|
|
85
|
+
}).catch(() => {
|
|
86
|
+
if (!cancelled)
|
|
87
|
+
setData({ projects: [], schema: [], fieldOptionsMap: {} });
|
|
88
|
+
});
|
|
89
|
+
return () => { cancelled = true; };
|
|
90
|
+
}, [clientSlug, apiBase]);
|
|
91
|
+
// Filter projects locally — instant, no API call on filter change
|
|
92
|
+
const filteredProjects = useMemo(() => {
|
|
93
|
+
if (!data)
|
|
94
|
+
return [];
|
|
95
|
+
const hasActiveFilters = Object.values(filters).some(Boolean);
|
|
96
|
+
if (!hasActiveFilters)
|
|
97
|
+
return data.projects;
|
|
98
|
+
return data.projects.filter((p) => matchesFilters(p, filters, data.schema, data.fieldOptionsMap));
|
|
99
|
+
}, [data, filters]);
|
|
100
|
+
const gridCols = columns === 2
|
|
101
|
+
? "repeat(2, 1fr)"
|
|
102
|
+
: "repeat(3, 1fr)";
|
|
103
|
+
// Loading state
|
|
104
|
+
if (!data) {
|
|
105
|
+
return (_jsxs("div", { style: {
|
|
106
|
+
width: "100%",
|
|
107
|
+
maxWidth: "1280px",
|
|
108
|
+
margin: "0 auto",
|
|
109
|
+
padding: "2rem 1rem",
|
|
110
|
+
fontFamily: font,
|
|
111
|
+
}, children: [_jsx("div", { style: { display: "flex", gap: "1rem", marginBottom: "2rem", paddingBottom: "2rem", borderBottom: "1px solid #e4e4e7" }, children: [1, 2].map((i) => (_jsx("div", { style: { width: 180, height: 60, backgroundColor: "#f4f4f5", borderRadius: 6 } }, i))) }), _jsx("div", { style: { display: "grid", gridTemplateColumns: gridCols, gap: "2rem" }, children: Array.from({ length: 6 }).map((_, i) => (_jsx("div", { style: { backgroundColor: "#f4f4f5", borderRadius: 2, height: 320, animation: "pulse 1.5s ease-in-out infinite" } }, i))) }), _jsx("style", { children: `@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.5 } }` })] }));
|
|
112
|
+
}
|
|
113
|
+
return (_jsxs("div", { style: {
|
|
114
|
+
width: "100%",
|
|
115
|
+
maxWidth: "1280px",
|
|
116
|
+
margin: "0 auto",
|
|
117
|
+
padding: "2rem 1rem",
|
|
118
|
+
boxSizing: "border-box",
|
|
119
|
+
fontFamily: font,
|
|
120
|
+
}, children: [filteredProjects.length === 0 && (_jsx("div", { style: { textAlign: "center", padding: "4rem 0" }, children: _jsx("p", { style: { color: "#71717a", fontFamily: font }, children: "No projects found." }) })), filteredProjects.length > 0 && (_jsxs(_Fragment, { children: [_jsx("style", { children: `
|
|
121
|
+
.chisel-portfolio-grid {
|
|
122
|
+
display: grid;
|
|
123
|
+
grid-template-columns: 1fr;
|
|
124
|
+
gap: 1.5rem;
|
|
125
|
+
}
|
|
126
|
+
@media (min-width: 640px) {
|
|
127
|
+
.chisel-portfolio-grid { grid-template-columns: repeat(2, 1fr); }
|
|
128
|
+
}
|
|
129
|
+
@media (min-width: 1024px) {
|
|
130
|
+
.chisel-portfolio-grid { grid-template-columns: ${gridCols}; gap: 2rem; }
|
|
131
|
+
}
|
|
132
|
+
.chisel-project-card-img { height: 180px; }
|
|
133
|
+
@media (min-width: 640px) { .chisel-project-card-img { height: 200px; } }
|
|
134
|
+
@media (min-width: 1024px) { .chisel-project-card-img { height: 220px; } }
|
|
135
|
+
` }), _jsx("div", { className: "chisel-portfolio-grid", children: filteredProjects.map((project, index) => (_jsx(ProjectCard, { project: project, schema: data.schema, fieldOptionsMap: data.fieldOptionsMap, basePath: basePath, priority: index === 0 }, project.id))) })] }))] }));
|
|
136
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { ProjectPortfolio } from "./ProjectPortfolio";
|
|
2
2
|
export type { ProjectPortfolioProps } from "./ProjectPortfolio";
|
|
3
|
+
export { ProjectPortfolioClient } from "./ProjectPortfolioClient";
|
|
4
|
+
export type { ProjectPortfolioClientProps } from "./ProjectPortfolioClient";
|
|
3
5
|
export { ProjectDetail } from "./ProjectDetail";
|
|
4
6
|
export type { ProjectDetailProps } from "./ProjectDetail";
|
|
5
7
|
export { SimilarProjects } from "./SimilarProjects";
|
|
6
8
|
export type { SimilarProjectsProps } from "./SimilarProjects";
|
|
7
9
|
export { GalleryCarousel } from "./GalleryCarousel";
|
|
8
|
-
export { ProjectMenu, fetchProjectMenuData } from "./ProjectMenu";
|
|
10
|
+
export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
|
|
9
11
|
export type { ProjectMenuProps } from "./ProjectMenu";
|
|
10
12
|
export { ProjectMenuClient } from "./ProjectMenuClient";
|
|
11
13
|
export type { ProjectMenuClientProps } from "./ProjectMenuClient";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAA;AACjE,YAAY,EAAE,2BAA2B,EAAE,MAAM,0BAA0B,CAAA;AAC3E,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACpF,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAA;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { ProjectPortfolio } from "./ProjectPortfolio";
|
|
2
|
+
export { ProjectPortfolioClient } from "./ProjectPortfolioClient";
|
|
2
3
|
export { ProjectDetail } from "./ProjectDetail";
|
|
3
4
|
export { SimilarProjects } from "./SimilarProjects";
|
|
4
5
|
export { GalleryCarousel } from "./GalleryCarousel";
|
|
5
|
-
export { ProjectMenu, fetchProjectMenuData } from "./ProjectMenu";
|
|
6
|
+
export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
|
|
6
7
|
export { ProjectMenuClient } from "./ProjectMenuClient";
|
|
7
8
|
export { ProjectCard } from "./ProjectCard";
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-portfolio",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectDetail, ProjectMenu
|
|
5
|
-
"keywords": ["nextjs", "react", "portfolio", "projects", "megamenu", "gallery"],
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
+
"keywords": ["nextjs", "react", "portfolio", "projects", "megamenu", "gallery", "filtering"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./dist/index.js",
|