project-portfolio 1.9.2 → 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 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
- #### Filter Integration
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.
@@ -297,8 +360,8 @@ Data is cached at multiple levels depending on which component is used.
297
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
298
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
299
362
 
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
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
302
365
 
303
366
  **Bypassing the cache**
304
367
 
@@ -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,5 +1,7 @@
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";
@@ -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,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"}
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,4 +1,5 @@
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";
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "project-portfolio",
3
- "version": "1.9.2",
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
- "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",