project-portfolio 1.8.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,15 +147,126 @@ export async function ProjectsMegaMenu() {
147
147
  | `subtitle` | `string` | No | — | Description paragraph shown above the project cards |
148
148
  | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
149
149
  | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
150
- | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
150
+ | `revalidate` | `number` | No | `86400` | Cache revalidation period in seconds (24 hours) |
151
+ | `noCache` | `boolean` | No | `false` | Disable caching entirely — useful for development |
151
152
 
152
153
  The filter options in the right sidebar are driven automatically by the `is_filterable` fields returned from the API schema — no manual configuration needed.
153
154
 
154
155
  ---
155
156
 
157
+ ### `SimilarProjects`
158
+
159
+ A standalone similar projects section. Fetches all projects for a client, filters to the same type as the current project, and renders up to 3 matching cards. Designed to be placed after `ProjectDetail` on a project detail page.
160
+
161
+ ```tsx
162
+ // app/projects/[slug]/page.tsx
163
+ import { ProjectDetail, SimilarProjects } from "project-portfolio"
164
+
165
+ export default async function ProjectPage({ params }: { params: { slug: string } }) {
166
+ return (
167
+ <>
168
+ <ProjectDetail
169
+ slug={params.slug}
170
+ clientSlug="your-client-slug"
171
+ apiBase="https://your-api.com"
172
+ />
173
+ <SimilarProjects
174
+ filters={{ type: "commercial" }}
175
+ excludeSlug={params.slug}
176
+ clientSlug="your-client-slug"
177
+ apiBase="https://your-api.com"
178
+ basePath="/projects"
179
+ />
180
+ </>
181
+ )
182
+ }
183
+ ```
184
+
185
+ `filters` accepts any combination of custom field key/value pairs — all must match (AND logic). `excludeSlug` optionally removes the current project from results.
186
+
187
+ | Prop | Type | Required | Default | Description |
188
+ |---|---|---|---|---|
189
+ | `filters` | `Record<string, string>` | No | `{}` | Key/value pairs to filter projects by custom field values. All filters must match (AND logic) |
190
+ | `excludeSlug` | `string` | No | — | Slug of a project to exclude from results (e.g. the current project) |
191
+ | `clientSlug` | `string` | Yes | — | Identifies which client's projects to load |
192
+ | `apiBase` | `string` | Yes | — | Base URL of the projects API |
193
+ | `basePath` | `string` | No | `"/projects"` | Base path for project detail links |
194
+ | `maxItems` | `number` | No | `3` | Maximum number of projects to show |
195
+ | `font` | `string` | No | System font stack | Font family string applied to all inline styles |
196
+ | `revalidate` | `number` | No | `60` | Cache revalidation period in seconds |
197
+
198
+ ---
199
+
200
+ ### `ProjectMenuClient` + `createMenuHandler`
201
+
202
+ `ProjectMenuClient` is a `"use client"` component that works in any React tree — no RSC, no `Suspense` wrapper, no server file needed. It fetches its own data and caches it in memory for the lifetime of the page session so the API is never called twice on a hover/re-hover.
203
+
204
+ There are two ways to use it:
205
+
206
+ ---
207
+
208
+ **Option 1 — `dataUrl` + `createMenuHandler` (recommended for production)**
209
+
210
+ Set up one API route once. The data is server-cached for 24 hours — most users never trigger a call to the upstream API at all.
211
+
212
+ ```ts
213
+ // app/api/chisel-menu/route.ts
214
+ import { createMenuHandler } from "project-portfolio"
215
+
216
+ export const GET = createMenuHandler({
217
+ clientSlug: "your-client-slug",
218
+ apiBase: "https://your-api.com",
219
+ })
220
+ ```
221
+
222
+ ```tsx
223
+ // In your nav/header — works in any "use client" component
224
+ import { ProjectMenuClient } from "project-portfolio"
225
+
226
+ <ProjectMenuClient
227
+ dataUrl="/api/chisel-menu"
228
+ basePath="/projects"
229
+ viewAllPath="/projects"
230
+ />
231
+ ```
232
+
233
+ ---
234
+
235
+ **Option 2 — Direct fetch (quick setup / non-Next.js environments)**
236
+
237
+ No API route needed. The component fetches directly from the upstream API on first mount and caches the result in memory for the session.
238
+
239
+ ```tsx
240
+ import { ProjectMenuClient } from "project-portfolio"
241
+
242
+ <ProjectMenuClient
243
+ clientSlug="your-client-slug"
244
+ apiBase="https://your-api.com"
245
+ basePath="/projects"
246
+ viewAllPath="/projects"
247
+ />
248
+ ```
249
+
250
+ ---
251
+
252
+ | Prop | Type | Required | Default | Description |
253
+ |---|---|---|---|---|
254
+ | `dataUrl` | `string` | No | — | URL of a local API route created with `createMenuHandler()`. Recommended for production |
255
+ | `clientSlug` | `string` | No* | — | Client slug for direct fetch mode |
256
+ | `apiBase` | `string` | No* | — | API base URL for direct fetch mode |
257
+ | `basePath` | `string` | Yes | — | Base path for project detail links |
258
+ | `viewAllPath` | `string` | Yes | — | Path for the "View All Projects" link |
259
+ | `subtitle` | `string` | No | — | Description shown above the project cards |
260
+ | `font` | `string` | No | System font stack | Font family string |
261
+ | `maxProjects` | `number` | No | `6` | Maximum number of projects to display |
262
+
263
+ *One of `dataUrl` or `clientSlug + apiBase` must be provided. Pre-fetched `projects`, `schema`, `filterOptions`, `filterFieldKey`, and `fieldOptionsMap` can also be passed directly for SSR usage.
264
+
265
+ ---
266
+
156
267
  ## Important: Server Component Usage
157
268
 
158
- All three components (`ProjectPortfolio`, `ProjectDetail`, `ProjectMenu`) are **async Server Components**. They must be rendered in a server context:
269
+ All top-level components (`ProjectPortfolio`, `ProjectDetail`, `ProjectMenu`, `SimilarProjects`) are **async Server Components**. They must be rendered in a server context:
159
270
 
160
271
  ```tsx
161
272
  // CORRECT
@@ -176,10 +287,33 @@ If a parent component uses `"use client"`, these components cannot be rendered i
176
287
 
177
288
  ## Caching
178
289
 
179
- Data is cached at two levels:
290
+ Data is cached at multiple levels depending on which component is used.
291
+
292
+ **Server Components (`ProjectMenu`, `ProjectPortfolio`, `ProjectDetail`, `SimilarProjects`)**
293
+ 1. **Per-render** — `React.cache()` deduplicates duplicate calls within the same render pass
294
+ 2. **Cross-request** — `next: { revalidate: 86400 }` (24 hours) caches responses in Next.js's Data Cache across all users and sessions
295
+
296
+ **`ProjectMenuClient` with `createMenuHandler` (recommended)**
297
+ 3. **Server-side route cache** — the `/api/chisel-menu` route is cached by Next.js for 24 hours. The upstream API is called at most once per day regardless of traffic
298
+ 4. **Module-level client cache** — after the first fetch within a session, the result is held in memory. Re-hovering the menu or remounting the component never triggers another network request
299
+
300
+ **`ProjectMenuClient` with direct fetch**
301
+ 4. **Module-level client cache only** — the upstream API is called once per session per user (on first mount), then cached in memory for the rest of that page session
180
302
 
181
- 1. **Per-render** `React.cache()` deduplicates any duplicate calls within the same render pass
182
- 2. **Cross-request** — `next: { revalidate: N }` caches responses in Next.js's built-in Data Cache
303
+ **Bypassing the cache**
304
+
305
+ | Method | Scope | Use case |
306
+ |---|---|---|
307
+ | `?bust=1` on `/api/chisel-menu` | Single request | Dev/testing after a CMS change |
308
+ | `CHISEL_CACHE_BYPASS=true` env var | Entire deployment | Staging environments |
309
+ | `revalidateTag("chisel-menu-{clientSlug}")` | Server cache only | CMS webhook on content publish |
310
+ | `noCache: true` on `ProjectMenu` | Single render | Debug during development |
311
+
312
+ ```ts
313
+ // CMS webhook example — invalidate server cache immediately on publish
314
+ import { revalidateTag } from "next/cache"
315
+ revalidateTag("chisel-menu-your-client-slug")
316
+ ```
183
317
 
184
318
  No third-party caching libraries are required.
185
319
 
@@ -9,12 +9,6 @@ export interface ProjectDetailProps {
9
9
  backPath?: string;
10
10
  /** Label for the "back" link. Defaults to "All Projects" */
11
11
  backLabel?: string;
12
- /**
13
- * Base path used to build individual project detail URLs in similar project cards.
14
- * Defaults to backPath if not provided, then "/projects".
15
- * e.g. if backPath="/" set projectBasePath="/projects"
16
- */
17
- projectBasePath?: string;
18
12
  /**
19
13
  * Seconds to cache via Next.js Data Cache on production deployments.
20
14
  * React.cache() always deduplicates within a single render in all environments.
@@ -22,5 +16,5 @@ export interface ProjectDetailProps {
22
16
  */
23
17
  revalidate?: number;
24
18
  }
25
- export declare function ProjectDetail({ slug, clientSlug, apiBase, backPath, backLabel, projectBasePath, revalidate, }: ProjectDetailProps): Promise<import("react/jsx-runtime").JSX.Element>;
19
+ export declare function ProjectDetail({ slug, clientSlug, apiBase, backPath, backLabel, revalidate, }: ProjectDetailProps): Promise<import("react/jsx-runtime").JSX.Element>;
26
20
  //# sourceMappingURL=ProjectDetail.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectDetail.d.ts","sourceRoot":"","sources":["../src/ProjectDetail.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,kBAAkB;IACjC,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,iEAAiE;IACjE,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAkLD,wBAAsB,aAAa,CAAC,EAClC,IAAI,EACJ,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,SAA0B,EAC1B,eAAe,EACf,UAAe,GAChB,EAAE,kBAAkB,oDA0MpB"}
1
+ {"version":3,"file":"ProjectDetail.d.ts","sourceRoot":"","sources":["../src/ProjectDetail.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,kBAAkB;IACjC,+BAA+B;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,iEAAiE;IACjE,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAmDD,wBAAsB,aAAa,CAAC,EAClC,IAAI,EACJ,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,SAA0B,EAC1B,UAAe,GAChB,EAAE,kBAAkB,oDAyKpB"}
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cache } from "react";
3
+ // LocationValue is used in locationValue cast below
3
4
  import { GalleryCarousel } from "./GalleryCarousel";
4
5
  // ─── Helpers ────────────────────────────────────────────────────────────────
5
6
  function parseMultiValue(raw) {
@@ -20,76 +21,27 @@ function dedupeByKey(arr) {
20
21
  }
21
22
  // ─── Data fetching ───────────────────────────────────────────────────────────
22
23
  const fetchProjectDetail = cache(async (apiBase, clientSlug, slug, revalidate) => {
23
- var _a, _b, _c, _d;
24
+ var _a, _b, _c;
24
25
  const fetchOpts = revalidate > 0
25
26
  ? { next: { revalidate } }
26
27
  : {};
27
28
  try {
28
- const [projectRes, allProjectsRes] = await Promise.all([
29
- fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR`, fetchOpts),
30
- fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR`, fetchOpts),
31
- ]);
32
- // Per API docs, single project response is { client: {..., custom_fields_schema}, data: {...} }
29
+ const projectRes = await fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects/${slug}?api_key=pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR`, fetchOpts);
33
30
  const projectJson = projectRes.ok ? await projectRes.json() : null;
34
31
  const project = (_a = projectJson === null || projectJson === void 0 ? void 0 : projectJson.data) !== null && _a !== void 0 ? _a : null;
35
32
  const schema = dedupeByKey((_c = (_b = projectJson === null || projectJson === void 0 ? void 0 : projectJson.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : []);
36
- // All projects response is { client: {...}, data: [...] }
37
- const allProjectsJson = allProjectsRes.ok ? await allProjectsRes.json() : null;
38
- const allProjects = (_d = allProjectsJson === null || allProjectsJson === void 0 ? void 0 : allProjectsJson.data) !== null && _d !== void 0 ? _d : [];
39
- return { project, schema, allProjects };
33
+ return { project, schema };
40
34
  }
41
- catch (_e) {
42
- return { project: null, schema: [], allProjects: [] };
35
+ catch (_d) {
36
+ return { project: null, schema: [] };
43
37
  }
44
38
  });
45
- // ─── Similar Projects sub-component ─────────────────────────────────────────
46
- function SimilarProjects({ allProjects, currentSlug, badgeValue, badgeField, locationField, basePath, font, }) {
47
- const similar = allProjects
48
- .filter((p) => {
49
- var _a;
50
- if (p.slug === currentSlug)
51
- return false;
52
- if (!badgeValue || !badgeField)
53
- return true;
54
- const val = (_a = parseMultiValue(p.custom_field_values[badgeField.key])[0]) !== null && _a !== void 0 ? _a : null;
55
- return val === badgeValue;
56
- })
57
- .slice(0, 3);
58
- if (similar.length === 0)
59
- return null;
60
- return (_jsxs("section", { style: { borderTop: "1px solid #e4e4e7", paddingTop: "3rem" }, children: [_jsxs("div", { style: { display: "flex", alignItems: "flex-end", justifyContent: "space-between", marginBottom: "2rem" }, children: [_jsxs("div", { children: [_jsx("p", { style: { fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", color: "#f18a00", margin: "0 0 6px 0" }, children: "More Work" }), _jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "28px", margin: 0, fontFamily: font }, children: "Similar Projects" })] }), _jsxs("a", { href: basePath, style: { color: "#18181b", fontWeight: 600, fontSize: "15px", textDecoration: "none", display: "flex", alignItems: "center", gap: "6px", fontFamily: font }, children: ["View All", _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M9 18l6-6-6-6" }) })] })] }), _jsx("div", { className: "chisel-similar-grid", children: similar.map((p) => {
61
- var _a, _b, _c, _d, _e;
62
- const imgUrl = (_d = (_a = p.image_url) !== null && _a !== void 0 ? _a : (_c = (_b = p.media) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.url) !== null && _d !== void 0 ? _d : null;
63
- const badge = badgeField
64
- ? ((_e = parseMultiValue(p.custom_field_values[badgeField.key])[0]) !== null && _e !== void 0 ? _e : null)
65
- : null;
66
- const loc = locationField
67
- ? p.custom_field_values[locationField.key]
68
- : null;
69
- const locStr = loc ? [loc.city, loc.state].filter(Boolean).join(", ") : null;
70
- return (_jsxs("a", { href: `${basePath}/${p.slug}`, style: { textDecoration: "none", color: "inherit", display: "block", borderBottom: "1px solid #e4e4e7", fontFamily: font }, children: [_jsxs("div", { style: { position: "relative", width: "100%", height: "220px", overflow: "hidden", backgroundColor: "#f4f4f5", marginBottom: "1rem" }, children: [imgUrl && (_jsx("img", { src: imgUrl, alt: p.title, style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })), badge && (_jsx("span", { style: {
71
- position: "absolute",
72
- top: "12px",
73
- left: "12px",
74
- backgroundColor: "#f18a00",
75
- color: "#fff",
76
- fontSize: "10px",
77
- fontWeight: 700,
78
- textTransform: "uppercase",
79
- letterSpacing: "0.1em",
80
- padding: "4px 10px",
81
- }, children: badge }))] }), _jsxs("div", { style: { paddingBottom: "1.25rem" }, children: [_jsx("h3", { style: { color: "#18181b", fontWeight: 700, fontSize: "17px", lineHeight: 1.3, margin: "0 0 8px 0", fontFamily: font }, children: p.title }), locStr && (_jsxs("p", { style: { display: "flex", alignItems: "center", gap: "5px", color: "#71717a", fontSize: "14px", margin: 0 }, children: [_jsxs("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 }, children: [_jsx("path", { d: "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" }), _jsx("circle", { cx: "12", cy: "10", r: "3" })] }), locStr] }))] })] }, p.id));
82
- }) })] }));
83
- }
84
39
  // ─── Component ───────────────────────────────────────────────────────────────
85
- export async function ProjectDetail({ slug, clientSlug, apiBase, backPath = "/projects", backLabel = "All Projects", projectBasePath, revalidate = 60, }) {
40
+ export async function ProjectDetail({ slug, clientSlug, apiBase, backPath = "/projects", backLabel = "All Projects", revalidate = 60, }) {
86
41
  var _a, _b, _c, _d, _e, _f, _g, _h, _j;
87
- // projectBasePath is for individual project card links in the similar projects section.
88
- // Falls back to backPath so existing integrations work without any changes.
89
- const resolvedProjectBasePath = projectBasePath !== null && projectBasePath !== void 0 ? projectBasePath : backPath;
90
- const { project, schema, allProjects } = await fetchProjectDetail(apiBase, clientSlug, slug, revalidate);
42
+ const { project, schema } = await fetchProjectDetail(apiBase, clientSlug, slug, revalidate);
91
43
  if (!project) {
92
- return (_jsxs("div", { style: { textAlign: "center", padding: "6rem 1.5rem", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }, children: [_jsx("p", { style: { color: "#71717a", fontSize: "18px" }, children: "Project not found." }), _jsxs("a", { href: backPath, style: { color: "#f18a00", fontWeight: 600, fontSize: "14px", textDecoration: "none", marginTop: "1rem", display: "inline-block" }, children: ["\u2190 ", backLabel] })] }));
44
+ return (_jsx("div", { style: { textAlign: "center", padding: "6rem 1.5rem", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }, children: _jsx("p", { style: { color: "#71717a", fontSize: "18px" }, children: "Project not found." }) }));
93
45
  }
94
46
  const imageUrl = (_d = (_a = project.image_url) !== null && _a !== void 0 ? _a : (_c = (_b = project.media) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.url) !== null && _d !== void 0 ? _d : null;
95
47
  const galleryImages = project.image_url
@@ -131,12 +83,9 @@ export async function ProjectDetail({ slug, clientSlug, apiBase, backPath = "/pr
131
83
  return isNaN(n) ? String(raw) : String(Math.round(n));
132
84
  })();
133
85
  const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
134
- return (_jsxs("main", { style: { minHeight: "100vh", backgroundColor: "#fff", fontFamily: font }, children: [_jsxs("section", { className: "chisel-hero-img", style: { position: "relative", width: "100%", overflow: "hidden" }, children: [imageUrl ? (_jsx("img", { src: imageUrl, alt: project.title, style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })) : (_jsx("div", { style: { position: "absolute", inset: 0, backgroundColor: "#27272a" } })), _jsx("div", { style: { position: "absolute", inset: 0, background: "linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.38) 50%, rgba(0,0,0,0.12) 100%)" } }), _jsx("div", { style: { position: "absolute", top: "1.5rem", left: 0, right: 0, padding: "0 1.5rem" }, children: _jsxs("a", { href: backPath, style: { display: "inline-flex", alignItems: "center", gap: "6px", color: "rgba(255,255,255,0.7)", fontSize: "14px", textDecoration: "none", fontFamily: font }, children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M19 12H5M12 5l-7 7 7 7" }) }), backLabel] }) }), _jsxs("div", { style: { position: "absolute", bottom: 0, left: 0, right: 0, padding: "0 1.5rem 2.5rem", maxWidth: "900px" }, children: [badgeValue && (_jsx("p", { style: { color: "#f18a00", fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", margin: "0 0 10px 0" }, children: badgeValue })), _jsx("h1", { style: { color: "#fff", fontWeight: 700, fontSize: "clamp(28px, 4vw, 52px)", lineHeight: 1.1, margin: "0 0 12px 0", fontFamily: font }, children: project.title }), locationString && (_jsxs("p", { style: { display: "flex", alignItems: "center", gap: "6px", color: "rgba(255,255,255,0.75)", fontSize: "15px", margin: 0 }, children: [_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 }, children: [_jsx("path", { d: "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" }), _jsx("circle", { cx: "12", cy: "10", r: "3" })] }), locationString] }))] })] }), _jsxs("section", { style: { borderBottom: "1px solid #e4e4e7", backgroundColor: "#fff" }, children: [_jsx("style", { children: `
86
+ return (_jsxs("main", { style: { minHeight: "100vh", backgroundColor: "#fff", fontFamily: font }, children: [_jsxs("section", { className: "chisel-hero-img", style: { position: "relative", width: "100%", overflow: "hidden" }, children: [imageUrl ? (_jsx("img", { src: imageUrl, alt: project.title, style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })) : (_jsx("div", { style: { position: "absolute", inset: 0, backgroundColor: "#27272a" } })), _jsx("div", { style: { position: "absolute", inset: 0, background: "linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.38) 50%, rgba(0,0,0,0.12) 100%)" } }), _jsxs("div", { style: { position: "absolute", bottom: 0, left: 0, right: 0, padding: "0 1.5rem 2.5rem", maxWidth: "900px" }, children: [badgeValue && (_jsx("p", { style: { color: "#f18a00", fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", margin: "0 0 10px 0" }, children: badgeValue })), _jsx("h1", { style: { color: "#fff", fontWeight: 700, fontSize: "clamp(28px, 4vw, 52px)", lineHeight: 1.1, margin: "0 0 12px 0", fontFamily: font }, children: project.title }), locationString && (_jsxs("p", { style: { display: "flex", alignItems: "center", gap: "6px", color: "rgba(255,255,255,0.75)", fontSize: "15px", margin: 0 }, children: [_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 }, children: [_jsx("path", { d: "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" }), _jsx("circle", { cx: "12", cy: "10", r: "3" })] }), locationString] }))] })] }), _jsxs("section", { style: { borderBottom: "1px solid #e4e4e7", backgroundColor: "#fff" }, children: [_jsx("style", { children: `
135
87
  .chisel-stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }
136
88
  @media (min-width: 768px) { .chisel-stats-grid { grid-template-columns: repeat(4, 1fr); gap: 2rem; } }
137
- .chisel-similar-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
138
- @media (min-width: 640px) { .chisel-similar-grid { grid-template-columns: repeat(2, 1fr); } }
139
- @media (min-width: 1024px) { .chisel-similar-grid { grid-template-columns: repeat(3, 1fr); } }
140
89
  .chisel-gallery-placeholder { display: grid; grid-template-columns: 1fr; gap: 12px; }
141
90
  @media (min-width: 640px) { .chisel-gallery-placeholder { grid-template-columns: repeat(3, 1fr); } }
142
91
  .chisel-hero-img { height: 50vw; min-height: 220px; max-height: 560px; }
@@ -146,7 +95,7 @@ export async function ProjectDetail({ slug, clientSlug, apiBase, backPath = "/pr
146
95
  { label: "Project Type", value: badgeValue !== null && badgeValue !== void 0 ? badgeValue : "—" },
147
96
  { label: "Sq. Footage", value: coverageFormatted !== null && coverageFormatted !== void 0 ? coverageFormatted : "—" },
148
97
  { label: "Completed", value: yearCompleted !== null && yearCompleted !== void 0 ? yearCompleted : "—" },
149
- ].map(({ label, value }) => (_jsxs("div", { children: [_jsx("p", { style: { fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.1em", color: "#a1a1aa", margin: "0 0 6px 0" }, children: label }), _jsx("p", { style: { color: "#18181b", fontWeight: 600, fontSize: "15px", margin: 0 }, children: value })] }, label))) })] }), _jsxs("article", { style: { maxWidth: "1280px", margin: "0 auto", padding: "2rem 1rem", boxSizing: "border-box", display: "flex", flexDirection: "column", gap: "2.5rem" }, children: [(project.blurb || project.description) && (_jsxs("section", { style: { maxWidth: "720px" }, children: [_jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "22px", margin: "0 0 16px 0", fontFamily: font }, children: "Project Overview" }), project.blurb && (_jsx("p", { style: { color: "#3f3f46", fontSize: "16px", lineHeight: 1.7, margin: "0 0 16px 0" }, children: project.blurb })), project.description && project.description !== project.blurb && (_jsx("p", { style: { color: "#3f3f46", fontSize: "16px", lineHeight: 1.7, margin: 0 }, children: project.description }))] })), _jsxs("section", { children: [_jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "22px", margin: "0 0 1.5rem 0", fontFamily: font }, children: "Project Gallery" }), galleryImages.length > 0 ? (_jsx(GalleryCarousel, { images: galleryImages, projectTitle: project.title })) : (
98
+ ].map(({ label, value }) => (_jsxs("div", { children: [_jsx("p", { style: { fontSize: "10px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.1em", color: "#a1a1aa", margin: "0 0 6px 0" }, children: label }), _jsx("p", { style: { color: "#18181b", fontWeight: 600, fontSize: "15px", margin: 0 }, children: value })] }, label))) })] }), _jsxs("article", { style: { maxWidth: "1280px", margin: "0 auto", padding: "2rem 1rem", boxSizing: "border-box", display: "flex", flexDirection: "column", gap: "2.5rem" }, children: [(project.blurb || project.description) && (_jsxs("section", { style: { maxWidth: "900px" }, children: [_jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "22px", margin: "0 0 16px 0", fontFamily: font }, children: "Project Overview" }), project.blurb && (_jsx("p", { style: { color: "#3f3f46", fontSize: "16px", lineHeight: 1.7, margin: "0 0 16px 0" }, children: project.blurb })), project.description && project.description !== project.blurb && (_jsx("p", { style: { color: "#3f3f46", fontSize: "16px", lineHeight: 1.7, margin: 0 }, children: project.description }))] })), _jsxs("section", { children: [_jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "22px", margin: "0 0 1.5rem 0", fontFamily: font }, children: "Project Gallery" }), galleryImages.length > 0 ? (_jsx(GalleryCarousel, { images: galleryImages, projectTitle: project.title })) : (
150
99
  /* Placeholder */
151
- _jsx("div", { className: "chisel-gallery-placeholder", children: [0, 1, 2].map((i) => (_jsxs("div", { style: { aspectRatio: "4/3", borderRadius: "4px", backgroundColor: "#f9f9f9", border: "1px solid #e4e4e7", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px" }, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#a1a1aa", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 19.5h16.5" }) }), _jsx("p", { style: { color: "#a1a1aa", fontSize: "12px", margin: 0 }, children: "Photos coming soon" })] }, i))) }))] }), _jsx(SimilarProjects, { allProjects: allProjects, currentSlug: slug, badgeValue: badgeValue, badgeField: badgeField !== null && badgeField !== void 0 ? badgeField : null, locationField: locationField !== null && locationField !== void 0 ? locationField : null, basePath: resolvedProjectBasePath, font: font })] })] }));
100
+ _jsx("div", { className: "chisel-gallery-placeholder", children: [0, 1, 2].map((i) => (_jsxs("div", { style: { aspectRatio: "4/3", borderRadius: "4px", backgroundColor: "#f9f9f9", border: "1px solid #e4e4e7", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "8px" }, children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "#a1a1aa", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 19.5h16.5" }) }), _jsx("p", { style: { color: "#a1a1aa", fontSize: "12px", margin: 0 }, children: "Photos coming soon" })] }, i))) }))] })] })] }));
152
101
  }
@@ -1,3 +1,4 @@
1
+ import type { Project, CustomFieldSchema } from "./types";
1
2
  export interface ProjectMenuProps {
2
3
  /** Client slug used to fetch projects */
3
4
  clientSlug: string;
@@ -13,7 +14,7 @@ export interface ProjectMenuProps {
13
14
  font?: string;
14
15
  /** Max number of projects to show at once. Defaults to 6 */
15
16
  maxProjects?: number;
16
- /** Revalidation period in seconds. Defaults to 60 */
17
+ /** Revalidation period in seconds. Defaults to 86400 (24 hours) */
17
18
  revalidate?: number;
18
19
  /**
19
20
  * Disable all caching. Every request hits the API fresh.
@@ -21,5 +22,50 @@ export interface ProjectMenuProps {
21
22
  */
22
23
  noCache?: boolean;
23
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>;
54
+ export declare function fetchProjectMenuData({ apiBase, clientSlug, revalidate, noCache, }: {
55
+ apiBase: string;
56
+ clientSlug: string;
57
+ revalidate?: number;
58
+ noCache?: boolean;
59
+ }): Promise<{
60
+ projects: Project[];
61
+ schema: CustomFieldSchema[];
62
+ filterOptions: {
63
+ id: string;
64
+ label: string;
65
+ }[];
66
+ filterFieldKey: string | null;
67
+ filterFieldName: string;
68
+ fieldOptionsMap: Record<string, Record<string, string>>;
69
+ }>;
24
70
  export declare function ProjectMenu({ clientSlug, apiBase, basePath, viewAllPath, subtitle, font, maxProjects, revalidate, noCache, }: ProjectMenuProps): Promise<import("react/jsx-runtime").JSX.Element>;
25
71
  //# sourceMappingURL=ProjectMenu.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectMenu.d.ts","sourceRoot":"","sources":["../src/ProjectMenu.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,qDAAqD;IACrD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AA6CD,wBAAsB,WAAW,CAAC,EAChC,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,WAAW,EACX,QAAQ,EACR,IAA0E,EAC1E,WAAe,EACf,UAAe,EACf,OAAe,GAChB,EAAE,gBAAgB,oDAiClB"}
1
+ {"version":3,"file":"ProjectMenu.d.ts","sourceRoot":"","sources":["../src/ProjectMenu.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAA;AAKzD,MAAM,WAAW,gBAAgB;IAC/B,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,wDAAwD;IACxD,OAAO,EAAE,MAAM,CAAA;IACf,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,yDAAyD;IACzD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,UAAU,EACV,OAAO,EACP,UAAkB,GACnB,EAAE;IACD,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,aACoC,OAAO,uBAsD3C;AAED,wBAAsB,oBAAoB,CAAC,EACzC,OAAO,EACP,UAAU,EACV,UAAkB,EAClB,OAAe,GAChB,EAAE;IACD,OAAO,EAAE,MAAM,CAAA;IACf,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,MAAM,EAAE,iBAAiB,EAAE,CAAA;IAC3B,aAAa,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IAC9C,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,MAAM,CAAA;IACvB,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACxD,CAAC,CAqBD;AA6CD,wBAAsB,WAAW,CAAC,EAChC,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,WAAW,EACX,QAAQ,EACR,IAA0E,EAC1E,WAAe,EACf,UAAkB,EAClB,OAAe,GAChB,EAAE,gBAAgB,oDAiClB"}
@@ -2,7 +2,104 @@ 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
- const fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = false) => {
5
+ /**
6
+ * createMenuHandler
7
+ *
8
+ * Creates a Next.js route handler that fetches and server-caches the menu data.
9
+ * Drop it into app/api/chisel-menu/route.ts — one line of setup.
10
+ *
11
+ * Features:
12
+ * - Cached for 24 hours by default (override with revalidate option)
13
+ * - Add ?bust=1 to bypass the cache for a single request (dev/testing)
14
+ * - Set CHISEL_CACHE_BYPASS=true in env to disable caching for the whole deployment (staging)
15
+ * - Fetch is tagged with "chisel-menu-{clientSlug}" for revalidateTag() support
16
+ *
17
+ * @example
18
+ * // app/api/chisel-menu/route.ts
19
+ * import { createMenuHandler } from "project-portfolio"
20
+ * export const GET = createMenuHandler({ clientSlug: "my-client", apiBase: "https://nexus.chiselandco.com" })
21
+ *
22
+ * // Bust the cache manually (one request):
23
+ * fetch("/api/chisel-menu?bust=1")
24
+ *
25
+ * // Invalidate from a CMS webhook:
26
+ * import { revalidateTag } from "next/cache"
27
+ * revalidateTag("chisel-menu-my-client")
28
+ */
29
+ export function createMenuHandler({ clientSlug, apiBase, revalidate = 86400, }) {
30
+ return async function GET(request) {
31
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
32
+ const bypass = process.env.CHISEL_CACHE_BYPASS === "true" ||
33
+ new URL(request.url).searchParams.has("bust");
34
+ const fetchOpts = bypass
35
+ ? { cache: "no-store" }
36
+ : { next: { revalidate, tags: [`chisel-menu-${clientSlug}`] } };
37
+ try {
38
+ const [projectsRes, fieldsRes] = await Promise.all([
39
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=${API_KEY}`, fetchOpts),
40
+ fetch(`${apiBase}/api/v1/clients/${clientSlug}/fields?api_key=${API_KEY}`, fetchOpts),
41
+ ]);
42
+ const json = projectsRes.ok ? await projectsRes.json() : {};
43
+ const projects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
44
+ const schema = (_c = (_b = json === null || json === void 0 ? void 0 : json.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : [];
45
+ const fieldsJson = fieldsRes.ok ? await fieldsRes.json() : { fields: [] };
46
+ const fieldOptionsMap = {};
47
+ for (const field of ((_d = fieldsJson.fields) !== null && _d !== void 0 ? _d : [])) {
48
+ const map = {};
49
+ for (const opt of ((_e = field.options) !== null && _e !== void 0 ? _e : [])) {
50
+ if (typeof opt === "object" && opt.id && opt.label) {
51
+ map[opt.id] = opt.label;
52
+ map[opt.label] = opt.label;
53
+ }
54
+ }
55
+ fieldOptionsMap[field.key] = map;
56
+ }
57
+ const filterField = (_f = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _f !== void 0 ? _f : null;
58
+ const filterOptions = filterField
59
+ ? ((_g = filterField.options) !== null && _g !== void 0 ? _g : []).map((opt) => {
60
+ var _a, _b;
61
+ if (typeof opt === "string")
62
+ return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
63
+ return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : "", label: (_b = opt.label) !== null && _b !== void 0 ? _b : "" };
64
+ })
65
+ : [];
66
+ return Response.json({
67
+ projects,
68
+ schema,
69
+ filterOptions,
70
+ filterFieldKey: (_h = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _h !== void 0 ? _h : null,
71
+ filterFieldName: (_j = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _j !== void 0 ? _j : "Project Type",
72
+ fieldOptionsMap,
73
+ });
74
+ }
75
+ catch (_k) {
76
+ return Response.json({ projects: [], schema: [], filterOptions: [], filterFieldKey: null, filterFieldName: "Project Type", fieldOptionsMap: {} }, { status: 500 });
77
+ }
78
+ };
79
+ }
80
+ export async function fetchProjectMenuData({ apiBase, clientSlug, revalidate = 86400, noCache = false, }) {
81
+ var _a, _b, _c, _d;
82
+ const raw = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
83
+ const filterField = (_a = raw.schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
84
+ const filterOptions = filterField
85
+ ? ((_b = filterField.options) !== null && _b !== void 0 ? _b : []).map((opt) => {
86
+ var _a, _b, _c;
87
+ if (typeof opt === "string") {
88
+ return { id: opt.toLowerCase().replace(/\s+/g, "-"), label: opt };
89
+ }
90
+ return { id: (_a = opt.id) !== null && _a !== void 0 ? _a : String((_b = opt.label) !== null && _b !== void 0 ? _b : "").toLowerCase().replace(/\s+/g, "-"), label: (_c = opt.label) !== null && _c !== void 0 ? _c : "" };
91
+ })
92
+ : [];
93
+ return {
94
+ projects: raw.projects,
95
+ schema: raw.schema,
96
+ filterOptions,
97
+ filterFieldKey: (_c = filterField === null || filterField === void 0 ? void 0 : filterField.key) !== null && _c !== void 0 ? _c : null,
98
+ filterFieldName: (_d = filterField === null || filterField === void 0 ? void 0 : filterField.name) !== null && _d !== void 0 ? _d : "Project Type",
99
+ fieldOptionsMap: raw.fieldOptionsMap,
100
+ };
101
+ }
102
+ const _fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = false) => {
6
103
  var _a, _b, _c, _d, _e;
7
104
  const fetchOpts = noCache
8
105
  ? { cache: "no-store" }
@@ -36,9 +133,9 @@ const fetchMenuData = cache(async (apiBase, clientSlug, revalidate, noCache = fa
36
133
  return { projects: [], schema: [], fieldOptionsMap: {} };
37
134
  }
38
135
  });
39
- export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 60, noCache = false, }) {
136
+ export async function ProjectMenu({ clientSlug, apiBase, basePath = "/projects", viewAllPath, subtitle, font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", maxProjects = 6, revalidate = 86400, noCache = false, }) {
40
137
  var _a, _b, _c, _d;
41
- const { projects, schema, fieldOptionsMap } = await fetchMenuData(apiBase, clientSlug, revalidate, noCache);
138
+ const { projects, schema, fieldOptionsMap } = await _fetchMenuData(apiBase, clientSlug, revalidate, noCache);
42
139
  // Find the filterable select field (badge_overlay is our category field)
43
140
  const filterField = (_a = schema.find((f) => f.is_filterable && (f.type === "select" || f.type === "multi-select"))) !== null && _a !== void 0 ? _a : null;
44
141
  // Build filter option list from schema
@@ -1,20 +1,33 @@
1
1
  import type { Project, CustomFieldSchema } from "./types";
2
- interface ProjectMenuClientProps {
3
- projects: Project[];
4
- schema: CustomFieldSchema[];
5
- filterOptions: {
2
+ export interface ProjectMenuClientProps {
3
+ /**
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.
14
+ */
15
+ clientSlug?: string;
16
+ apiBase?: string;
17
+ projects?: Project[];
18
+ schema?: CustomFieldSchema[];
19
+ filterOptions?: {
6
20
  id: string;
7
21
  label: string;
8
22
  }[];
9
- filterFieldKey: string | null;
23
+ filterFieldKey?: string | null;
10
24
  filterFieldName?: string;
11
25
  fieldOptionsMap?: Record<string, Record<string, string>>;
12
26
  subtitle?: string;
13
27
  basePath: string;
14
28
  viewAllPath: string;
15
- font: string;
29
+ font?: string;
16
30
  maxProjects?: number;
17
31
  }
18
- export declare function ProjectMenuClient({ projects, schema, filterOptions, filterFieldKey, filterFieldName, fieldOptionsMap, subtitle, basePath, viewAllPath, font, maxProjects, }: ProjectMenuClientProps): import("react/jsx-runtime").JSX.Element;
19
- export {};
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;
20
33
  //# sourceMappingURL=ProjectMenuClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ProjectMenuClient.d.ts","sourceRoot":"","sources":["../src/ProjectMenuClient.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,iBAAiB,EAAoB,MAAM,SAAS,CAAA;AAa3E,UAAU,sBAAsB;IAC9B,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,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,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAID,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EACR,MAAM,EACN,aAAa,EACb,cAAc,EACd,eAAgC,EAChC,eAAoB,EACpB,QAAQ,EACR,QAAQ,EACR,WAAW,EACX,IAAI,EACJ,WAAe,GAChB,EAAE,sBAAsB,2CA4VxB"}
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"}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useState } from "react";
3
+ import { useState, useEffect } from "react";
4
4
  function parseSingleValue(raw) {
5
5
  if (typeof raw === "string")
6
6
  return raw.replace(/`/g, "").trim();
@@ -14,9 +14,99 @@ function parseMultiValue(raw) {
14
14
  return [];
15
15
  }
16
16
  const ACCENT = "oklch(0.78 0.16 85)";
17
- export function ProjectMenuClient({ projects, schema, filterOptions, filterFieldKey, filterFieldName = "Project Type", fieldOptionsMap = {}, subtitle, basePath, viewAllPath, font, maxProjects = 6, }) {
17
+ const API_KEY = "pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR";
18
+ const DEFAULT_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
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, }) {
21
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
18
22
  const [filtersOpen, setFiltersOpen] = useState(false);
19
23
  const [hoveredCard, setHoveredCard] = useState(null);
24
+ const [fetched, setFetched] = useState(null);
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.
27
+ useEffect(() => {
28
+ const hasDataUrl = !!dataUrl;
29
+ const hasDirectFetch = !!(clientSlug && apiBase);
30
+ if (!hasDataUrl && !hasDirectFetch)
31
+ return;
32
+ let cancelled = false;
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;
65
+ }
66
+ }
67
+ fieldOptionsMap[field.key] = map;
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());
90
+ }
91
+ menuDataCache.get(cacheKey).then((data) => {
92
+ if (!cancelled)
93
+ setFetched(data);
94
+ }).catch(() => {
95
+ // silently fail — render nothing
96
+ });
97
+ return () => { cancelled = true; };
98
+ }, [dataUrl, clientSlug, apiBase]);
99
+ // Resolve data: prefer self-fetched, fall back to props
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 : [];
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 : [];
102
+ const filterOptions = (_f = (_e = fetched === null || fetched === void 0 ? void 0 : fetched.filterOptions) !== null && _e !== void 0 ? _e : filterOptionsProp) !== null && _f !== void 0 ? _f : [];
103
+ const filterFieldKey = (_h = (_g = fetched === null || fetched === void 0 ? void 0 : fetched.filterFieldKey) !== null && _g !== void 0 ? _g : filterFieldKeyProp) !== null && _h !== void 0 ? _h : null;
104
+ const filterFieldName = (_j = fetched === null || fetched === void 0 ? void 0 : fetched.filterFieldName) !== null && _j !== void 0 ? _j : filterFieldNameProp;
105
+ const fieldOptionsMap = (_k = fetched === null || fetched === void 0 ? void 0 : fetched.fieldOptionsMap) !== null && _k !== void 0 ? _k : fieldOptionsMapProp;
106
+ // Show a minimal skeleton while self-fetching
107
+ if ((dataUrl || (clientSlug && apiBase)) && !fetched) {
108
+ return (_jsx("div", { style: { padding: "2rem 2.5rem", fontFamily: font, color: "#a1a1aa", fontSize: "14px" }, children: "Loading projects..." }));
109
+ }
20
110
  const displayed = projects.slice(0, maxProjects);
21
111
  const badgeField = schema.find((f) => f.display_position === "badge_overlay");
22
112
  const tagsField = schema.find((f) => f.display_position === "tags");
@@ -0,0 +1,25 @@
1
+ export interface SimilarProjectsProps {
2
+ /**
3
+ * Key/value pairs to filter projects by custom field values.
4
+ * e.g. { type: "commercial" } or { type: "educational-facilities" }
5
+ * All filters must match (AND logic).
6
+ */
7
+ filters?: Record<string, string>;
8
+ /**
9
+ * Slug of a project to exclude from results (e.g. the currently viewed project).
10
+ * Optional — omit if you don't need to exclude any project.
11
+ */
12
+ excludeSlug?: string;
13
+ /** The client slug identifying which client owns these projects */
14
+ clientSlug: string;
15
+ /** Base URL of the projects API */
16
+ apiBase: string;
17
+ /** Base path used to build individual project detail URLs e.g. "/projects" */
18
+ basePath?: string;
19
+ /** Maximum number of projects to show. Defaults to 3 */
20
+ maxItems?: number;
21
+ /** Seconds to cache. Defaults to 60 */
22
+ revalidate?: number;
23
+ }
24
+ export declare function SimilarProjects({ filters, excludeSlug, clientSlug, apiBase, basePath, maxItems, revalidate, }: SimilarProjectsProps): Promise<import("react/jsx-runtime").JSX.Element | null>;
25
+ //# sourceMappingURL=SimilarProjects.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SimilarProjects.d.ts","sourceRoot":"","sources":["../src/SimilarProjects.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,mEAAmE;IACnE,UAAU,EAAE,MAAM,CAAA;IAClB,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,uCAAuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AA8CD,wBAAsB,eAAe,CAAC,EACpC,OAAY,EACZ,WAAW,EACX,UAAU,EACV,OAAO,EACP,QAAsB,EACtB,QAAY,EACZ,UAAe,GAChB,EAAE,oBAAoB,2DA6GtB"}
@@ -0,0 +1,77 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cache } from "react";
3
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
4
+ function parseMultiValue(raw) {
5
+ if (Array.isArray(raw))
6
+ return raw.map(String).map((s) => s.replace(/`/g, "").trim()).filter(Boolean);
7
+ if (typeof raw === "string")
8
+ return raw.replace(/`/g, "").split(",").map((s) => s.trim()).filter(Boolean);
9
+ return [];
10
+ }
11
+ function dedupeByKey(arr) {
12
+ const seen = new Set();
13
+ return arr.filter((f) => {
14
+ if (seen.has(f.key))
15
+ return false;
16
+ seen.add(f.key);
17
+ return true;
18
+ });
19
+ }
20
+ // ─── Data fetching ────────────────────────────────────────────────────────────
21
+ const fetchSimilarData = cache(async (apiBase, clientSlug, revalidate) => {
22
+ var _a, _b, _c;
23
+ const fetchOpts = revalidate > 0 ? { next: { revalidate } } : {};
24
+ try {
25
+ const res = await fetch(`${apiBase}/api/v1/clients/${clientSlug}/projects?api_key=pk_live_crmsuTIm7NNfb9uEWBCyv88F6kj2YQUR`, fetchOpts);
26
+ const json = res.ok ? await res.json() : null;
27
+ const allProjects = (_a = json === null || json === void 0 ? void 0 : json.data) !== null && _a !== void 0 ? _a : [];
28
+ const schema = dedupeByKey((_c = (_b = json === null || json === void 0 ? void 0 : json.client) === null || _b === void 0 ? void 0 : _b.custom_fields_schema) !== null && _c !== void 0 ? _c : []);
29
+ return { allProjects, schema };
30
+ }
31
+ catch (_d) {
32
+ return { allProjects: [], schema: [] };
33
+ }
34
+ });
35
+ // ─── Component ────────────────────────────────────────────────────────────────
36
+ export async function SimilarProjects({ filters = {}, excludeSlug, clientSlug, apiBase, basePath = "/projects", maxItems = 3, revalidate = 60, }) {
37
+ const { allProjects, schema } = await fetchSimilarData(apiBase, clientSlug, revalidate);
38
+ const badgeField = schema.find((f) => f.display_position === "badge_overlay");
39
+ const locationField = schema.find((f) => f.type === "location");
40
+ const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
41
+ const filterEntries = Object.entries(filters);
42
+ const similar = allProjects
43
+ .filter((p) => {
44
+ // Exclude the specified slug if provided
45
+ if (excludeSlug && p.slug === excludeSlug)
46
+ return false;
47
+ // Every filter key/value must match the project's custom field values
48
+ return filterEntries.every(([key, value]) => {
49
+ const fieldValues = parseMultiValue(p.custom_field_values[key]);
50
+ return fieldValues.some((v) => v.toLowerCase() === value.toLowerCase());
51
+ });
52
+ })
53
+ .slice(0, maxItems);
54
+ if (similar.length === 0)
55
+ return null;
56
+ return (_jsxs("section", { style: { borderTop: "1px solid #e4e4e7", paddingTop: "3rem", maxWidth: "1280px", margin: "0 auto", padding: "3rem 1rem 2rem", boxSizing: "border-box", fontFamily: font }, children: [_jsx("style", { children: `
57
+ .chisel-similar-grid { display: grid; grid-template-columns: 1fr; gap: 1.5rem; }
58
+ @media (min-width: 640px) { .chisel-similar-grid { grid-template-columns: repeat(2, 1fr); } }
59
+ @media (min-width: 1024px) { .chisel-similar-grid { grid-template-columns: repeat(3, 1fr); } }
60
+ ` }), _jsxs("div", { style: { display: "flex", alignItems: "flex-end", justifyContent: "space-between", marginBottom: "2rem" }, children: [_jsxs("div", { children: [_jsx("p", { style: { fontSize: "11px", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.12em", color: "oklch(0.78 0.16 85)", margin: "0 0 6px 0" }, children: "More Work" }), _jsx("h2", { style: { color: "#18181b", fontWeight: 700, fontSize: "28px", margin: 0, fontFamily: font }, children: "Similar Projects" })] }), _jsxs("a", { href: basePath, style: { color: "#18181b", fontWeight: 600, fontSize: "15px", textDecoration: "none", display: "flex", alignItems: "center", gap: "6px", fontFamily: font }, children: ["View All", _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: "M9 18l6-6-6-6" }) })] })] }), _jsx("div", { className: "chisel-similar-grid", children: similar.map((p) => {
61
+ var _a, _b, _c, _d, _e;
62
+ const imgUrl = (_d = (_a = p.image_url) !== null && _a !== void 0 ? _a : (_c = (_b = p.media) === null || _b === void 0 ? void 0 : _b[0]) === null || _c === void 0 ? void 0 : _c.url) !== null && _d !== void 0 ? _d : null;
63
+ const badge = badgeField
64
+ ? ((_e = parseMultiValue(p.custom_field_values[badgeField.key])[0]) !== null && _e !== void 0 ? _e : null)
65
+ : null;
66
+ const loc = locationField
67
+ ? p.custom_field_values[locationField.key]
68
+ : null;
69
+ const locStr = loc ? [loc.city, loc.state].filter(Boolean).join(", ") : null;
70
+ return (_jsxs("a", { href: `${basePath}/${p.slug}`, style: { textDecoration: "none", color: "inherit", display: "block", borderBottom: "1px solid #e4e4e7", fontFamily: font }, children: [_jsxs("div", { style: { position: "relative", width: "100%", height: "220px", overflow: "hidden", backgroundColor: "#f4f4f5", marginBottom: "1rem" }, children: [imgUrl && (_jsx("img", { src: imgUrl, alt: p.title, style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })), badge && (_jsx("span", { style: {
71
+ position: "absolute", top: "12px", left: "12px",
72
+ backgroundColor: "oklch(0.78 0.16 85)", color: "#fff",
73
+ fontSize: "10px", fontWeight: 700, textTransform: "uppercase",
74
+ letterSpacing: "0.1em", padding: "4px 10px",
75
+ }, children: badge }))] }), _jsxs("div", { style: { paddingBottom: "1.25rem" }, children: [_jsx("h3", { style: { color: "#18181b", fontWeight: 700, fontSize: "17px", lineHeight: 1.3, margin: "0 0 8px 0", fontFamily: font }, children: p.title }), locStr && (_jsxs("p", { style: { display: "flex", alignItems: "center", gap: "5px", color: "#71717a", fontSize: "14px", margin: 0 }, children: [_jsxs("svg", { width: "13", height: "13", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", style: { flexShrink: 0 }, children: [_jsx("path", { d: "M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" }), _jsx("circle", { cx: "12", cy: "10", r: "3" })] }), locStr] }))] })] }, p.id));
76
+ }) })] }));
77
+ }
package/dist/index.d.ts CHANGED
@@ -2,9 +2,13 @@ export { ProjectPortfolio } from "./ProjectPortfolio";
2
2
  export type { ProjectPortfolioProps } from "./ProjectPortfolio";
3
3
  export { ProjectDetail } from "./ProjectDetail";
4
4
  export type { ProjectDetailProps } from "./ProjectDetail";
5
+ export { SimilarProjects } from "./SimilarProjects";
6
+ export type { SimilarProjectsProps } from "./SimilarProjects";
5
7
  export { GalleryCarousel } from "./GalleryCarousel";
6
- export { ProjectMenu } from "./ProjectMenu";
8
+ export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
7
9
  export type { ProjectMenuProps } from "./ProjectMenu";
10
+ export { ProjectMenuClient } from "./ProjectMenuClient";
11
+ export type { ProjectMenuClientProps } from "./ProjectMenuClient";
8
12
  export { ProjectCard } from "./ProjectCard";
9
13
  export type { CardVariant } from "./ProjectCard";
10
14
  export type { Project, CustomFieldSchema, CustomFieldValue, LocationValue, Media, } from "./types";
@@ -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,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AAC/C,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAC7D,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AACpF,YAAY,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAA;AACjE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC3C,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAChD,YAAY,EACV,OAAO,EACP,iBAAiB,EACjB,gBAAgB,EAChB,aAAa,EACb,KAAK,GACN,MAAM,SAAS,CAAA"}
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { ProjectPortfolio } from "./ProjectPortfolio";
2
2
  export { ProjectDetail } from "./ProjectDetail";
3
+ export { SimilarProjects } from "./SimilarProjects";
3
4
  export { GalleryCarousel } from "./GalleryCarousel";
4
- export { ProjectMenu } from "./ProjectMenu";
5
+ export { ProjectMenu, fetchProjectMenuData, createMenuHandler } from "./ProjectMenu";
6
+ export { ProjectMenuClient } from "./ProjectMenuClient";
5
7
  export { ProjectCard } from "./ProjectCard";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-portfolio",
3
- "version": "1.8.0",
3
+ "version": "1.9.2",
4
4
  "description": "Self-contained project portfolio components for Next.js App Router. Includes ProjectPortfolio, ProjectDetail, ProjectMenu (megamenu), and GalleryCarousel. Pass a clientSlug and apiBase — done.",
5
5
  "keywords": ["nextjs", "react", "portfolio", "projects", "megamenu", "gallery"],
6
6
  "license": "MIT",