@kern-di/trust-carousel 0.1.14 → 0.1.17

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.
@@ -0,0 +1,83 @@
1
+ # Instructions for coding agents — `@kern-di/trust-carousel`
2
+
3
+ Use this when adding the Google reviews carousel to a **fleet** site that talks to KERN’s central API.
4
+
5
+ After `pnpm add @kern-di/trust-carousel`, this file is also at:
6
+
7
+ `node_modules/@kern-di/trust-carousel/CODING_AGENT.md`
8
+
9
+ ---
10
+
11
+ ## 1. The UI expects `GET /api/reviews`
12
+
13
+ `GoogleReviewsCarouselCached` defaults to **`reviewsApiPath="/api/reviews"`**. The browser performs a **GET** to that URL (no query string when using `resolvePlaceOnServer`).
14
+
15
+ You **must** implement that route on the **same origin** as the SPA (Vercel `api/reviews.ts`, or equivalent). Without it, the carousel cannot load data.
16
+
17
+ ---
18
+
19
+ ## 2. Vercel serverless route (recommended)
20
+
21
+ Add **`api/reviews.ts`** next to your other `api/*` handlers (Vercel Node runtime):
22
+
23
+ ```ts
24
+ import { createVercelHandler } from "@kern-di/trust-carousel/server";
25
+
26
+ export default createVercelHandler({
27
+ apiKey: process.env.KERN_API_KEY ?? "",
28
+ placeId: process.env.GOOGLE_PLACE_ID ?? "",
29
+ });
30
+ ```
31
+
32
+ This handler proxies to the company central API **`GET /api/google-reviews`** with bearer auth. Ensure your Vercel project rewrites or routing expose **`/api/reviews`** to this file (same pattern as other `api/*.ts` routes).
33
+
34
+ ---
35
+
36
+ ## 3. Environment variables (customer / fleet Vercel project)
37
+
38
+ | Variable | Purpose |
39
+ | ------------------- | ------- |
40
+ | `KERN_API_KEY` | Bearer token issued by KERN (secret). |
41
+ | `GOOGLE_PLACE_ID` | Google Place ID for this business. |
42
+ | `KERN_API_URL` | Central API **origin only** (e.g. `https://api.kern-di.de` or `http://localhost:7072`). **No** `/api` suffix. |
43
+ | `VITE_KERN_API_URL` | Optional fallback if only Vite-prefixed vars are available server-side. |
44
+ | `KERN_REVIEWS_DEBUG`| Set to `1` for bridge debug logs (optional). |
45
+
46
+ ---
47
+
48
+ ## 4. Local `pnpm dev` without `vercel dev`
49
+
50
+ Plain Vite dev often has **no** `/api/*`. Add the dev middleware from **`@kern-di/trust-carousel/vite-plugin`** so **`GET /api/reviews`** exists locally:
51
+
52
+ ```ts
53
+ import { trustCarouselReviewsVitePlugin } from "@kern-di/trust-carousel/vite-plugin";
54
+
55
+ export default defineConfig({
56
+ plugins: [
57
+ /* … */
58
+ trustCarouselReviewsVitePlugin(),
59
+ ],
60
+ });
61
+ ```
62
+
63
+ Use the same env vars in `.env` as on Vercel (see `.env.example` in the package repo).
64
+
65
+ ---
66
+
67
+ ## 5. React usage (fleet)
68
+
69
+ Prefer **`GoogleReviewsCarouselCached`** with server-side place resolution:
70
+
71
+ ```tsx
72
+ import { GoogleReviewsCarouselCached } from "@kern-di/trust-carousel";
73
+
74
+ <GoogleReviewsCarouselCached resolvePlaceOnServer locale="de" />
75
+ ```
76
+
77
+ Tailwind: include this package in **`content`** (e.g. `node_modules/@kern-di/trust-carousel/dist/**/*.js`).
78
+
79
+ ---
80
+
81
+ ## 6. Quick check
82
+
83
+ With env set, **`GET /api/reviews`** should return JSON with `businessName`, `placeUrl`, `reviews`, `fetchedAt`, `source`, and (when available) `aggregateRating` / `totalReviewCount`. If `KERN_API_KEY` or `GOOGLE_PLACE_ID` is missing, the bridge responds with **503** and a small JSON error body.
@@ -23,6 +23,13 @@ function normalizeApiOrigin(url) {
23
23
  return s;
24
24
  }
25
25
  var CACHE_CONTROL_BRIDGE = "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, must-revalidate";
26
+ function normalizeBridgePlacesLanguage(raw) {
27
+ const fallback = "de";
28
+ const s = raw?.trim().toLowerCase();
29
+ if (!s) return fallback;
30
+ if (!/^[a-z]{2}(-[a-z0-9]{2,8})?$/.test(s)) return fallback;
31
+ return s;
32
+ }
26
33
  async function executeReviewsProxy(input) {
27
34
  const apiKey = input.apiKey?.trim() ?? input.env.KERN_API_KEY?.trim() ?? "";
28
35
  const placeId = input.placeId?.trim() ?? input.env.GOOGLE_PLACE_ID?.trim() ?? "";
@@ -42,7 +49,8 @@ async function executeReviewsProxy(input) {
42
49
  };
43
50
  }
44
51
  const origin = resolveKernApiOrigin(input.apiUrl, input.env);
45
- const url = `${origin}${KERN_API_GOOGLE_REVIEWS_PATH}?placeId=${encodeURIComponent(placeId)}`;
52
+ const lang = normalizeBridgePlacesLanguage(input.language);
53
+ const url = `${origin}${KERN_API_GOOGLE_REVIEWS_PATH}?placeId=${encodeURIComponent(placeId)}&language=${encodeURIComponent(lang)}`;
46
54
  if (debug) {
47
55
  console.log("[kern-di/trust-carousel] upstream GET", url);
48
56
  }
@@ -84,4 +92,4 @@ export {
84
92
  resolveKernApiOrigin,
85
93
  executeReviewsProxy
86
94
  };
87
- //# sourceMappingURL=chunk-QCKKYPGS.js.map
95
+ //# sourceMappingURL=chunk-DDRJRV5Q.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/reviewsBridgeCore.ts"],"sourcesContent":["/** Env bag for bridge (e.g. `process.env` or Vite `loadEnv` merged with `process.env`). */\nexport type ReviewsBridgeEnv = Record<string, string | undefined>;\n\n/** Default production origin for the company central API (no path). */\nexport const DEFAULT_KERN_API_URL = \"https://api.kern-di.de\";\n\n/**\n * Path on the central API for Google reviews (bearer + `placeId` query).\n */\nexport const KERN_API_GOOGLE_REVIEWS_PATH = \"/api/google-reviews\";\n\nexport function isReviewsBridgeDebug(env: ReviewsBridgeEnv): boolean {\n\treturn env.KERN_REVIEWS_DEBUG === \"1\" || env.DEBUG_KERN_REVIEWS === \"1\";\n}\n\n/**\n * Resolves central API **origin** only.\n * Precedence: `configApiUrl` → `KERN_API_URL` → `VITE_KERN_API_URL` → default.\n */\nexport function resolveKernApiOrigin(\n\tconfigApiUrl: string | undefined,\n\tenv: ReviewsBridgeEnv = process.env as ReviewsBridgeEnv,\n): string {\n\tconst fromConfig = configApiUrl?.trim();\n\tif (fromConfig) return normalizeApiOrigin(fromConfig);\n\tconst fromEnv = env.KERN_API_URL?.trim() || env.VITE_KERN_API_URL?.trim();\n\tif (fromEnv) return normalizeApiOrigin(fromEnv);\n\treturn normalizeApiOrigin(DEFAULT_KERN_API_URL);\n}\n\nfunction stripTrailingSlash(url: string): string {\n\treturn url.replace(/\\/$/, \"\");\n}\n\nfunction normalizeApiOrigin(url: string): string {\n\tlet s = stripTrailingSlash(url.trim());\n\tif (s.endsWith(\"/api\")) {\n\t\ts = s.slice(0, -4);\n\t\ts = stripTrailingSlash(s);\n\t}\n\treturn s;\n}\n\nconst CACHE_CONTROL_BRIDGE =\n\t\"public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, must-revalidate\";\n\nexport type ExecuteReviewsProxyInput = {\n\t/** Overrides `env.KERN_API_KEY` */\n\tapiKey?: string;\n\t/** Overrides `env.GOOGLE_PLACE_ID` */\n\tplaceId?: string;\n\t/** Overrides env-based origin resolution */\n\tapiUrl?: string;\n\t/** Google Places `language` (default `de`). */\n\tlanguage?: string | null;\n\tenv: ReviewsBridgeEnv;\n};\n\nfunction normalizeBridgePlacesLanguage(raw?: string | null): string {\n\tconst fallback = \"de\";\n\tconst s = raw?.trim().toLowerCase();\n\tif (!s) return fallback;\n\tif (!/^[a-z]{2}(-[a-z0-9]{2,8})?$/.test(s)) return fallback;\n\treturn s;\n}\n\n/**\n * Shared implementation: GET reviews JSON from the company central API (Redis + Places upstream).\n * Used by `createVercelHandler` and the Vite dev plugin.\n */\nexport async function executeReviewsProxy(\n\tinput: ExecuteReviewsProxyInput,\n): Promise<{\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: Buffer;\n}> {\n\tconst apiKey = input.apiKey?.trim() ?? input.env.KERN_API_KEY?.trim() ?? \"\";\n\tconst placeId =\n\t\tinput.placeId?.trim() ?? input.env.GOOGLE_PLACE_ID?.trim() ?? \"\";\n\tconst debug = isReviewsBridgeDebug(input.env);\n\n\tif (!apiKey || !placeId) {\n\t\tif (debug) {\n\t\t\tconsole.warn(\n\t\t\t\t\"[kern-di/trust-carousel] bridge misconfigured: missing KERN_API_KEY or GOOGLE_PLACE_ID\",\n\t\t\t);\n\t\t}\n\t\treturn {\n\t\t\tstatus: 503,\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: Buffer.from(\n\t\t\t\tJSON.stringify({ error: \"Reviews API is not configured.\" }),\n\t\t\t),\n\t\t};\n\t}\n\n\tconst origin = resolveKernApiOrigin(input.apiUrl, input.env);\n\tconst lang = normalizeBridgePlacesLanguage(input.language);\n\tconst url = `${origin}${KERN_API_GOOGLE_REVIEWS_PATH}?placeId=${encodeURIComponent(placeId)}&language=${encodeURIComponent(lang)}`;\n\n\tif (debug) {\n\t\tconsole.log(\"[kern-di/trust-carousel] upstream GET\", url);\n\t}\n\n\ttry {\n\t\tconst upstream = await fetch(url, {\n\t\t\theaders: { Authorization: `Bearer ${apiKey}` },\n\t\t});\n\n\t\tif (debug) {\n\t\t\tconsole.log(\n\t\t\t\t\"[kern-di/trust-carousel] upstream response\",\n\t\t\t\tupstream.status,\n\t\t\t\tupstream.statusText,\n\t\t\t);\n\t\t}\n\n\t\tconst headers: Record<string, string> = {\n\t\t\t\"Cache-Control\": CACHE_CONTROL_BRIDGE,\n\t\t};\n\t\tconst ct = upstream.headers.get(\"content-type\");\n\t\tif (ct) {\n\t\t\theaders[\"Content-Type\"] = ct;\n\t\t}\n\n\t\tconst buf = Buffer.from(await upstream.arrayBuffer());\n\t\treturn { status: upstream.status, headers, body: buf };\n\t} catch (err) {\n\t\tif (debug) {\n\t\t\tconsole.error(\"[kern-di/trust-carousel] upstream fetch error\", err);\n\t\t}\n\t\treturn {\n\t\t\tstatus: 502,\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: Buffer.from(JSON.stringify({ error: \"Could not load reviews.\" })),\n\t\t};\n\t}\n}\n"],"mappings":";AAIO,IAAM,uBAAuB;AAK7B,IAAM,+BAA+B;AAErC,SAAS,qBAAqB,KAAgC;AACpE,SAAO,IAAI,uBAAuB,OAAO,IAAI,uBAAuB;AACrE;AAMO,SAAS,qBACf,cACA,MAAwB,QAAQ,KACvB;AACT,QAAM,aAAa,cAAc,KAAK;AACtC,MAAI,WAAY,QAAO,mBAAmB,UAAU;AACpD,QAAM,UAAU,IAAI,cAAc,KAAK,KAAK,IAAI,mBAAmB,KAAK;AACxE,MAAI,QAAS,QAAO,mBAAmB,OAAO;AAC9C,SAAO,mBAAmB,oBAAoB;AAC/C;AAEA,SAAS,mBAAmB,KAAqB;AAChD,SAAO,IAAI,QAAQ,OAAO,EAAE;AAC7B;AAEA,SAAS,mBAAmB,KAAqB;AAChD,MAAI,IAAI,mBAAmB,IAAI,KAAK,CAAC;AACrC,MAAI,EAAE,SAAS,MAAM,GAAG;AACvB,QAAI,EAAE,MAAM,GAAG,EAAE;AACjB,QAAI,mBAAmB,CAAC;AAAA,EACzB;AACA,SAAO;AACR;AAEA,IAAM,uBACL;AAcD,SAAS,8BAA8B,KAA6B;AACnE,QAAM,WAAW;AACjB,QAAM,IAAI,KAAK,KAAK,EAAE,YAAY;AAClC,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,CAAC,8BAA8B,KAAK,CAAC,EAAG,QAAO;AACnD,SAAO;AACR;AAMA,eAAsB,oBACrB,OAKE;AACF,QAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,MAAM,IAAI,cAAc,KAAK,KAAK;AACzE,QAAM,UACL,MAAM,SAAS,KAAK,KAAK,MAAM,IAAI,iBAAiB,KAAK,KAAK;AAC/D,QAAM,QAAQ,qBAAqB,MAAM,GAAG;AAE5C,MAAI,CAAC,UAAU,CAAC,SAAS;AACxB,QAAI,OAAO;AACV,cAAQ;AAAA,QACP;AAAA,MACD;AAAA,IACD;AACA,WAAO;AAAA,MACN,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,OAAO;AAAA,QACZ,KAAK,UAAU,EAAE,OAAO,iCAAiC,CAAC;AAAA,MAC3D;AAAA,IACD;AAAA,EACD;AAEA,QAAM,SAAS,qBAAqB,MAAM,QAAQ,MAAM,GAAG;AAC3D,QAAM,OAAO,8BAA8B,MAAM,QAAQ;AACzD,QAAM,MAAM,GAAG,MAAM,GAAG,4BAA4B,YAAY,mBAAmB,OAAO,CAAC,aAAa,mBAAmB,IAAI,CAAC;AAEhI,MAAI,OAAO;AACV,YAAQ,IAAI,yCAAyC,GAAG;AAAA,EACzD;AAEA,MAAI;AACH,UAAM,WAAW,MAAM,MAAM,KAAK;AAAA,MACjC,SAAS,EAAE,eAAe,UAAU,MAAM,GAAG;AAAA,IAC9C,CAAC;AAED,QAAI,OAAO;AACV,cAAQ;AAAA,QACP;AAAA,QACA,SAAS;AAAA,QACT,SAAS;AAAA,MACV;AAAA,IACD;AAEA,UAAM,UAAkC;AAAA,MACvC,iBAAiB;AAAA,IAClB;AACA,UAAM,KAAK,SAAS,QAAQ,IAAI,cAAc;AAC9C,QAAI,IAAI;AACP,cAAQ,cAAc,IAAI;AAAA,IAC3B;AAEA,UAAM,MAAM,OAAO,KAAK,MAAM,SAAS,YAAY,CAAC;AACpD,WAAO,EAAE,QAAQ,SAAS,QAAQ,SAAS,MAAM,IAAI;AAAA,EACtD,SAAS,KAAK;AACb,QAAI,OAAO;AACV,cAAQ,MAAM,iDAAiD,GAAG;AAAA,IACnE;AACA,WAAO;AAAA,MACN,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,OAAO,KAAK,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAAA,IACvE;AAAA,EACD;AACD;","names":[]}
package/dist/index.d.ts CHANGED
@@ -28,7 +28,7 @@ interface GoogleReviewsCarouselProps {
28
28
  autoPlaySpeed?: number;
29
29
  }
30
30
  declare const SAMPLE_REVIEWS: GoogleReview[];
31
- declare function GoogleReviewsCarousel({ reviews, businessName, placeUrl, aggregateRating: aggregateRatingProp, totalReviewCount: totalReviewCountProp, locale, autoPlay, autoPlaySpeed, }: GoogleReviewsCarouselProps): react_jsx_runtime.JSX.Element;
31
+ declare function GoogleReviewsCarousel({ reviews, businessName, placeUrl, aggregateRating: aggregateRatingProp, totalReviewCount: totalReviewCountProp, locale, accentColor, autoPlay, autoPlaySpeed, }: GoogleReviewsCarouselProps): react_jsx_runtime.JSX.Element;
32
32
 
33
33
  /** JSON body from your reviews API (e.g. `GET /api/reviews` fleet bridge or `GET /api/google-reviews`). */
34
34
  type CachedGoogleReviewsResponse = {
package/dist/index.js CHANGED
@@ -104,12 +104,22 @@ function normalizeProfilePhotoUrl(url) {
104
104
  else if (/^http:\/\//i.test(u)) u = `https://${u.slice(7)}`;
105
105
  return u;
106
106
  }
107
- function StarRating({ rating, size = 16 }) {
107
+ var DEFAULT_STAR_COLOR = "#FBBC05";
108
+ var DEFAULT_STAR_EMPTY_COLOR = "rgba(120, 120, 120, 0.35)";
109
+ function StarRating({
110
+ rating,
111
+ size = 16,
112
+ color = DEFAULT_STAR_COLOR,
113
+ emptyColor = DEFAULT_STAR_EMPTY_COLOR
114
+ }) {
108
115
  return /* @__PURE__ */ jsx("span", { className: "inline-flex gap-0.5", children: Array.from({ length: 5 }).map((_, i) => /* @__PURE__ */ jsx(
109
116
  Star,
110
117
  {
111
118
  size,
112
- className: i < rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"
119
+ fill: i < rating ? "currentColor" : "none",
120
+ style: {
121
+ color: i < rating ? color : emptyColor
122
+ }
113
123
  },
114
124
  i
115
125
  )) });
@@ -165,7 +175,8 @@ function GoogleLogo({ className }) {
165
175
  function ReviewCard({
166
176
  review,
167
177
  index,
168
- labels
178
+ labels,
179
+ accentColor
169
180
  }) {
170
181
  const [expanded, setExpanded] = useState(false);
171
182
  const [imgFailed, setImgFailed] = useState(false);
@@ -203,7 +214,7 @@ function ReviewCard({
203
214
  /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: review.date })
204
215
  ] })
205
216
  ] }),
206
- /* @__PURE__ */ jsx(StarRating, { rating: review.rating }),
217
+ /* @__PURE__ */ jsx(StarRating, { rating: review.rating, color: accentColor }),
207
218
  /* @__PURE__ */ jsx("p", { className: "mt-3 flex-1 text-sm leading-relaxed text-foreground/80", children: shouldTruncate && !expanded ? review.text.slice(0, 150) + "\u2026" : review.text }),
208
219
  shouldTruncate && /* @__PURE__ */ jsx(
209
220
  "button",
@@ -225,6 +236,7 @@ function GoogleReviewsCarousel({
225
236
  aggregateRating: aggregateRatingProp,
226
237
  totalReviewCount: totalReviewCountProp,
227
238
  locale = "de",
239
+ accentColor,
228
240
  autoPlay = true,
229
241
  autoPlaySpeed = 5e3
230
242
  }) {
@@ -259,7 +271,14 @@ function GoogleReviewsCarousel({
259
271
  /* @__PURE__ */ jsx("h3", { className: "font-display text-xl font-semibold text-foreground md:text-2xl", children: businessName }),
260
272
  officialRating !== void 0 ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
261
273
  /* @__PURE__ */ jsx("span", { className: "text-2xl font-bold text-foreground", children: officialRating.toFixed(1) }),
262
- /* @__PURE__ */ jsx(StarRating, { rating: Math.round(officialRating), size: 20 }),
274
+ /* @__PURE__ */ jsx(
275
+ StarRating,
276
+ {
277
+ rating: Math.round(officialRating),
278
+ size: 20,
279
+ color: accentColor
280
+ }
281
+ ),
263
282
  /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: labels.reviewCount(summaryCount) })
264
283
  ] }) : /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: labels.reviewCount(summaryCount) })
265
284
  ] }),
@@ -295,7 +314,15 @@ function GoogleReviewsCarousel({
295
314
  {
296
315
  className: "flex transition-transform duration-500 ease-in-out",
297
316
  style: { transform: `translateX(-${current * 100}%)` },
298
- children: reviews.map((review, i) => /* @__PURE__ */ jsx("div", { className: "w-full flex-shrink-0 px-1", children: /* @__PURE__ */ jsx(ReviewCard, { review, index: i, labels }) }, i))
317
+ children: reviews.map((review, i) => /* @__PURE__ */ jsx("div", { className: "w-full flex-shrink-0 px-1", children: /* @__PURE__ */ jsx(
318
+ ReviewCard,
319
+ {
320
+ review,
321
+ index: i,
322
+ labels,
323
+ accentColor
324
+ }
325
+ ) }, i))
299
326
  }
300
327
  ) }),
301
328
  total > 1 && /* @__PURE__ */ jsx("div", { className: "mt-4 flex justify-center gap-2", children: reviews.map((_, i) => /* @__PURE__ */ jsx(
@@ -336,19 +363,23 @@ function GoogleReviewsCarousel({
336
363
  import { Loader2 } from "lucide-react";
337
364
  import { useEffect as useEffect2, useState as useState2 } from "react";
338
365
  import { jsx as jsx2 } from "react/jsx-runtime";
339
- function buildReviewsUrl(base, params) {
366
+ function buildReviewsUrl(base, params, placesLanguage) {
340
367
  const path = base.replace(/\/$/, "");
368
+ let url;
341
369
  if (params.resolvePlaceOnServer) {
342
- return path;
343
- }
344
- const joiner = path.includes("?") ? "&" : "?";
345
- if (params.placeId?.trim()) {
346
- return `${path}${joiner}placeId=${encodeURIComponent(params.placeId.trim())}`;
347
- }
348
- if (params.slug?.trim()) {
349
- return `${path}${joiner}slug=${encodeURIComponent(params.slug.trim())}`;
370
+ url = path;
371
+ } else {
372
+ const joiner = path.includes("?") ? "&" : "?";
373
+ if (params.placeId?.trim()) {
374
+ url = `${path}${joiner}placeId=${encodeURIComponent(params.placeId.trim())}`;
375
+ } else if (params.slug?.trim()) {
376
+ url = `${path}${joiner}slug=${encodeURIComponent(params.slug.trim())}`;
377
+ } else {
378
+ url = path;
379
+ }
350
380
  }
351
- return path;
381
+ const langJoin = url.includes("?") ? "&" : "?";
382
+ return `${url}${langJoin}language=${encodeURIComponent(placesLanguage)}`;
352
383
  }
353
384
  function isSuccessPayload(data) {
354
385
  if (!data || typeof data !== "object") return false;
@@ -368,11 +399,16 @@ function GoogleReviewsCarouselCached({
368
399
  const [loading, setLoading] = useState2(true);
369
400
  useEffect2(() => {
370
401
  let cancelled = false;
371
- const url = buildReviewsUrl(reviewsApiPath, {
372
- slug,
373
- placeId,
374
- resolvePlaceOnServer
375
- });
402
+ const placesLanguage = locale === "en" ? "en" : "de";
403
+ const url = buildReviewsUrl(
404
+ reviewsApiPath,
405
+ {
406
+ slug,
407
+ placeId,
408
+ resolvePlaceOnServer
409
+ },
410
+ placesLanguage
411
+ );
376
412
  (async () => {
377
413
  setLoading(true);
378
414
  setError(null);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/components/GoogleReviewsCarousel.tsx","../src/lib/cn.ts","../src/components/GoogleReviewsCarouselCached.tsx"],"sourcesContent":["import { ChevronLeft, ChevronRight, ExternalLink, Star } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n// ---------- Types ----------\nexport interface GoogleReview {\n\tauthor: string;\n\trating: number;\n\tdate: string;\n\ttext: string;\n\t/** HTTPS is enforced; broken or blocked URLs fall back to initials (e.g. some Google CDN responses). */\n\tphotoUrl?: string;\n}\n\nexport type GoogleReviewsCarouselLocale = \"de\" | \"en\";\n\nexport interface GoogleReviewsCarouselProps {\n\treviews: GoogleReview[];\n\tbusinessName: string;\n\tplaceUrl: string;\n\t/**\n\t * Official overall rating from Google Places (all reviews). The header score\n\t * and stars are shown only when this is set — never derived from the\n\t * `reviews` sample.\n\t */\n\taggregateRating?: number;\n\t/** Official total review count from Google Places. When omitted, uses `reviews.length`. */\n\ttotalReviewCount?: number;\n\t/** UI copy. Default `de` matches KERN Handwerk sites. */\n\tlocale?: GoogleReviewsCarouselLocale;\n\taccentColor?: string;\n\tautoPlay?: boolean;\n\tautoPlaySpeed?: number;\n}\n\nconst UI_STRINGS: Record<\n\tGoogleReviewsCarouselLocale,\n\t{\n\t\teyebrow: string;\n\t\treviewCount: (n: number) => string;\n\t\treadMore: string;\n\t\tshowLess: string;\n\t\treadAllOnGoogle: string;\n\t\tprevReview: string;\n\t\tnextReview: string;\n\t\tgoToReview: (i: number) => string;\n\t}\n> = {\n\tde: {\n\t\teyebrow: \"Google-Bewertungen\",\n\t\treviewCount: (n) => `(${n} ${n === 1 ? \"Bewertung\" : \"Bewertungen\"})`,\n\t\treadMore: \"Mehr anzeigen\",\n\t\tshowLess: \"Weniger anzeigen\",\n\t\treadAllOnGoogle: \"Alle Bewertungen bei Google\",\n\t\tprevReview: \"Vorherige Bewertung\",\n\t\tnextReview: \"Nächste Bewertung\",\n\t\tgoToReview: (i) => `Zu Bewertung ${i}`,\n\t},\n\ten: {\n\t\teyebrow: \"Google reviews\",\n\t\treviewCount: (n) => `(${n} ${n === 1 ? \"review\" : \"reviews\"})`,\n\t\treadMore: \"Read more\",\n\t\tshowLess: \"Show less\",\n\t\treadAllOnGoogle: \"Read all reviews on Google\",\n\t\tprevReview: \"Previous review\",\n\t\tnextReview: \"Next review\",\n\t\tgoToReview: (i) => `Go to review ${i}`,\n\t},\n};\n\n// ---------- Sample data ----------\nexport const SAMPLE_REVIEWS: GoogleReview[] = [\n\t{\n\t\tauthor: \"Sarah Mitchell\",\n\t\trating: 5,\n\t\tdate: \"2 weeks ago\",\n\t\ttext: \"Absolutely fantastic service! They fixed our burst pipe on a Sunday evening within an hour of calling. The plumber was professional, tidy, and explained everything clearly. Pricing was very fair — no hidden charges. Highly recommend to anyone needing reliable plumbing work.\",\n\t\tphotoUrl: \"https://i.pravatar.cc/128?img=47\",\n\t},\n\t{\n\t\tauthor: \"James Thornton\",\n\t\trating: 5,\n\t\tdate: \"1 month ago\",\n\t\ttext: \"Had a full rewire done on our 1930s semi. The team were punctual every single day, kept the house as clean as possible, and the finish is immaculate. They even helped us plan the socket layout for our new kitchen. Top-notch electricians.\",\n\t\tphotoUrl: \"https://i.pravatar.cc/128?img=12\",\n\t},\n\t{\n\t\tauthor: \"Maria Chen\",\n\t\trating: 5,\n\t\tdate: \"3 weeks ago\",\n\t\ttext: \"Called them for an emergency boiler repair in the middle of winter. They came out the same day and had the heating back on within two hours. Genuine lifesavers — we have young kids and couldn't have waited. Will use again for our annual service.\",\n\t},\n\t{\n\t\tauthor: \"David Okonkwo\",\n\t\trating: 4,\n\t\tdate: \"2 months ago\",\n\t\ttext: \"Great job installing our new bathroom. Tiling work is spotless and the underfloor heating is a dream. Only minor hiccup was a small delay waiting for parts, but they kept us updated throughout. Overall very happy with the result.\",\n\t},\n\t{\n\t\tauthor: \"Emma Richardson\",\n\t\trating: 5,\n\t\tdate: \"1 week ago\",\n\t\ttext: \"We've used this company three times now — blocked drain, leaking shower, and a full kitchen tap replacement. Every time the service has been prompt, friendly, and reasonably priced. It's so hard to find tradespeople you can trust, and these guys are the real deal.\",\n\t},\n];\n\n// ---------- Helpers ----------\nconst AVATAR_COLORS = [\n\t\"hsl(210, 60%, 50%)\",\n\t\"hsl(340, 60%, 50%)\",\n\t\"hsl(160, 55%, 42%)\",\n\t\"hsl(30, 70%, 50%)\",\n\t\"hsl(270, 50%, 55%)\",\n];\n\nfunction getInitial(name: string) {\n\treturn name.charAt(0).toUpperCase();\n}\n\nfunction coerceFiniteNumber(value: unknown): number | undefined {\n\tif (value == null) return undefined;\n\tif (typeof value === \"number\" && Number.isFinite(value)) return value;\n\tif (typeof value === \"string\") {\n\t\tconst n = Number.parseFloat(value.trim().replace(\",\", \".\"));\n\t\tif (Number.isFinite(n)) return n;\n\t}\n\treturn undefined;\n}\n\nfunction coerceNonNegativeInt(value: unknown): number | undefined {\n\tif (value == null) return undefined;\n\tif (typeof value === \"number\" && Number.isInteger(value) && value >= 0) {\n\t\treturn value;\n\t}\n\tif (typeof value === \"string\" && /^\\s*\\d+\\s*$/.test(value)) {\n\t\tconst n = Number.parseInt(value.trim(), 10);\n\t\tif (Number.isFinite(n) && n >= 0) return n;\n\t}\n\treturn undefined;\n}\n\n/** Google profile URLs are often `http://` or protocol-relative; normalize for reliable loading. */\nfunction normalizeProfilePhotoUrl(url: string | undefined): string | undefined {\n\tif (!url?.trim()) return undefined;\n\tlet u = url.trim();\n\tif (u.startsWith(\"//\")) u = `https:${u}`;\n\telse if (/^http:\\/\\//i.test(u)) u = `https://${u.slice(7)}`;\n\treturn u;\n}\n\nfunction StarRating({ rating, size = 16 }: { rating: number; size?: number }) {\n\treturn (\n\t\t<span className=\"inline-flex gap-0.5\">\n\t\t\t{Array.from({ length: 5 }).map((_, i) => (\n\t\t\t\t<Star\n\t\t\t\t\tkey={i}\n\t\t\t\t\tsize={size}\n\t\t\t\t\tclassName={\n\t\t\t\t\t\ti < rating\n\t\t\t\t\t\t\t? \"fill-amber-400 text-amber-400\"\n\t\t\t\t\t\t\t: \"text-muted-foreground/30\"\n\t\t\t\t\t}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</span>\n\t);\n}\n\nfunction GoogleLogo({ className }: { className?: string }) {\n\treturn (\n\t\t<svg\n\t\t\tclassName={className}\n\t\t\tviewBox=\"0 0 272 92\"\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t>\n\t\t\t<path\n\t\t\t\td=\"M115.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18C71.25 34.32 81.24 25 93.5 25s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44S80.99 39.2 80.99 47.18c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z\"\n\t\t\t\tfill=\"#EA4335\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M163.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18c0-12.85 9.99-22.18 22.25-22.18s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44s-12.51 5.46-12.51 13.44c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z\"\n\t\t\t\tfill=\"#FBBC05\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M209.75 26.34v39.82c0 16.38-9.66 23.07-21.08 23.07-10.75 0-17.22-7.19-19.66-13.07l8.48-3.53c1.51 3.61 5.21 7.87 11.17 7.87 7.31 0 11.84-4.51 11.84-13v-3.19h-.34c-2.18 2.69-6.38 5.04-11.68 5.04-11.09 0-21.25-9.66-21.25-22.09 0-12.52 10.16-22.26 21.25-22.26 5.29 0 9.49 2.35 11.68 4.96h.34v-3.61h9.25zm-8.56 20.92c0-7.81-5.21-13.52-11.84-13.52-6.72 0-12.35 5.71-12.35 13.52 0 7.73 5.63 13.36 12.35 13.36 6.63 0 11.84-5.63 11.84-13.36z\"\n\t\t\t\tfill=\"#4285F4\"\n\t\t\t/>\n\t\t\t<path d=\"M225 3v65h-9.5V3h9.5z\" fill=\"#34A853\" />\n\t\t\t<path\n\t\t\t\td=\"M262.02 54.48l7.56 5.04c-2.44 3.61-8.32 9.83-18.48 9.83-12.6 0-22.01-9.74-22.01-22.18 0-13.19 9.49-22.18 20.92-22.18 11.51 0 17.14 9.16 18.98 14.11l1.01 2.52-29.65 12.28c2.27 4.45 5.8 6.72 10.75 6.72 4.96 0 8.4-2.44 10.92-6.14zm-23.27-7.98l19.82-8.23c-1.09-2.77-4.37-4.7-8.23-4.7-4.95 0-11.84 4.37-11.59 12.93z\"\n\t\t\t\tfill=\"#EA4335\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M35.29 41.19V32H67c.31 1.64.47 3.58.47 5.68 0 7.06-1.93 15.79-8.15 22.01-6.05 6.3-13.78 9.66-24.02 9.66C16.32 69.35.36 53.89.36 34.91.36 15.93 16.32.47 35.3.47c10.5 0 17.98 4.12 23.6 9.49l-6.64 6.64c-4.03-3.78-9.49-6.72-16.97-6.72-13.86 0-24.7 11.17-24.7 25.03 0 13.86 10.84 25.03 24.7 25.03 8.99 0 14.11-3.61 17.39-6.89 2.66-2.66 4.41-6.46 5.1-11.65l-22.49-.01z\"\n\t\t\t\tfill=\"#4285F4\"\n\t\t\t/>\n\t\t</svg>\n\t);\n}\n\n// ---------- Review Card ----------\nfunction ReviewCard({\n\treview,\n\tindex,\n\tlabels,\n}: {\n\treview: GoogleReview;\n\tindex: number;\n\tlabels: (typeof UI_STRINGS)[GoogleReviewsCarouselLocale];\n}) {\n\tconst [expanded, setExpanded] = useState(false);\n\tconst [imgFailed, setImgFailed] = useState(false);\n\tconst shouldTruncate = review.text.length > 150;\n\tconst photoSrc = normalizeProfilePhotoUrl(review.photoUrl);\n\n\tuseEffect(() => {\n\t\tsetImgFailed(false);\n\t}, [photoSrc]);\n\n\tconst showPhoto = Boolean(photoSrc) && !imgFailed;\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col rounded-lg border bg-card p-5 shadow-sm\">\n\t\t\t{/* Author row */}\n\t\t\t<div className=\"mb-3 flex items-center gap-3\">\n\t\t\t\t{showPhoto ? (\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={photoSrc}\n\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\tclassName=\"h-10 w-10 rounded-full object-cover\"\n\t\t\t\t\t\tloading=\"lazy\"\n\t\t\t\t\t\tdecoding=\"async\"\n\t\t\t\t\t\tonError={() => setImgFailed(true)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold text-white\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: AVATAR_COLORS[index % AVATAR_COLORS.length],\n\t\t\t\t\t\t}}\n\t\t\t\t\t\taria-hidden\n\t\t\t\t\t>\n\t\t\t\t\t\t{getInitial(review.author)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t<p className=\"truncate text-sm font-medium text-foreground\">\n\t\t\t\t\t\t{review.author}\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">{review.date}</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Stars */}\n\t\t\t<StarRating rating={review.rating} />\n\n\t\t\t{/* Text */}\n\t\t\t<p className=\"mt-3 flex-1 text-sm leading-relaxed text-foreground/80\">\n\t\t\t\t{shouldTruncate && !expanded\n\t\t\t\t\t? review.text.slice(0, 150) + \"…\"\n\t\t\t\t\t: review.text}\n\t\t\t</p>\n\t\t\t{shouldTruncate && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => setExpanded(!expanded)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"mt-1 inline-flex h-auto self-start p-0 text-xs font-medium text-primary underline-offset-4 hover:underline\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{expanded ? labels.showLess : labels.readMore}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n\n// ---------- Main Component ----------\nexport default function GoogleReviewsCarousel({\n\treviews,\n\tbusinessName,\n\tplaceUrl,\n\taggregateRating: aggregateRatingProp,\n\ttotalReviewCount: totalReviewCountProp,\n\tlocale = \"de\",\n\tautoPlay = true,\n\tautoPlaySpeed = 5000,\n}: GoogleReviewsCarouselProps) {\n\tconst labels = UI_STRINGS[locale];\n\tconst [current, setCurrent] = useState(0);\n\tconst [paused, setPaused] = useState(false);\n\tconst total = reviews.length;\n\n\tconst next = useCallback(() => setCurrent((c) => (c + 1) % total), [total]);\n\tconst prev = useCallback(\n\t\t() => setCurrent((c) => (c - 1 + total) % total),\n\t\t[total],\n\t);\n\n\tuseEffect(() => {\n\t\tif (!autoPlay || paused || total <= 1) return;\n\t\tconst id = setInterval(next, autoPlaySpeed);\n\t\treturn () => clearInterval(id);\n\t}, [autoPlay, autoPlaySpeed, paused, next, total]);\n\n\tif (total === 0) return null;\n\n\tconst officialTotal = coerceNonNegativeInt(totalReviewCountProp);\n\tconst summaryCount = officialTotal ?? total;\n\tconst officialRating = coerceFiniteNumber(aggregateRatingProp);\n\n\treturn (\n\t\t<section\n\t\t\tclassName=\"mx-auto w-full max-w-3xl\"\n\t\t\tonMouseEnter={() => setPaused(true)}\n\t\t\tonMouseLeave={() => setPaused(false)}\n\t\t>\n\t\t\t{/* Header */}\n\t\t\t<div className=\"mb-10 flex flex-col items-center gap-4 py-2 text-center\">\n\t\t\t\t<GoogleLogo className=\"h-8\" />\n\t\t\t\t<p className=\"text-xs font-medium uppercase tracking-widest text-muted-foreground\">\n\t\t\t\t\t{labels.eyebrow}\n\t\t\t\t</p>\n\t\t\t\t<h3 className=\"font-display text-xl font-semibold text-foreground md:text-2xl\">\n\t\t\t\t\t{businessName}\n\t\t\t\t</h3>\n\t\t\t\t{officialRating !== undefined ? (\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<span className=\"text-2xl font-bold text-foreground\">\n\t\t\t\t\t\t\t{officialRating.toFixed(1)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<StarRating rating={Math.round(officialRating)} size={20} />\n\t\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{labels.reviewCount(summaryCount)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{labels.reviewCount(summaryCount)}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* Carousel */}\n\t\t\t<div className=\"relative\">\n\t\t\t\t{/* Arrows */}\n\t\t\t\t{total > 1 && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"absolute -left-4 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-input bg-background shadow-md ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={prev}\n\t\t\t\t\t\t\taria-label={labels.prevReview}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"absolute -right-4 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-input bg-background shadow-md ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={next}\n\t\t\t\t\t\t\taria-label={labels.nextReview}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\n\t\t\t\t{/* Card viewport */}\n\t\t\t\t<div className=\"overflow-hidden rounded-lg\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex transition-transform duration-500 ease-in-out\"\n\t\t\t\t\t\tstyle={{ transform: `translateX(-${current * 100}%)` }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{reviews.map((review, i) => (\n\t\t\t\t\t\t\t<div key={i} className=\"w-full flex-shrink-0 px-1\">\n\t\t\t\t\t\t\t\t<ReviewCard review={review} index={i} labels={labels} />\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Dots */}\n\t\t\t\t{total > 1 && (\n\t\t\t\t\t<div className=\"mt-4 flex justify-center gap-2\">\n\t\t\t\t\t\t{reviews.map((_, i) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setCurrent(i)}\n\t\t\t\t\t\t\t\taria-label={labels.goToReview(i + 1)}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"h-2 w-2 rounded-full transition-all\",\n\t\t\t\t\t\t\t\t\ti === current\n\t\t\t\t\t\t\t\t\t\t? \"w-6 bg-primary\"\n\t\t\t\t\t\t\t\t\t\t: \"bg-muted-foreground/30 hover:bg-muted-foreground/50\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* CTA */}\n\t\t\t<div className=\"mt-6 flex justify-center\">\n\t\t\t\t<a\n\t\t\t\t\thref={placeUrl}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"inline-flex h-11 items-center justify-center gap-2 whitespace-nowrap rounded-md border border-input bg-background px-4 py-2 font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{labels.readAllOnGoogle}\n\t\t\t\t\t<ExternalLink className=\"ml-1 h-3.5 w-3.5 shrink-0\" />\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</section>\n\t);\n}\n","import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n\treturn twMerge(clsx(inputs));\n}\n","import { Loader2 } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport GoogleReviewsCarousel, {\n\ttype GoogleReview,\n\ttype GoogleReviewsCarouselProps,\n} from \"./GoogleReviewsCarousel\";\n\n/** JSON body from your reviews API (e.g. `GET /api/reviews` fleet bridge or `GET /api/google-reviews`). */\nexport type CachedGoogleReviewsResponse = {\n\tbusinessName: string;\n\tplaceUrl: string;\n\treviews: GoogleReview[];\n\tfetchedAt: string;\n\tsource: \"live\";\n\t/** Google Places overall rating (all reviews); omit if unknown. */\n\taggregateRating?: number;\n\ttotalReviewCount?: number;\n};\n\nexport type GoogleReviewsCarouselCachedProps = Omit<\n\tGoogleReviewsCarouselProps,\n\t| \"reviews\"\n\t| \"businessName\"\n\t| \"placeUrl\"\n\t| \"aggregateRating\"\n\t| \"totalReviewCount\"\n> & {\n\t/** Resolved server-side via `GOOGLE_REVIEWS_SLUG_MAP` when `placeId` is omitted. */\n\tslug?: string;\n\t/** Google Place ID; avoids slug map. Prefer for single-tenant sites. */\n\tplaceId?: string;\n\t/** Base path or absolute URL; default `/api/reviews` (fleet bridge). Use `/api/google-reviews` for query `placeId`/`slug` APIs. */\n\treviewsApiPath?: string;\n\t/**\n\t * When true, fetches `reviewsApiPath` with no query string; the server must resolve\n\t * `placeId` (e.g. `createVercelHandler` + `GOOGLE_PLACE_ID` / `KERN_API_KEY`; optional `KERN_API_URL` or `VITE_KERN_API_URL` for the central API origin).\n\t */\n\tresolvePlaceOnServer?: boolean;\n};\n\nfunction buildReviewsUrl(\n\tbase: string,\n\tparams: { slug?: string; placeId?: string; resolvePlaceOnServer?: boolean },\n): string {\n\tconst path = base.replace(/\\/$/, \"\");\n\tif (params.resolvePlaceOnServer) {\n\t\treturn path;\n\t}\n\tconst joiner = path.includes(\"?\") ? \"&\" : \"?\";\n\tif (params.placeId?.trim()) {\n\t\treturn `${path}${joiner}placeId=${encodeURIComponent(params.placeId.trim())}`;\n\t}\n\tif (params.slug?.trim()) {\n\t\treturn `${path}${joiner}slug=${encodeURIComponent(params.slug.trim())}`;\n\t}\n\treturn path;\n}\n\nfunction isSuccessPayload(data: unknown): data is CachedGoogleReviewsResponse {\n\tif (!data || typeof data !== \"object\") return false;\n\tconst o = data as Record<string, unknown>;\n\treturn (\n\t\ttypeof o.businessName === \"string\" &&\n\t\ttypeof o.placeUrl === \"string\" &&\n\t\tArray.isArray(o.reviews) &&\n\t\ttypeof o.fetchedAt === \"string\" &&\n\t\ttypeof o.source === \"string\"\n\t);\n}\n\n/**\n * Loads reviews from your backend (`/api/reviews` by default). The server should\n * return cacheable JSON (e.g. Cache-Control at the CDN).\n */\nexport default function GoogleReviewsCarouselCached({\n\tslug,\n\tplaceId,\n\treviewsApiPath = \"/api/reviews\",\n\tresolvePlaceOnServer = false,\n\tlocale = \"de\",\n\t...carouselRest\n}: GoogleReviewsCarouselCachedProps) {\n\tconst [data, setData] = useState<CachedGoogleReviewsResponse | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\tconst [loading, setLoading] = useState(true);\n\n\tuseEffect(() => {\n\t\tlet cancelled = false;\n\t\tconst url = buildReviewsUrl(reviewsApiPath, {\n\t\t\tslug,\n\t\t\tplaceId,\n\t\t\tresolvePlaceOnServer,\n\t\t});\n\n\t\t(async () => {\n\t\t\tsetLoading(true);\n\t\t\tsetError(null);\n\t\t\tif (!resolvePlaceOnServer && !slug?.trim() && !placeId?.trim()) {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"slug oder placeId ist erforderlich.\"\n\t\t\t\t\t\t\t: \"Either slug or placeId is required.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\tsetLoading(false);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\t// Avoid stale browser/proxy JSON missing aggregateRating / totalReviewCount.\n\t\t\t\tconst res = await fetch(url, { cache: \"no-store\" });\n\t\t\t\tconst json: unknown = await res.json().catch(() => null);\n\t\t\t\tif (cancelled) return;\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tconst msg =\n\t\t\t\t\t\tjson &&\n\t\t\t\t\t\ttypeof json === \"object\" &&\n\t\t\t\t\t\t\"error\" in json &&\n\t\t\t\t\t\ttypeof (json as { error: unknown }).error === \"string\"\n\t\t\t\t\t\t\t? (json as { error: string }).error\n\t\t\t\t\t\t\t: res.statusText;\n\t\t\t\t\tsetError(msg);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!isSuccessPayload(json)) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"Ungültige Antwort vom Server.\"\n\t\t\t\t\t\t\t: \"Invalid server response.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetData(json);\n\t\t\t} catch {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"Bewertungen konnten nicht geladen werden.\"\n\t\t\t\t\t\t\t: \"Could not load reviews.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tif (!cancelled) setLoading(false);\n\t\t\t}\n\t\t})();\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t};\n\t}, [slug, placeId, reviewsApiPath, resolvePlaceOnServer, locale]);\n\n\tif (loading) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"mx-auto flex w-full max-w-2xl justify-center py-16 text-muted-foreground\"\n\t\t\t\trole=\"status\"\n\t\t\t\taria-live=\"polite\"\n\t\t\t>\n\t\t\t\t<Loader2 className=\"h-8 w-8 animate-spin\" aria-hidden />\n\t\t\t</div>\n\t\t);\n\t}\n\n\tif (error || !data) {\n\t\treturn (\n\t\t\t<p\n\t\t\t\tclassName=\"mx-auto max-w-2xl text-center text-sm text-muted-foreground\"\n\t\t\t\trole=\"alert\"\n\t\t\t>\n\t\t\t\t{error ??\n\t\t\t\t\t(locale === \"de\"\n\t\t\t\t\t\t? \"Keine Bewertungen verfügbar.\"\n\t\t\t\t\t\t: \"No reviews available.\")}\n\t\t\t</p>\n\t\t);\n\t}\n\n\tif (data.reviews.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<GoogleReviewsCarousel\n\t\t\t{...carouselRest}\n\t\t\tlocale={locale}\n\t\t\treviews={data.reviews}\n\t\t\tbusinessName={data.businessName}\n\t\t\tplaceUrl={data.placeUrl}\n\t\t\taggregateRating={data.aggregateRating}\n\t\t\ttotalReviewCount={data.totalReviewCount}\n\t\t/>\n\t);\n}\n"],"mappings":"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","names":["useEffect","useState","jsx","useState","useEffect"]}
1
+ {"version":3,"sources":["../src/components/GoogleReviewsCarousel.tsx","../src/lib/cn.ts","../src/components/GoogleReviewsCarouselCached.tsx"],"sourcesContent":["import { ChevronLeft, ChevronRight, ExternalLink, Star } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { cn } from \"../lib/cn\";\n\n// ---------- Types ----------\nexport interface GoogleReview {\n\tauthor: string;\n\trating: number;\n\tdate: string;\n\ttext: string;\n\t/** HTTPS is enforced; broken or blocked URLs fall back to initials (e.g. some Google CDN responses). */\n\tphotoUrl?: string;\n}\n\nexport type GoogleReviewsCarouselLocale = \"de\" | \"en\";\n\nexport interface GoogleReviewsCarouselProps {\n\treviews: GoogleReview[];\n\tbusinessName: string;\n\tplaceUrl: string;\n\t/**\n\t * Official overall rating from Google Places (all reviews). The header score\n\t * and stars are shown only when this is set — never derived from the\n\t * `reviews` sample.\n\t */\n\taggregateRating?: number;\n\t/** Official total review count from Google Places. When omitted, uses `reviews.length`. */\n\ttotalReviewCount?: number;\n\t/** UI copy. Default `de` matches KERN Handwerk sites. */\n\tlocale?: GoogleReviewsCarouselLocale;\n\taccentColor?: string;\n\tautoPlay?: boolean;\n\tautoPlaySpeed?: number;\n}\n\nconst UI_STRINGS: Record<\n\tGoogleReviewsCarouselLocale,\n\t{\n\t\teyebrow: string;\n\t\treviewCount: (n: number) => string;\n\t\treadMore: string;\n\t\tshowLess: string;\n\t\treadAllOnGoogle: string;\n\t\tprevReview: string;\n\t\tnextReview: string;\n\t\tgoToReview: (i: number) => string;\n\t}\n> = {\n\tde: {\n\t\teyebrow: \"Google-Bewertungen\",\n\t\treviewCount: (n) => `(${n} ${n === 1 ? \"Bewertung\" : \"Bewertungen\"})`,\n\t\treadMore: \"Mehr anzeigen\",\n\t\tshowLess: \"Weniger anzeigen\",\n\t\treadAllOnGoogle: \"Alle Bewertungen bei Google\",\n\t\tprevReview: \"Vorherige Bewertung\",\n\t\tnextReview: \"Nächste Bewertung\",\n\t\tgoToReview: (i) => `Zu Bewertung ${i}`,\n\t},\n\ten: {\n\t\teyebrow: \"Google reviews\",\n\t\treviewCount: (n) => `(${n} ${n === 1 ? \"review\" : \"reviews\"})`,\n\t\treadMore: \"Read more\",\n\t\tshowLess: \"Show less\",\n\t\treadAllOnGoogle: \"Read all reviews on Google\",\n\t\tprevReview: \"Previous review\",\n\t\tnextReview: \"Next review\",\n\t\tgoToReview: (i) => `Go to review ${i}`,\n\t},\n};\n\n// ---------- Sample data ----------\nexport const SAMPLE_REVIEWS: GoogleReview[] = [\n\t{\n\t\tauthor: \"Sarah Mitchell\",\n\t\trating: 5,\n\t\tdate: \"2 weeks ago\",\n\t\ttext: \"Absolutely fantastic service! They fixed our burst pipe on a Sunday evening within an hour of calling. The plumber was professional, tidy, and explained everything clearly. Pricing was very fair — no hidden charges. Highly recommend to anyone needing reliable plumbing work.\",\n\t\tphotoUrl: \"https://i.pravatar.cc/128?img=47\",\n\t},\n\t{\n\t\tauthor: \"James Thornton\",\n\t\trating: 5,\n\t\tdate: \"1 month ago\",\n\t\ttext: \"Had a full rewire done on our 1930s semi. The team were punctual every single day, kept the house as clean as possible, and the finish is immaculate. They even helped us plan the socket layout for our new kitchen. Top-notch electricians.\",\n\t\tphotoUrl: \"https://i.pravatar.cc/128?img=12\",\n\t},\n\t{\n\t\tauthor: \"Maria Chen\",\n\t\trating: 5,\n\t\tdate: \"3 weeks ago\",\n\t\ttext: \"Called them for an emergency boiler repair in the middle of winter. They came out the same day and had the heating back on within two hours. Genuine lifesavers — we have young kids and couldn't have waited. Will use again for our annual service.\",\n\t},\n\t{\n\t\tauthor: \"David Okonkwo\",\n\t\trating: 4,\n\t\tdate: \"2 months ago\",\n\t\ttext: \"Great job installing our new bathroom. Tiling work is spotless and the underfloor heating is a dream. Only minor hiccup was a small delay waiting for parts, but they kept us updated throughout. Overall very happy with the result.\",\n\t},\n\t{\n\t\tauthor: \"Emma Richardson\",\n\t\trating: 5,\n\t\tdate: \"1 week ago\",\n\t\ttext: \"We've used this company three times now — blocked drain, leaking shower, and a full kitchen tap replacement. Every time the service has been prompt, friendly, and reasonably priced. It's so hard to find tradespeople you can trust, and these guys are the real deal.\",\n\t},\n];\n\n// ---------- Helpers ----------\nconst AVATAR_COLORS = [\n\t\"hsl(210, 60%, 50%)\",\n\t\"hsl(340, 60%, 50%)\",\n\t\"hsl(160, 55%, 42%)\",\n\t\"hsl(30, 70%, 50%)\",\n\t\"hsl(270, 50%, 55%)\",\n];\n\nfunction getInitial(name: string) {\n\treturn name.charAt(0).toUpperCase();\n}\n\nfunction coerceFiniteNumber(value: unknown): number | undefined {\n\tif (value == null) return undefined;\n\tif (typeof value === \"number\" && Number.isFinite(value)) return value;\n\tif (typeof value === \"string\") {\n\t\tconst n = Number.parseFloat(value.trim().replace(\",\", \".\"));\n\t\tif (Number.isFinite(n)) return n;\n\t}\n\treturn undefined;\n}\n\nfunction coerceNonNegativeInt(value: unknown): number | undefined {\n\tif (value == null) return undefined;\n\tif (typeof value === \"number\" && Number.isInteger(value) && value >= 0) {\n\t\treturn value;\n\t}\n\tif (typeof value === \"string\" && /^\\s*\\d+\\s*$/.test(value)) {\n\t\tconst n = Number.parseInt(value.trim(), 10);\n\t\tif (Number.isFinite(n) && n >= 0) return n;\n\t}\n\treturn undefined;\n}\n\n/** Google profile URLs are often `http://` or protocol-relative; normalize for reliable loading. */\nfunction normalizeProfilePhotoUrl(url: string | undefined): string | undefined {\n\tif (!url?.trim()) return undefined;\n\tlet u = url.trim();\n\tif (u.startsWith(\"//\")) u = `https:${u}`;\n\telse if (/^http:\\/\\//i.test(u)) u = `https://${u.slice(7)}`;\n\treturn u;\n}\n\nconst DEFAULT_STAR_COLOR = \"#FBBC05\";\nconst DEFAULT_STAR_EMPTY_COLOR = \"rgba(120, 120, 120, 0.35)\";\n\nfunction StarRating({\n\trating,\n\tsize = 16,\n\tcolor = DEFAULT_STAR_COLOR,\n\temptyColor = DEFAULT_STAR_EMPTY_COLOR,\n}: {\n\trating: number;\n\tsize?: number;\n\tcolor?: string;\n\temptyColor?: string;\n}) {\n\treturn (\n\t\t<span className=\"inline-flex gap-0.5\">\n\t\t\t{Array.from({ length: 5 }).map((_, i) => (\n\t\t\t\t<Star\n\t\t\t\t\tkey={i}\n\t\t\t\t\tsize={size}\n\t\t\t\t\tfill={i < rating ? \"currentColor\" : \"none\"}\n\t\t\t\t\tstyle={{\n\t\t\t\t\t\tcolor: i < rating ? color : emptyColor,\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</span>\n\t);\n}\n\nfunction GoogleLogo({ className }: { className?: string }) {\n\treturn (\n\t\t<svg\n\t\t\tclassName={className}\n\t\t\tviewBox=\"0 0 272 92\"\n\t\t\txmlns=\"http://www.w3.org/2000/svg\"\n\t\t>\n\t\t\t<path\n\t\t\t\td=\"M115.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18C71.25 34.32 81.24 25 93.5 25s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44S80.99 39.2 80.99 47.18c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z\"\n\t\t\t\tfill=\"#EA4335\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M163.75 47.18c0 12.77-9.99 22.18-22.25 22.18s-22.25-9.41-22.25-22.18c0-12.85 9.99-22.18 22.25-22.18s22.25 9.32 22.25 22.18zm-9.74 0c0-7.98-5.79-13.44-12.51-13.44s-12.51 5.46-12.51 13.44c0 7.9 5.79 13.44 12.51 13.44s12.51-5.55 12.51-13.44z\"\n\t\t\t\tfill=\"#FBBC05\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M209.75 26.34v39.82c0 16.38-9.66 23.07-21.08 23.07-10.75 0-17.22-7.19-19.66-13.07l8.48-3.53c1.51 3.61 5.21 7.87 11.17 7.87 7.31 0 11.84-4.51 11.84-13v-3.19h-.34c-2.18 2.69-6.38 5.04-11.68 5.04-11.09 0-21.25-9.66-21.25-22.09 0-12.52 10.16-22.26 21.25-22.26 5.29 0 9.49 2.35 11.68 4.96h.34v-3.61h9.25zm-8.56 20.92c0-7.81-5.21-13.52-11.84-13.52-6.72 0-12.35 5.71-12.35 13.52 0 7.73 5.63 13.36 12.35 13.36 6.63 0 11.84-5.63 11.84-13.36z\"\n\t\t\t\tfill=\"#4285F4\"\n\t\t\t/>\n\t\t\t<path d=\"M225 3v65h-9.5V3h9.5z\" fill=\"#34A853\" />\n\t\t\t<path\n\t\t\t\td=\"M262.02 54.48l7.56 5.04c-2.44 3.61-8.32 9.83-18.48 9.83-12.6 0-22.01-9.74-22.01-22.18 0-13.19 9.49-22.18 20.92-22.18 11.51 0 17.14 9.16 18.98 14.11l1.01 2.52-29.65 12.28c2.27 4.45 5.8 6.72 10.75 6.72 4.96 0 8.4-2.44 10.92-6.14zm-23.27-7.98l19.82-8.23c-1.09-2.77-4.37-4.7-8.23-4.7-4.95 0-11.84 4.37-11.59 12.93z\"\n\t\t\t\tfill=\"#EA4335\"\n\t\t\t/>\n\t\t\t<path\n\t\t\t\td=\"M35.29 41.19V32H67c.31 1.64.47 3.58.47 5.68 0 7.06-1.93 15.79-8.15 22.01-6.05 6.3-13.78 9.66-24.02 9.66C16.32 69.35.36 53.89.36 34.91.36 15.93 16.32.47 35.3.47c10.5 0 17.98 4.12 23.6 9.49l-6.64 6.64c-4.03-3.78-9.49-6.72-16.97-6.72-13.86 0-24.7 11.17-24.7 25.03 0 13.86 10.84 25.03 24.7 25.03 8.99 0 14.11-3.61 17.39-6.89 2.66-2.66 4.41-6.46 5.1-11.65l-22.49-.01z\"\n\t\t\t\tfill=\"#4285F4\"\n\t\t\t/>\n\t\t</svg>\n\t);\n}\n\n// ---------- Review Card ----------\nfunction ReviewCard({\n\treview,\n\tindex,\n\tlabels,\n\taccentColor,\n}: {\n\treview: GoogleReview;\n\tindex: number;\n\tlabels: (typeof UI_STRINGS)[GoogleReviewsCarouselLocale];\n\taccentColor?: string;\n}) {\n\tconst [expanded, setExpanded] = useState(false);\n\tconst [imgFailed, setImgFailed] = useState(false);\n\tconst shouldTruncate = review.text.length > 150;\n\tconst photoSrc = normalizeProfilePhotoUrl(review.photoUrl);\n\n\tuseEffect(() => {\n\t\tsetImgFailed(false);\n\t}, [photoSrc]);\n\n\tconst showPhoto = Boolean(photoSrc) && !imgFailed;\n\n\treturn (\n\t\t<div className=\"flex h-full flex-col rounded-lg border bg-card p-5 shadow-sm\">\n\t\t\t{/* Author row */}\n\t\t\t<div className=\"mb-3 flex items-center gap-3\">\n\t\t\t\t{showPhoto ? (\n\t\t\t\t\t<img\n\t\t\t\t\t\tsrc={photoSrc}\n\t\t\t\t\t\talt=\"\"\n\t\t\t\t\t\tclassName=\"h-10 w-10 rounded-full object-cover\"\n\t\t\t\t\t\tloading=\"lazy\"\n\t\t\t\t\t\tdecoding=\"async\"\n\t\t\t\t\t\tonError={() => setImgFailed(true)}\n\t\t\t\t\t/>\n\t\t\t\t) : (\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold text-white\"\n\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\tbackgroundColor: AVATAR_COLORS[index % AVATAR_COLORS.length],\n\t\t\t\t\t\t}}\n\t\t\t\t\t\taria-hidden\n\t\t\t\t\t>\n\t\t\t\t\t\t{getInitial(review.author)}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t<p className=\"truncate text-sm font-medium text-foreground\">\n\t\t\t\t\t\t{review.author}\n\t\t\t\t\t</p>\n\t\t\t\t\t<p className=\"text-xs text-muted-foreground\">{review.date}</p>\n\t\t\t\t</div>\n\t\t\t</div>\n\n\t\t\t{/* Stars */}\n\t\t\t<StarRating rating={review.rating} color={accentColor} />\n\n\t\t\t{/* Text */}\n\t\t\t<p className=\"mt-3 flex-1 text-sm leading-relaxed text-foreground/80\">\n\t\t\t\t{shouldTruncate && !expanded\n\t\t\t\t\t? review.text.slice(0, 150) + \"…\"\n\t\t\t\t\t: review.text}\n\t\t\t</p>\n\t\t\t{shouldTruncate && (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => setExpanded(!expanded)}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"mt-1 inline-flex h-auto self-start p-0 text-xs font-medium text-primary underline-offset-4 hover:underline\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{expanded ? labels.showLess : labels.readMore}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n\n// ---------- Main Component ----------\nexport default function GoogleReviewsCarousel({\n\treviews,\n\tbusinessName,\n\tplaceUrl,\n\taggregateRating: aggregateRatingProp,\n\ttotalReviewCount: totalReviewCountProp,\n\tlocale = \"de\",\n\taccentColor,\n\tautoPlay = true,\n\tautoPlaySpeed = 5000,\n}: GoogleReviewsCarouselProps) {\n\tconst labels = UI_STRINGS[locale];\n\tconst [current, setCurrent] = useState(0);\n\tconst [paused, setPaused] = useState(false);\n\tconst total = reviews.length;\n\n\tconst next = useCallback(() => setCurrent((c) => (c + 1) % total), [total]);\n\tconst prev = useCallback(\n\t\t() => setCurrent((c) => (c - 1 + total) % total),\n\t\t[total],\n\t);\n\n\tuseEffect(() => {\n\t\tif (!autoPlay || paused || total <= 1) return;\n\t\tconst id = setInterval(next, autoPlaySpeed);\n\t\treturn () => clearInterval(id);\n\t}, [autoPlay, autoPlaySpeed, paused, next, total]);\n\n\tif (total === 0) return null;\n\n\tconst officialTotal = coerceNonNegativeInt(totalReviewCountProp);\n\tconst summaryCount = officialTotal ?? total;\n\tconst officialRating = coerceFiniteNumber(aggregateRatingProp);\n\n\treturn (\n\t\t<section\n\t\t\tclassName=\"mx-auto w-full max-w-3xl\"\n\t\t\tonMouseEnter={() => setPaused(true)}\n\t\t\tonMouseLeave={() => setPaused(false)}\n\t\t>\n\t\t\t{/* Header */}\n\t\t\t<div className=\"mb-10 flex flex-col items-center gap-4 py-2 text-center\">\n\t\t\t\t<GoogleLogo className=\"h-8\" />\n\t\t\t\t<p className=\"text-xs font-medium uppercase tracking-widest text-muted-foreground\">\n\t\t\t\t\t{labels.eyebrow}\n\t\t\t\t</p>\n\t\t\t\t<h3 className=\"font-display text-xl font-semibold text-foreground md:text-2xl\">\n\t\t\t\t\t{businessName}\n\t\t\t\t</h3>\n\t\t\t\t{officialRating !== undefined ? (\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t<span className=\"text-2xl font-bold text-foreground\">\n\t\t\t\t\t\t\t{officialRating.toFixed(1)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<StarRating\n\t\t\t\t\t\t\trating={Math.round(officialRating)}\n\t\t\t\t\t\t\tsize={20}\n\t\t\t\t\t\t\tcolor={accentColor}\n\t\t\t\t\t\t/>\n\t\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t\t{labels.reviewCount(summaryCount)}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</div>\n\t\t\t\t) : (\n\t\t\t\t\t<span className=\"text-sm text-muted-foreground\">\n\t\t\t\t\t\t{labels.reviewCount(summaryCount)}\n\t\t\t\t\t</span>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* Carousel */}\n\t\t\t<div className=\"relative\">\n\t\t\t\t{/* Arrows */}\n\t\t\t\t{total > 1 && (\n\t\t\t\t\t<>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"absolute -left-4 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-input bg-background shadow-md ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={prev}\n\t\t\t\t\t\t\taria-label={labels.prevReview}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronLeft className=\"h-4 w-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\"absolute -right-4 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-input bg-background shadow-md ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\tonClick={next}\n\t\t\t\t\t\t\taria-label={labels.nextReview}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronRight className=\"h-4 w-4\" />\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</>\n\t\t\t\t)}\n\n\t\t\t\t{/* Card viewport */}\n\t\t\t\t<div className=\"overflow-hidden rounded-lg\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclassName=\"flex transition-transform duration-500 ease-in-out\"\n\t\t\t\t\t\tstyle={{ transform: `translateX(-${current * 100}%)` }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{reviews.map((review, i) => (\n\t\t\t\t\t\t\t<div key={i} className=\"w-full flex-shrink-0 px-1\">\n\t\t\t\t\t\t\t\t<ReviewCard\n\t\t\t\t\t\t\t\t\treview={review}\n\t\t\t\t\t\t\t\t\tindex={i}\n\t\t\t\t\t\t\t\t\tlabels={labels}\n\t\t\t\t\t\t\t\t\taccentColor={accentColor}\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t{/* Dots */}\n\t\t\t\t{total > 1 && (\n\t\t\t\t\t<div className=\"mt-4 flex justify-center gap-2\">\n\t\t\t\t\t\t{reviews.map((_, i) => (\n\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\tkey={i}\n\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\tonClick={() => setCurrent(i)}\n\t\t\t\t\t\t\t\taria-label={labels.goToReview(i + 1)}\n\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\"h-2 w-2 rounded-full transition-all\",\n\t\t\t\t\t\t\t\t\ti === current\n\t\t\t\t\t\t\t\t\t\t? \"w-6 bg-primary\"\n\t\t\t\t\t\t\t\t\t\t: \"bg-muted-foreground/30 hover:bg-muted-foreground/50\",\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t)}\n\t\t\t</div>\n\n\t\t\t{/* CTA */}\n\t\t\t<div className=\"mt-6 flex justify-center\">\n\t\t\t\t<a\n\t\t\t\t\thref={placeUrl}\n\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\trel=\"noopener noreferrer\"\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\"inline-flex h-11 items-center justify-center gap-2 whitespace-nowrap rounded-md border border-input bg-background px-4 py-2 font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t{labels.readAllOnGoogle}\n\t\t\t\t\t<ExternalLink className=\"ml-1 h-3.5 w-3.5 shrink-0\" />\n\t\t\t\t</a>\n\t\t\t</div>\n\t\t</section>\n\t);\n}\n","import { type ClassValue, clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n\treturn twMerge(clsx(inputs));\n}\n","import { Loader2 } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport GoogleReviewsCarousel, {\n\ttype GoogleReview,\n\ttype GoogleReviewsCarouselProps,\n} from \"./GoogleReviewsCarousel\";\n\n/** JSON body from your reviews API (e.g. `GET /api/reviews` fleet bridge or `GET /api/google-reviews`). */\nexport type CachedGoogleReviewsResponse = {\n\tbusinessName: string;\n\tplaceUrl: string;\n\treviews: GoogleReview[];\n\tfetchedAt: string;\n\tsource: \"live\";\n\t/** Google Places overall rating (all reviews); omit if unknown. */\n\taggregateRating?: number;\n\ttotalReviewCount?: number;\n};\n\nexport type GoogleReviewsCarouselCachedProps = Omit<\n\tGoogleReviewsCarouselProps,\n\t| \"reviews\"\n\t| \"businessName\"\n\t| \"placeUrl\"\n\t| \"aggregateRating\"\n\t| \"totalReviewCount\"\n> & {\n\t/** Resolved server-side via `GOOGLE_REVIEWS_SLUG_MAP` when `placeId` is omitted. */\n\tslug?: string;\n\t/** Google Place ID; avoids slug map. Prefer for single-tenant sites. */\n\tplaceId?: string;\n\t/** Base path or absolute URL; default `/api/reviews` (fleet bridge). Use `/api/google-reviews` for query `placeId`/`slug` APIs. */\n\treviewsApiPath?: string;\n\t/**\n\t * When true, fetches `reviewsApiPath` with no query string; the server must resolve\n\t * `placeId` (e.g. `createVercelHandler` + `GOOGLE_PLACE_ID` / `KERN_API_KEY`; optional `KERN_API_URL` or `VITE_KERN_API_URL` for the central API origin).\n\t */\n\tresolvePlaceOnServer?: boolean;\n};\n\nfunction buildReviewsUrl(\n\tbase: string,\n\tparams: { slug?: string; placeId?: string; resolvePlaceOnServer?: boolean },\n\tplacesLanguage: string,\n): string {\n\tconst path = base.replace(/\\/$/, \"\");\n\tlet url: string;\n\tif (params.resolvePlaceOnServer) {\n\t\turl = path;\n\t} else {\n\t\tconst joiner = path.includes(\"?\") ? \"&\" : \"?\";\n\t\tif (params.placeId?.trim()) {\n\t\t\turl = `${path}${joiner}placeId=${encodeURIComponent(params.placeId.trim())}`;\n\t\t} else if (params.slug?.trim()) {\n\t\t\turl = `${path}${joiner}slug=${encodeURIComponent(params.slug.trim())}`;\n\t\t} else {\n\t\t\turl = path;\n\t\t}\n\t}\n\tconst langJoin = url.includes(\"?\") ? \"&\" : \"?\";\n\treturn `${url}${langJoin}language=${encodeURIComponent(placesLanguage)}`;\n}\n\nfunction isSuccessPayload(data: unknown): data is CachedGoogleReviewsResponse {\n\tif (!data || typeof data !== \"object\") return false;\n\tconst o = data as Record<string, unknown>;\n\treturn (\n\t\ttypeof o.businessName === \"string\" &&\n\t\ttypeof o.placeUrl === \"string\" &&\n\t\tArray.isArray(o.reviews) &&\n\t\ttypeof o.fetchedAt === \"string\" &&\n\t\ttypeof o.source === \"string\"\n\t);\n}\n\n/**\n * Loads reviews from your backend (`/api/reviews` by default). The server should\n * return cacheable JSON (e.g. Cache-Control at the CDN).\n */\nexport default function GoogleReviewsCarouselCached({\n\tslug,\n\tplaceId,\n\treviewsApiPath = \"/api/reviews\",\n\tresolvePlaceOnServer = false,\n\tlocale = \"de\",\n\t...carouselRest\n}: GoogleReviewsCarouselCachedProps) {\n\tconst [data, setData] = useState<CachedGoogleReviewsResponse | null>(null);\n\tconst [error, setError] = useState<string | null>(null);\n\tconst [loading, setLoading] = useState(true);\n\n\tuseEffect(() => {\n\t\tlet cancelled = false;\n\t\tconst placesLanguage = locale === \"en\" ? \"en\" : \"de\";\n\t\tconst url = buildReviewsUrl(\n\t\t\treviewsApiPath,\n\t\t\t{\n\t\t\t\tslug,\n\t\t\t\tplaceId,\n\t\t\t\tresolvePlaceOnServer,\n\t\t\t},\n\t\t\tplacesLanguage,\n\t\t);\n\n\t\t(async () => {\n\t\t\tsetLoading(true);\n\t\t\tsetError(null);\n\t\t\tif (!resolvePlaceOnServer && !slug?.trim() && !placeId?.trim()) {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"slug oder placeId ist erforderlich.\"\n\t\t\t\t\t\t\t: \"Either slug or placeId is required.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\tsetLoading(false);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\t\t\ttry {\n\t\t\t\t// Avoid stale browser/proxy JSON missing aggregateRating / totalReviewCount.\n\t\t\t\tconst res = await fetch(url, { cache: \"no-store\" });\n\t\t\t\tconst json: unknown = await res.json().catch(() => null);\n\t\t\t\tif (cancelled) return;\n\t\t\t\tif (!res.ok) {\n\t\t\t\t\tconst msg =\n\t\t\t\t\t\tjson &&\n\t\t\t\t\t\ttypeof json === \"object\" &&\n\t\t\t\t\t\t\"error\" in json &&\n\t\t\t\t\t\ttypeof (json as { error: unknown }).error === \"string\"\n\t\t\t\t\t\t\t? (json as { error: string }).error\n\t\t\t\t\t\t\t: res.statusText;\n\t\t\t\t\tsetError(msg);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!isSuccessPayload(json)) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"Ungültige Antwort vom Server.\"\n\t\t\t\t\t\t\t: \"Invalid server response.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsetData(json);\n\t\t\t} catch {\n\t\t\t\tif (!cancelled) {\n\t\t\t\t\tsetError(\n\t\t\t\t\t\tlocale === \"de\"\n\t\t\t\t\t\t\t? \"Bewertungen konnten nicht geladen werden.\"\n\t\t\t\t\t\t\t: \"Could not load reviews.\",\n\t\t\t\t\t);\n\t\t\t\t\tsetData(null);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tif (!cancelled) setLoading(false);\n\t\t\t}\n\t\t})();\n\n\t\treturn () => {\n\t\t\tcancelled = true;\n\t\t};\n\t}, [slug, placeId, reviewsApiPath, resolvePlaceOnServer, locale]);\n\n\tif (loading) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"mx-auto flex w-full max-w-2xl justify-center py-16 text-muted-foreground\"\n\t\t\t\trole=\"status\"\n\t\t\t\taria-live=\"polite\"\n\t\t\t>\n\t\t\t\t<Loader2 className=\"h-8 w-8 animate-spin\" aria-hidden />\n\t\t\t</div>\n\t\t);\n\t}\n\n\tif (error || !data) {\n\t\treturn (\n\t\t\t<p\n\t\t\t\tclassName=\"mx-auto max-w-2xl text-center text-sm text-muted-foreground\"\n\t\t\t\trole=\"alert\"\n\t\t\t>\n\t\t\t\t{error ??\n\t\t\t\t\t(locale === \"de\"\n\t\t\t\t\t\t? \"Keine Bewertungen verfügbar.\"\n\t\t\t\t\t\t: \"No reviews available.\")}\n\t\t\t</p>\n\t\t);\n\t}\n\n\tif (data.reviews.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<GoogleReviewsCarousel\n\t\t\t{...carouselRest}\n\t\t\tlocale={locale}\n\t\t\treviews={data.reviews}\n\t\t\tbusinessName={data.businessName}\n\t\t\tplaceUrl={data.placeUrl}\n\t\t\taggregateRating={data.aggregateRating}\n\t\t\ttotalReviewCount={data.totalReviewCount}\n\t\t/>\n\t);\n}\n"],"mappings":"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","names":["useEffect","useState","jsx","useState","useEffect"]}
package/dist/server.d.ts CHANGED
@@ -19,18 +19,6 @@ type CreateVercelHandlerConfig = {
19
19
  placeId: string;
20
20
  apiUrl?: string;
21
21
  };
22
- /**
23
- * Vercel Node serverless handler that proxies to the company central API
24
- * `GET /api/google-reviews` with bearer auth. Use as `api/reviews.ts` on fleet sites.
25
- *
26
- * **Origin** from `config.apiUrl`, else **`KERN_API_URL`** or **`VITE_KERN_API_URL`**
27
- * (Vite `.env` is often exposed as the latter on Vercel), else production default.
28
- *
29
- * For **Vite `pnpm dev`**, add `trustCarouselReviewsVitePlugin` from `@kern-di/trust-carousel/vite-plugin`
30
- * so `/api/reviews` exists without `vercel dev`.
31
- *
32
- * Set `KERN_REVIEWS_DEBUG=1` to log the resolved upstream URL (no secrets) and HTTP status.
33
- */
34
22
  declare function createVercelHandler(config: CreateVercelHandlerConfig): (req: VercelRequest, res: VercelResponse) => Promise<void>;
35
23
 
36
24
  export { type CreateVercelHandlerConfig, DEFAULT_KERN_API_URL, KERN_API_GOOGLE_REVIEWS_PATH, type ReviewsBridgeEnv, createVercelHandler, resolveKernApiOrigin };
package/dist/server.js CHANGED
@@ -3,9 +3,15 @@ import {
3
3
  KERN_API_GOOGLE_REVIEWS_PATH,
4
4
  executeReviewsProxy,
5
5
  resolveKernApiOrigin
6
- } from "./chunk-QCKKYPGS.js";
6
+ } from "./chunk-DDRJRV5Q.js";
7
7
 
8
8
  // src/server.ts
9
+ function firstQueryParam(query, key) {
10
+ const v = query[key];
11
+ if (typeof v === "string") return v;
12
+ if (Array.isArray(v) && typeof v[0] === "string") return v[0];
13
+ return void 0;
14
+ }
9
15
  function createVercelHandler(config) {
10
16
  return async (req, res) => {
11
17
  if (req.method === "OPTIONS") {
@@ -17,10 +23,12 @@ function createVercelHandler(config) {
17
23
  return;
18
24
  }
19
25
  const env = process.env;
26
+ const language = firstQueryParam(req.query, "language") ?? firstQueryParam(req.query, "lang");
20
27
  const result = await executeReviewsProxy({
21
28
  apiKey: config.apiKey,
22
29
  placeId: config.placeId,
23
30
  apiUrl: config.apiUrl,
31
+ language,
24
32
  env
25
33
  });
26
34
  for (const [k, v] of Object.entries(result.headers)) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts"],"sourcesContent":["import type { VercelRequest, VercelResponse } from \"@vercel/node\";\nimport {\n\texecuteReviewsProxy,\n\ttype ReviewsBridgeEnv,\n} from \"./reviewsBridgeCore.js\";\n\nexport {\n\tDEFAULT_KERN_API_URL,\n\tKERN_API_GOOGLE_REVIEWS_PATH,\n\tresolveKernApiOrigin,\n\ttype ReviewsBridgeEnv,\n} from \"./reviewsBridgeCore.js\";\n\nexport type CreateVercelHandlerConfig = {\n\tapiKey: string;\n\tplaceId: string;\n\tapiUrl?: string;\n};\n\n/**\n * Vercel Node serverless handler that proxies to the company central API\n * `GET /api/google-reviews` with bearer auth. Use as `api/reviews.ts` on fleet sites.\n *\n * **Origin** from `config.apiUrl`, else **`KERN_API_URL`** or **`VITE_KERN_API_URL`**\n * (Vite `.env` is often exposed as the latter on Vercel), else production default.\n *\n * For **Vite `pnpm dev`**, add `trustCarouselReviewsVitePlugin` from `@kern-di/trust-carousel/vite-plugin`\n * so `/api/reviews` exists without `vercel dev`.\n *\n * Set `KERN_REVIEWS_DEBUG=1` to log the resolved upstream URL (no secrets) and HTTP status.\n */\nexport function createVercelHandler(\n\tconfig: CreateVercelHandlerConfig,\n): (req: VercelRequest, res: VercelResponse) => Promise<void> {\n\treturn async (req: VercelRequest, res: VercelResponse) => {\n\t\tif (req.method === \"OPTIONS\") {\n\t\t\tres.status(204).end();\n\t\t\treturn;\n\t\t}\n\t\tif (req.method !== \"GET\") {\n\t\t\tres.status(405).json({ error: \"Method not allowed\" });\n\t\t\treturn;\n\t\t}\n\n\t\tconst env = process.env as ReviewsBridgeEnv;\n\t\tconst result = await executeReviewsProxy({\n\t\t\tapiKey: config.apiKey,\n\t\t\tplaceId: config.placeId,\n\t\t\tapiUrl: config.apiUrl,\n\t\t\tenv,\n\t\t});\n\n\t\tfor (const [k, v] of Object.entries(result.headers)) {\n\t\t\tres.setHeader(k, v);\n\t\t}\n\t\tres.status(result.status).send(result.body);\n\t};\n}\n"],"mappings":";;;;;;;;AA+BO,SAAS,oBACf,QAC6D;AAC7D,SAAO,OAAO,KAAoB,QAAwB;AACzD,QAAI,IAAI,WAAW,WAAW;AAC7B,UAAI,OAAO,GAAG,EAAE,IAAI;AACpB;AAAA,IACD;AACA,QAAI,IAAI,WAAW,OAAO;AACzB,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,qBAAqB,CAAC;AACpD;AAAA,IACD;AAEA,UAAM,MAAM,QAAQ;AACpB,UAAM,SAAS,MAAM,oBAAoB;AAAA,MACxC,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,QAAQ,OAAO;AAAA,MACf;AAAA,IACD,CAAC;AAED,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,OAAO,OAAO,GAAG;AACpD,UAAI,UAAU,GAAG,CAAC;AAAA,IACnB;AACA,QAAI,OAAO,OAAO,MAAM,EAAE,KAAK,OAAO,IAAI;AAAA,EAC3C;AACD;","names":[]}
1
+ {"version":3,"sources":["../src/server.ts"],"sourcesContent":["import type { VercelRequest, VercelResponse } from \"@vercel/node\";\nimport {\n\ttype ReviewsBridgeEnv,\n\texecuteReviewsProxy,\n} from \"./reviewsBridgeCore.js\";\n\nexport {\n\tDEFAULT_KERN_API_URL,\n\tKERN_API_GOOGLE_REVIEWS_PATH,\n\tresolveKernApiOrigin,\n\ttype ReviewsBridgeEnv,\n} from \"./reviewsBridgeCore.js\";\n\nexport type CreateVercelHandlerConfig = {\n\tapiKey: string;\n\tplaceId: string;\n\tapiUrl?: string;\n};\n\n/**\n * Vercel Node serverless handler that proxies to the company central API\n * `GET /api/google-reviews` with bearer auth. Use as `api/reviews.ts` on fleet sites.\n *\n * **Origin** from `config.apiUrl`, else **`KERN_API_URL`** or **`VITE_KERN_API_URL`**\n * (Vite `.env` is often exposed as the latter on Vercel), else production default.\n *\n * For **Vite `pnpm dev`**, add `trustCarouselReviewsVitePlugin` from `@kern-di/trust-carousel/vite-plugin`\n * so `/api/reviews` exists without `vercel dev`.\n *\n * Set `KERN_REVIEWS_DEBUG=1` to log the resolved upstream URL (no secrets) and HTTP status.\n */\nfunction firstQueryParam(\n\tquery: VercelRequest[\"query\"],\n\tkey: string,\n): string | undefined {\n\tconst v = query[key];\n\tif (typeof v === \"string\") return v;\n\tif (Array.isArray(v) && typeof v[0] === \"string\") return v[0];\n\treturn undefined;\n}\n\nexport function createVercelHandler(\n\tconfig: CreateVercelHandlerConfig,\n): (req: VercelRequest, res: VercelResponse) => Promise<void> {\n\treturn async (req: VercelRequest, res: VercelResponse) => {\n\t\tif (req.method === \"OPTIONS\") {\n\t\t\tres.status(204).end();\n\t\t\treturn;\n\t\t}\n\t\tif (req.method !== \"GET\") {\n\t\t\tres.status(405).json({ error: \"Method not allowed\" });\n\t\t\treturn;\n\t\t}\n\n\t\tconst env = process.env as ReviewsBridgeEnv;\n\t\tconst language =\n\t\t\tfirstQueryParam(req.query, \"language\") ??\n\t\t\tfirstQueryParam(req.query, \"lang\");\n\t\tconst result = await executeReviewsProxy({\n\t\t\tapiKey: config.apiKey,\n\t\t\tplaceId: config.placeId,\n\t\t\tapiUrl: config.apiUrl,\n\t\t\tlanguage,\n\t\t\tenv,\n\t\t});\n\n\t\tfor (const [k, v] of Object.entries(result.headers)) {\n\t\t\tres.setHeader(k, v);\n\t\t}\n\t\tres.status(result.status).send(result.body);\n\t};\n}\n"],"mappings":";;;;;;;;AA+BA,SAAS,gBACR,OACA,KACqB;AACrB,QAAM,IAAI,MAAM,GAAG;AACnB,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,MAAI,MAAM,QAAQ,CAAC,KAAK,OAAO,EAAE,CAAC,MAAM,SAAU,QAAO,EAAE,CAAC;AAC5D,SAAO;AACR;AAEO,SAAS,oBACf,QAC6D;AAC7D,SAAO,OAAO,KAAoB,QAAwB;AACzD,QAAI,IAAI,WAAW,WAAW;AAC7B,UAAI,OAAO,GAAG,EAAE,IAAI;AACpB;AAAA,IACD;AACA,QAAI,IAAI,WAAW,OAAO;AACzB,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,qBAAqB,CAAC;AACpD;AAAA,IACD;AAEA,UAAM,MAAM,QAAQ;AACpB,UAAM,WACL,gBAAgB,IAAI,OAAO,UAAU,KACrC,gBAAgB,IAAI,OAAO,MAAM;AAClC,UAAM,SAAS,MAAM,oBAAoB;AAAA,MACxC,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,QAAQ,OAAO;AAAA,MACf;AAAA,MACA;AAAA,IACD,CAAC;AAED,eAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,OAAO,OAAO,GAAG;AACpD,UAAI,UAAU,GAAG,CAAC;AAAA,IACnB;AACA,QAAI,OAAO,OAAO,MAAM,EAAE,KAAK,OAAO,IAAI;AAAA,EAC3C;AACD;","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  executeReviewsProxy
3
- } from "./chunk-QCKKYPGS.js";
3
+ } from "./chunk-DDRJRV5Q.js";
4
4
 
5
5
  // src/vite-plugin.ts
6
6
  import { loadEnv } from "vite";
@@ -32,7 +32,12 @@ function trustCarouselReviewsVitePlugin(options) {
32
32
  ...process.env,
33
33
  ...loaded
34
34
  };
35
- const result = await executeReviewsProxy({ env: merged });
35
+ const q = new URL(rawUrl, "http://localhost").searchParams;
36
+ const language = q.get("language") ?? q.get("lang") ?? void 0;
37
+ const result = await executeReviewsProxy({
38
+ env: merged,
39
+ language
40
+ });
36
41
  for (const [k, v] of Object.entries(result.headers)) {
37
42
  res.setHeader(k, v);
38
43
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/vite-plugin.ts"],"sourcesContent":["import { loadEnv, type Plugin } from \"vite\";\nimport { executeReviewsProxy, type ReviewsBridgeEnv } from \"./reviewsBridgeCore.js\";\n\nexport type TrustCarouselReviewsVitePluginOptions = {\n\t/** Dev middleware path (default `/api/reviews`). */\n\tpath?: string;\n};\n\n/**\n * Vite dev server: serves `GET /api/reviews` with the same proxy logic as\n * `createVercelHandler` (central API + bearer). Reads `.env` via `loadEnv`\n * (`KERN_*`, `VITE_*`, `GOOGLE_*`, `DEBUG_*` prefixes) merged with `process.env`.\n *\n * Production builds still need `api/reviews.ts` on Vercel (or your host’s serverless).\n *\n * ```ts\n * // vite.config.ts\n * import { trustCarouselReviewsVitePlugin } from '@kern-di/trust-carousel/vite-plugin';\n * export default defineConfig({\n * plugins: [react(), trustCarouselReviewsVitePlugin()],\n * });\n * ```\n */\nexport function trustCarouselReviewsVitePlugin(\n\toptions?: TrustCarouselReviewsVitePluginOptions,\n): Plugin {\n\tconst routePath = options?.path ?? \"/api/reviews\";\n\n\tconst envPrefixes = [\"VITE_\", \"KERN_\", \"GOOGLE_\", \"DEBUG_\"] as const;\n\n\treturn {\n\t\tname: \"kern-di-trust-carousel-reviews\",\n\t\tconfigureServer(server) {\n\t\t\tserver.middlewares.use((req, res, next) => {\n\t\t\t\tif (req.method !== \"GET\") {\n\t\t\t\t\tnext();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst rawUrl = req.url ?? \"\";\n\t\t\t\tconst pathname = rawUrl.split(\"?\")[0] ?? \"\";\n\t\t\t\tif (pathname !== routePath) {\n\t\t\t\t\tnext();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tvoid (async () => {\n\t\t\t\t\tconst mode = server.config.mode;\n\t\t\t\t\tconst envDir = server.config.envDir;\n\t\t\t\t\tconst loaded: Record<string, string> = {};\n\t\t\t\t\tfor (const prefix of envPrefixes) {\n\t\t\t\t\t\tObject.assign(loaded, loadEnv(mode, envDir, prefix));\n\t\t\t\t\t}\n\t\t\t\t\tconst merged: ReviewsBridgeEnv = {\n\t\t\t\t\t\t...process.env,\n\t\t\t\t\t\t...loaded,\n\t\t\t\t\t};\n\n\t\t\t\t\tconst result = await executeReviewsProxy({ env: merged });\n\n\t\t\t\t\tfor (const [k, v] of Object.entries(result.headers)) {\n\t\t\t\t\t\tres.setHeader(k, v);\n\t\t\t\t\t}\n\t\t\t\t\tres.statusCode = result.status;\n\t\t\t\t\tres.end(result.body);\n\t\t\t\t})().catch((err) => {\n\t\t\t\t\tconsole.error(\"[kern-di/trust-carousel] vite-plugin\", err);\n\t\t\t\t\tres.statusCode = 502;\n\t\t\t\t\tres.setHeader(\"Content-Type\", \"application/json\");\n\t\t\t\t\tres.end(JSON.stringify({ error: \"Could not load reviews.\" }));\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAAA,SAAS,eAA4B;AAuB9B,SAAS,+BACf,SACS;AACT,QAAM,YAAY,SAAS,QAAQ;AAEnC,QAAM,cAAc,CAAC,SAAS,SAAS,WAAW,QAAQ;AAE1D,SAAO;AAAA,IACN,MAAM;AAAA,IACN,gBAAgB,QAAQ;AACvB,aAAO,YAAY,IAAI,CAAC,KAAK,KAAK,SAAS;AAC1C,YAAI,IAAI,WAAW,OAAO;AACzB,eAAK;AACL;AAAA,QACD;AACA,cAAM,SAAS,IAAI,OAAO;AAC1B,cAAM,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK;AACzC,YAAI,aAAa,WAAW;AAC3B,eAAK;AACL;AAAA,QACD;AAEA,cAAM,YAAY;AACjB,gBAAM,OAAO,OAAO,OAAO;AAC3B,gBAAM,SAAS,OAAO,OAAO;AAC7B,gBAAM,SAAiC,CAAC;AACxC,qBAAW,UAAU,aAAa;AACjC,mBAAO,OAAO,QAAQ,QAAQ,MAAM,QAAQ,MAAM,CAAC;AAAA,UACpD;AACA,gBAAM,SAA2B;AAAA,YAChC,GAAG,QAAQ;AAAA,YACX,GAAG;AAAA,UACJ;AAEA,gBAAM,SAAS,MAAM,oBAAoB,EAAE,KAAK,OAAO,CAAC;AAExD,qBAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,OAAO,OAAO,GAAG;AACpD,gBAAI,UAAU,GAAG,CAAC;AAAA,UACnB;AACA,cAAI,aAAa,OAAO;AACxB,cAAI,IAAI,OAAO,IAAI;AAAA,QACpB,GAAG,EAAE,MAAM,CAAC,QAAQ;AACnB,kBAAQ,MAAM,wCAAwC,GAAG;AACzD,cAAI,aAAa;AACjB,cAAI,UAAU,gBAAgB,kBAAkB;AAChD,cAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAAA,QAC7D,CAAC;AAAA,MACF,CAAC;AAAA,IACF;AAAA,EACD;AACD;","names":[]}
1
+ {"version":3,"sources":["../src/vite-plugin.ts"],"sourcesContent":["import { type Plugin, loadEnv } from \"vite\";\nimport {\n\ttype ReviewsBridgeEnv,\n\texecuteReviewsProxy,\n} from \"./reviewsBridgeCore.js\";\n\nexport type TrustCarouselReviewsVitePluginOptions = {\n\t/** Dev middleware path (default `/api/reviews`). */\n\tpath?: string;\n};\n\n/**\n * Vite dev server: serves `GET /api/reviews` with the same proxy logic as\n * `createVercelHandler` (central API + bearer). Reads `.env` via `loadEnv`\n * (`KERN_*`, `VITE_*`, `GOOGLE_*`, `DEBUG_*` prefixes) merged with `process.env`.\n *\n * Production builds still need `api/reviews.ts` on Vercel (or your host’s serverless).\n *\n * ```ts\n * // vite.config.ts\n * import { trustCarouselReviewsVitePlugin } from '@kern-di/trust-carousel/vite-plugin';\n * export default defineConfig({\n * plugins: [react(), trustCarouselReviewsVitePlugin()],\n * });\n * ```\n */\nexport function trustCarouselReviewsVitePlugin(\n\toptions?: TrustCarouselReviewsVitePluginOptions,\n): Plugin {\n\tconst routePath = options?.path ?? \"/api/reviews\";\n\n\tconst envPrefixes = [\"VITE_\", \"KERN_\", \"GOOGLE_\", \"DEBUG_\"] as const;\n\n\treturn {\n\t\tname: \"kern-di-trust-carousel-reviews\",\n\t\tconfigureServer(server) {\n\t\t\tserver.middlewares.use((req, res, next) => {\n\t\t\t\tif (req.method !== \"GET\") {\n\t\t\t\t\tnext();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst rawUrl = req.url ?? \"\";\n\t\t\t\tconst pathname = rawUrl.split(\"?\")[0] ?? \"\";\n\t\t\t\tif (pathname !== routePath) {\n\t\t\t\t\tnext();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tvoid (async () => {\n\t\t\t\t\tconst mode = server.config.mode;\n\t\t\t\t\tconst envDir = server.config.envDir;\n\t\t\t\t\tconst loaded: Record<string, string> = {};\n\t\t\t\t\tfor (const prefix of envPrefixes) {\n\t\t\t\t\t\tObject.assign(loaded, loadEnv(mode, envDir, prefix));\n\t\t\t\t\t}\n\t\t\t\t\tconst merged: ReviewsBridgeEnv = {\n\t\t\t\t\t\t...process.env,\n\t\t\t\t\t\t...loaded,\n\t\t\t\t\t};\n\n\t\t\t\t\tconst q = new URL(rawUrl, \"http://localhost\").searchParams;\n\t\t\t\t\tconst language = q.get(\"language\") ?? q.get(\"lang\") ?? undefined;\n\n\t\t\t\t\tconst result = await executeReviewsProxy({\n\t\t\t\t\t\tenv: merged,\n\t\t\t\t\t\tlanguage,\n\t\t\t\t\t});\n\n\t\t\t\t\tfor (const [k, v] of Object.entries(result.headers)) {\n\t\t\t\t\t\tres.setHeader(k, v);\n\t\t\t\t\t}\n\t\t\t\t\tres.statusCode = result.status;\n\t\t\t\t\tres.end(result.body);\n\t\t\t\t})().catch((err) => {\n\t\t\t\t\tconsole.error(\"[kern-di/trust-carousel] vite-plugin\", err);\n\t\t\t\t\tres.statusCode = 502;\n\t\t\t\t\tres.setHeader(\"Content-Type\", \"application/json\");\n\t\t\t\t\tres.end(JSON.stringify({ error: \"Could not load reviews.\" }));\n\t\t\t\t});\n\t\t\t});\n\t\t},\n\t};\n}\n"],"mappings":";;;;;AAAA,SAAsB,eAAe;AA0B9B,SAAS,+BACf,SACS;AACT,QAAM,YAAY,SAAS,QAAQ;AAEnC,QAAM,cAAc,CAAC,SAAS,SAAS,WAAW,QAAQ;AAE1D,SAAO;AAAA,IACN,MAAM;AAAA,IACN,gBAAgB,QAAQ;AACvB,aAAO,YAAY,IAAI,CAAC,KAAK,KAAK,SAAS;AAC1C,YAAI,IAAI,WAAW,OAAO;AACzB,eAAK;AACL;AAAA,QACD;AACA,cAAM,SAAS,IAAI,OAAO;AAC1B,cAAM,WAAW,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK;AACzC,YAAI,aAAa,WAAW;AAC3B,eAAK;AACL;AAAA,QACD;AAEA,cAAM,YAAY;AACjB,gBAAM,OAAO,OAAO,OAAO;AAC3B,gBAAM,SAAS,OAAO,OAAO;AAC7B,gBAAM,SAAiC,CAAC;AACxC,qBAAW,UAAU,aAAa;AACjC,mBAAO,OAAO,QAAQ,QAAQ,MAAM,QAAQ,MAAM,CAAC;AAAA,UACpD;AACA,gBAAM,SAA2B;AAAA,YAChC,GAAG,QAAQ;AAAA,YACX,GAAG;AAAA,UACJ;AAEA,gBAAM,IAAI,IAAI,IAAI,QAAQ,kBAAkB,EAAE;AAC9C,gBAAM,WAAW,EAAE,IAAI,UAAU,KAAK,EAAE,IAAI,MAAM,KAAK;AAEvD,gBAAM,SAAS,MAAM,oBAAoB;AAAA,YACxC,KAAK;AAAA,YACL;AAAA,UACD,CAAC;AAED,qBAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,OAAO,OAAO,GAAG;AACpD,gBAAI,UAAU,GAAG,CAAC;AAAA,UACnB;AACA,cAAI,aAAa,OAAO;AACxB,cAAI,IAAI,OAAO,IAAI;AAAA,QACpB,GAAG,EAAE,MAAM,CAAC,QAAQ;AACnB,kBAAQ,MAAM,wCAAwC,GAAG;AACzD,cAAI,aAAa;AACjB,cAAI,UAAU,gBAAgB,kBAAkB;AAChD,cAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAAA,QAC7D,CAAC;AAAA,MACF,CAAC;AAAA,IACF;AAAA,EACD;AACD;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kern-di/trust-carousel",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
4
4
  "description": "Drop-in Google Reviews carousel component for embedding social proof on any site",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,8 @@
22
22
  },
23
23
  "sideEffects": false,
24
24
  "files": [
25
- "dist"
25
+ "dist",
26
+ "CODING_AGENT.md"
26
27
  ],
27
28
  "dependencies": {
28
29
  "clsx": "^2.1.1",
@@ -52,8 +53,7 @@
52
53
  "tailwindcss": "^3.4.17",
53
54
  "tsup": "^8.5.1",
54
55
  "typescript": "^5.9.3",
55
- "vite": "^5.4.21",
56
- "@kern/ui": "0.0.0"
56
+ "vite": "^5.4.21"
57
57
  },
58
58
  "keywords": [
59
59
  "react",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/reviewsBridgeCore.ts"],"sourcesContent":["/** Env bag for bridge (e.g. `process.env` or Vite `loadEnv` merged with `process.env`). */\nexport type ReviewsBridgeEnv = Record<string, string | undefined>;\n\n/** Default production origin for the company central API (no path). */\nexport const DEFAULT_KERN_API_URL = \"https://api.kern-di.de\";\n\n/**\n * Path on the central API for Google reviews (bearer + `placeId` query).\n */\nexport const KERN_API_GOOGLE_REVIEWS_PATH = \"/api/google-reviews\";\n\nexport function isReviewsBridgeDebug(env: ReviewsBridgeEnv): boolean {\n\treturn (\n\t\tenv.KERN_REVIEWS_DEBUG === \"1\" || env.DEBUG_KERN_REVIEWS === \"1\"\n\t);\n}\n\n/**\n * Resolves central API **origin** only.\n * Precedence: `configApiUrl` → `KERN_API_URL` → `VITE_KERN_API_URL` → default.\n */\nexport function resolveKernApiOrigin(\n\tconfigApiUrl: string | undefined,\n\tenv: ReviewsBridgeEnv = process.env as ReviewsBridgeEnv,\n): string {\n\tconst fromConfig = configApiUrl?.trim();\n\tif (fromConfig) return normalizeApiOrigin(fromConfig);\n\tconst fromEnv =\n\t\tenv.KERN_API_URL?.trim() || env.VITE_KERN_API_URL?.trim();\n\tif (fromEnv) return normalizeApiOrigin(fromEnv);\n\treturn normalizeApiOrigin(DEFAULT_KERN_API_URL);\n}\n\nfunction stripTrailingSlash(url: string): string {\n\treturn url.replace(/\\/$/, \"\");\n}\n\nfunction normalizeApiOrigin(url: string): string {\n\tlet s = stripTrailingSlash(url.trim());\n\tif (s.endsWith(\"/api\")) {\n\t\ts = s.slice(0, -4);\n\t\ts = stripTrailingSlash(s);\n\t}\n\treturn s;\n}\n\nconst CACHE_CONTROL_BRIDGE =\n\t\"public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, must-revalidate\";\n\nexport type ExecuteReviewsProxyInput = {\n\t/** Overrides `env.KERN_API_KEY` */\n\tapiKey?: string;\n\t/** Overrides `env.GOOGLE_PLACE_ID` */\n\tplaceId?: string;\n\t/** Overrides env-based origin resolution */\n\tapiUrl?: string;\n\tenv: ReviewsBridgeEnv;\n};\n\n/**\n * Shared implementation: GET reviews JSON from the company central API (Redis + Places upstream).\n * Used by `createVercelHandler` and the Vite dev plugin.\n */\nexport async function executeReviewsProxy(\n\tinput: ExecuteReviewsProxyInput,\n): Promise<{\n\tstatus: number;\n\theaders: Record<string, string>;\n\tbody: Buffer;\n}> {\n\tconst apiKey =\n\t\tinput.apiKey?.trim() ?? input.env.KERN_API_KEY?.trim() ?? \"\";\n\tconst placeId =\n\t\tinput.placeId?.trim() ?? input.env.GOOGLE_PLACE_ID?.trim() ?? \"\";\n\tconst debug = isReviewsBridgeDebug(input.env);\n\n\tif (!apiKey || !placeId) {\n\t\tif (debug) {\n\t\t\tconsole.warn(\n\t\t\t\t\"[kern-di/trust-carousel] bridge misconfigured: missing KERN_API_KEY or GOOGLE_PLACE_ID\",\n\t\t\t);\n\t\t}\n\t\treturn {\n\t\t\tstatus: 503,\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: Buffer.from(\n\t\t\t\tJSON.stringify({ error: \"Reviews API is not configured.\" }),\n\t\t\t),\n\t\t};\n\t}\n\n\tconst origin = resolveKernApiOrigin(input.apiUrl, input.env);\n\tconst url = `${origin}${KERN_API_GOOGLE_REVIEWS_PATH}?placeId=${encodeURIComponent(placeId)}`;\n\n\tif (debug) {\n\t\tconsole.log(\"[kern-di/trust-carousel] upstream GET\", url);\n\t}\n\n\ttry {\n\t\tconst upstream = await fetch(url, {\n\t\t\theaders: { Authorization: `Bearer ${apiKey}` },\n\t\t});\n\n\t\tif (debug) {\n\t\t\tconsole.log(\n\t\t\t\t\"[kern-di/trust-carousel] upstream response\",\n\t\t\t\tupstream.status,\n\t\t\t\tupstream.statusText,\n\t\t\t);\n\t\t}\n\n\t\tconst headers: Record<string, string> = {\n\t\t\t\"Cache-Control\": CACHE_CONTROL_BRIDGE,\n\t\t};\n\t\tconst ct = upstream.headers.get(\"content-type\");\n\t\tif (ct) {\n\t\t\theaders[\"Content-Type\"] = ct;\n\t\t}\n\n\t\tconst buf = Buffer.from(await upstream.arrayBuffer());\n\t\treturn { status: upstream.status, headers, body: buf };\n\t} catch (err) {\n\t\tif (debug) {\n\t\t\tconsole.error(\"[kern-di/trust-carousel] upstream fetch error\", err);\n\t\t}\n\t\treturn {\n\t\t\tstatus: 502,\n\t\t\theaders: { \"Content-Type\": \"application/json\" },\n\t\t\tbody: Buffer.from(JSON.stringify({ error: \"Could not load reviews.\" })),\n\t\t};\n\t}\n}\n"],"mappings":";AAIO,IAAM,uBAAuB;AAK7B,IAAM,+BAA+B;AAErC,SAAS,qBAAqB,KAAgC;AACpE,SACC,IAAI,uBAAuB,OAAO,IAAI,uBAAuB;AAE/D;AAMO,SAAS,qBACf,cACA,MAAwB,QAAQ,KACvB;AACT,QAAM,aAAa,cAAc,KAAK;AACtC,MAAI,WAAY,QAAO,mBAAmB,UAAU;AACpD,QAAM,UACL,IAAI,cAAc,KAAK,KAAK,IAAI,mBAAmB,KAAK;AACzD,MAAI,QAAS,QAAO,mBAAmB,OAAO;AAC9C,SAAO,mBAAmB,oBAAoB;AAC/C;AAEA,SAAS,mBAAmB,KAAqB;AAChD,SAAO,IAAI,QAAQ,OAAO,EAAE;AAC7B;AAEA,SAAS,mBAAmB,KAAqB;AAChD,MAAI,IAAI,mBAAmB,IAAI,KAAK,CAAC;AACrC,MAAI,EAAE,SAAS,MAAM,GAAG;AACvB,QAAI,EAAE,MAAM,GAAG,EAAE;AACjB,QAAI,mBAAmB,CAAC;AAAA,EACzB;AACA,SAAO;AACR;AAEA,IAAM,uBACL;AAgBD,eAAsB,oBACrB,OAKE;AACF,QAAM,SACL,MAAM,QAAQ,KAAK,KAAK,MAAM,IAAI,cAAc,KAAK,KAAK;AAC3D,QAAM,UACL,MAAM,SAAS,KAAK,KAAK,MAAM,IAAI,iBAAiB,KAAK,KAAK;AAC/D,QAAM,QAAQ,qBAAqB,MAAM,GAAG;AAE5C,MAAI,CAAC,UAAU,CAAC,SAAS;AACxB,QAAI,OAAO;AACV,cAAQ;AAAA,QACP;AAAA,MACD;AAAA,IACD;AACA,WAAO;AAAA,MACN,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,OAAO;AAAA,QACZ,KAAK,UAAU,EAAE,OAAO,iCAAiC,CAAC;AAAA,MAC3D;AAAA,IACD;AAAA,EACD;AAEA,QAAM,SAAS,qBAAqB,MAAM,QAAQ,MAAM,GAAG;AAC3D,QAAM,MAAM,GAAG,MAAM,GAAG,4BAA4B,YAAY,mBAAmB,OAAO,CAAC;AAE3F,MAAI,OAAO;AACV,YAAQ,IAAI,yCAAyC,GAAG;AAAA,EACzD;AAEA,MAAI;AACH,UAAM,WAAW,MAAM,MAAM,KAAK;AAAA,MACjC,SAAS,EAAE,eAAe,UAAU,MAAM,GAAG;AAAA,IAC9C,CAAC;AAED,QAAI,OAAO;AACV,cAAQ;AAAA,QACP;AAAA,QACA,SAAS;AAAA,QACT,SAAS;AAAA,MACV;AAAA,IACD;AAEA,UAAM,UAAkC;AAAA,MACvC,iBAAiB;AAAA,IAClB;AACA,UAAM,KAAK,SAAS,QAAQ,IAAI,cAAc;AAC9C,QAAI,IAAI;AACP,cAAQ,cAAc,IAAI;AAAA,IAC3B;AAEA,UAAM,MAAM,OAAO,KAAK,MAAM,SAAS,YAAY,CAAC;AACpD,WAAO,EAAE,QAAQ,SAAS,QAAQ,SAAS,MAAM,IAAI;AAAA,EACtD,SAAS,KAAK;AACb,QAAI,OAAO;AACV,cAAQ,MAAM,iDAAiD,GAAG;AAAA,IACnE;AACA,WAAO;AAAA,MACN,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,OAAO,KAAK,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAAA,IACvE;AAAA,EACD;AACD;","names":[]}