@kern-di/trust-carousel 0.1.4 → 0.1.7
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/dist/index.d.ts +13 -8
- package/dist/index.js +16 -11
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +16 -0
- package/dist/server.js +46 -0
- package/dist/server.js.map +1 -0
- package/package.json +6 -1
package/dist/index.d.ts
CHANGED
|
@@ -14,9 +14,9 @@ interface GoogleReviewsCarouselProps {
|
|
|
14
14
|
businessName: string;
|
|
15
15
|
placeUrl: string;
|
|
16
16
|
/**
|
|
17
|
-
* Official overall rating from Google Places (all reviews).
|
|
18
|
-
*
|
|
19
|
-
* sample
|
|
17
|
+
* Official overall rating from Google Places (all reviews). The header score
|
|
18
|
+
* and stars are shown only when this is set — never derived from the
|
|
19
|
+
* `reviews` sample.
|
|
20
20
|
*/
|
|
21
21
|
aggregateRating?: number;
|
|
22
22
|
/** Official total review count from Google Places. When omitted, uses `reviews.length`. */
|
|
@@ -30,7 +30,7 @@ interface GoogleReviewsCarouselProps {
|
|
|
30
30
|
declare const SAMPLE_REVIEWS: GoogleReview[];
|
|
31
31
|
declare function GoogleReviewsCarousel({ reviews, businessName, placeUrl, aggregateRating: aggregateRatingProp, totalReviewCount: totalReviewCountProp, locale, autoPlay, autoPlaySpeed, }: GoogleReviewsCarouselProps): react_jsx_runtime.JSX.Element;
|
|
32
32
|
|
|
33
|
-
/** JSON body from `GET /api/
|
|
33
|
+
/** JSON body from your reviews API (e.g. `GET /api/reviews` fleet bridge or `GET /api/google-reviews`). */
|
|
34
34
|
type CachedGoogleReviewsResponse = {
|
|
35
35
|
businessName: string;
|
|
36
36
|
placeUrl: string;
|
|
@@ -46,13 +46,18 @@ type GoogleReviewsCarouselCachedProps = Omit<GoogleReviewsCarouselProps, "review
|
|
|
46
46
|
slug?: string;
|
|
47
47
|
/** Google Place ID; avoids slug map. Prefer for single-tenant sites. */
|
|
48
48
|
placeId?: string;
|
|
49
|
-
/** Base path or absolute URL; default `/api/google-reviews` */
|
|
49
|
+
/** Base path or absolute URL; default `/api/reviews` (fleet bridge). Use `/api/google-reviews` for query `placeId`/`slug` APIs. */
|
|
50
50
|
reviewsApiPath?: string;
|
|
51
|
+
/**
|
|
52
|
+
* When true, fetches `reviewsApiPath` with no query string; the server must resolve
|
|
53
|
+
* `placeId` (e.g. `createVercelHandler` + `GOOGLE_PLACE_ID` on Vercel).
|
|
54
|
+
*/
|
|
55
|
+
resolvePlaceOnServer?: boolean;
|
|
51
56
|
};
|
|
52
57
|
/**
|
|
53
|
-
* Loads reviews from your backend (`/api/
|
|
54
|
-
*
|
|
58
|
+
* Loads reviews from your backend (`/api/reviews` by default). The server should
|
|
59
|
+
* return cacheable JSON (e.g. Cache-Control at the CDN).
|
|
55
60
|
*/
|
|
56
|
-
declare function GoogleReviewsCarouselCached({ slug, placeId, reviewsApiPath, locale, ...carouselRest }: GoogleReviewsCarouselCachedProps): react_jsx_runtime.JSX.Element;
|
|
61
|
+
declare function GoogleReviewsCarouselCached({ slug, placeId, reviewsApiPath, resolvePlaceOnServer, locale, ...carouselRest }: GoogleReviewsCarouselCachedProps): react_jsx_runtime.JSX.Element;
|
|
57
62
|
|
|
58
63
|
export { type CachedGoogleReviewsResponse, type GoogleReview, GoogleReviewsCarousel, GoogleReviewsCarouselCached, type GoogleReviewsCarouselCachedProps, type GoogleReviewsCarouselLocale, type GoogleReviewsCarouselProps, SAMPLE_REVIEWS };
|
package/dist/index.js
CHANGED
|
@@ -245,10 +245,7 @@ function GoogleReviewsCarousel({
|
|
|
245
245
|
if (total === 0) return null;
|
|
246
246
|
const officialTotal = coerceNonNegativeInt(totalReviewCountProp);
|
|
247
247
|
const summaryCount = officialTotal ?? total;
|
|
248
|
-
const meanFromSample = total > 0 ? reviews.reduce((s, r) => s + r.rating, 0) / total : 0;
|
|
249
248
|
const officialRating = coerceFiniteNumber(aggregateRatingProp);
|
|
250
|
-
const summaryRating = officialRating ?? meanFromSample;
|
|
251
|
-
const ratingLabel = summaryRating.toFixed(1);
|
|
252
249
|
return /* @__PURE__ */ jsxs(
|
|
253
250
|
"section",
|
|
254
251
|
{
|
|
@@ -260,11 +257,11 @@ function GoogleReviewsCarousel({
|
|
|
260
257
|
/* @__PURE__ */ jsx(GoogleLogo, { className: "h-8" }),
|
|
261
258
|
/* @__PURE__ */ jsx("p", { className: "text-xs font-medium uppercase tracking-widest text-muted-foreground", children: labels.eyebrow }),
|
|
262
259
|
/* @__PURE__ */ jsx("h3", { className: "font-display text-xl font-semibold text-foreground md:text-2xl", children: businessName }),
|
|
263
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
264
|
-
/* @__PURE__ */ jsx("span", { className: "text-2xl font-bold text-foreground", children:
|
|
265
|
-
/* @__PURE__ */ jsx(StarRating, { rating: Math.round(
|
|
260
|
+
officialRating !== void 0 ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
261
|
+
/* @__PURE__ */ jsx("span", { className: "text-2xl font-bold text-foreground", children: officialRating.toFixed(1) }),
|
|
262
|
+
/* @__PURE__ */ jsx(StarRating, { rating: Math.round(officialRating), size: 20 }),
|
|
266
263
|
/* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: labels.reviewCount(summaryCount) })
|
|
267
|
-
] })
|
|
264
|
+
] }) : /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: labels.reviewCount(summaryCount) })
|
|
268
265
|
] }),
|
|
269
266
|
/* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
270
267
|
total > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
@@ -341,6 +338,9 @@ import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
|
341
338
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
342
339
|
function buildReviewsUrl(base, params) {
|
|
343
340
|
const path = base.replace(/\/$/, "");
|
|
341
|
+
if (params.resolvePlaceOnServer) {
|
|
342
|
+
return path;
|
|
343
|
+
}
|
|
344
344
|
const joiner = path.includes("?") ? "&" : "?";
|
|
345
345
|
if (params.placeId?.trim()) {
|
|
346
346
|
return `${path}${joiner}placeId=${encodeURIComponent(params.placeId.trim())}`;
|
|
@@ -358,7 +358,8 @@ function isSuccessPayload(data) {
|
|
|
358
358
|
function GoogleReviewsCarouselCached({
|
|
359
359
|
slug,
|
|
360
360
|
placeId,
|
|
361
|
-
reviewsApiPath = "/api/
|
|
361
|
+
reviewsApiPath = "/api/reviews",
|
|
362
|
+
resolvePlaceOnServer = false,
|
|
362
363
|
locale = "de",
|
|
363
364
|
...carouselRest
|
|
364
365
|
}) {
|
|
@@ -367,11 +368,15 @@ function GoogleReviewsCarouselCached({
|
|
|
367
368
|
const [loading, setLoading] = useState2(true);
|
|
368
369
|
useEffect2(() => {
|
|
369
370
|
let cancelled = false;
|
|
370
|
-
const url = buildReviewsUrl(reviewsApiPath, {
|
|
371
|
+
const url = buildReviewsUrl(reviewsApiPath, {
|
|
372
|
+
slug,
|
|
373
|
+
placeId,
|
|
374
|
+
resolvePlaceOnServer
|
|
375
|
+
});
|
|
371
376
|
(async () => {
|
|
372
377
|
setLoading(true);
|
|
373
378
|
setError(null);
|
|
374
|
-
if (!slug?.trim() && !placeId?.trim()) {
|
|
379
|
+
if (!resolvePlaceOnServer && !slug?.trim() && !placeId?.trim()) {
|
|
375
380
|
if (!cancelled) {
|
|
376
381
|
setError(
|
|
377
382
|
locale === "de" ? "slug oder placeId ist erforderlich." : "Either slug or placeId is required."
|
|
@@ -413,7 +418,7 @@ function GoogleReviewsCarouselCached({
|
|
|
413
418
|
return () => {
|
|
414
419
|
cancelled = true;
|
|
415
420
|
};
|
|
416
|
-
}, [slug, placeId, reviewsApiPath, locale]);
|
|
421
|
+
}, [slug, placeId, reviewsApiPath, resolvePlaceOnServer, locale]);
|
|
417
422
|
if (loading) {
|
|
418
423
|
return /* @__PURE__ */ jsx2(
|
|
419
424
|
"div",
|
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). When omitted, the\n\t * header averages the `reviews` array — wrong if you only have a Places API\n\t * sample (max ~5 reviews).\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 meanFromSample =\n\t\ttotal > 0 ? reviews.reduce((s, r) => s + r.rating, 0) / total : 0;\n\tconst officialRating = coerceFiniteNumber(aggregateRatingProp);\n\tconst summaryRating = officialRating ?? meanFromSample;\n\tconst ratingLabel = summaryRating.toFixed(1);\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<div className=\"flex items-center gap-2\">\n\t\t\t\t\t<span className=\"text-2xl font-bold text-foreground\">\n\t\t\t\t\t\t{ratingLabel}\n\t\t\t\t\t</span>\n\t\t\t\t\t<StarRating rating={Math.round(summaryRating)} size={20} />\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</div>\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 `GET /api/google-reviews` (website serverless handler). */\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/google-reviews` */\n\treviewsApiPath?: string;\n};\n\nfunction buildReviewsUrl(\n\tbase: string,\n\tparams: { slug?: string; placeId?: string },\n): string {\n\tconst path = base.replace(/\\/$/, \"\");\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/google-reviews` by default). The server\n * should return cacheable JSON (e.g. Cache-Control at the CDN).\n */\nexport default function GoogleReviewsCarouselCached({\n\tslug,\n\tplaceId,\n\treviewsApiPath = \"/api/google-reviews\",\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, { slug, placeId });\n\n\t\t(async () => {\n\t\t\tsetLoading(true);\n\t\t\tsetError(null);\n\t\t\tif (!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, 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\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` on Vercel).\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"]}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { VercelRequest, VercelResponse } from '@vercel/node';
|
|
2
|
+
|
|
3
|
+
/** Production host for `@kern/api` (company central API). Override via `apiUrl` in `createVercelHandler`. */
|
|
4
|
+
declare const DEFAULT_KERN_CENTRAL_API_URL = "https://api.kern-di.de";
|
|
5
|
+
type CreateVercelHandlerConfig = {
|
|
6
|
+
apiKey: string;
|
|
7
|
+
placeId: string;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Vercel Node serverless handler that proxies to the company central API
|
|
12
|
+
* `GET /api/google-reviews` with bearer auth. Use as `api/reviews.ts` on fleet sites.
|
|
13
|
+
*/
|
|
14
|
+
declare function createVercelHandler(config: CreateVercelHandlerConfig): (req: VercelRequest, res: VercelResponse) => Promise<void>;
|
|
15
|
+
|
|
16
|
+
export { type CreateVercelHandlerConfig, DEFAULT_KERN_CENTRAL_API_URL, createVercelHandler };
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
var DEFAULT_KERN_CENTRAL_API_URL = "https://api.kern-di.de";
|
|
3
|
+
function createVercelHandler(config) {
|
|
4
|
+
return async (req, res) => {
|
|
5
|
+
if (req.method === "OPTIONS") {
|
|
6
|
+
res.status(204).end();
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
if (req.method !== "GET") {
|
|
10
|
+
res.status(405).json({ error: "Method not allowed" });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const apiKey = config.apiKey?.trim();
|
|
14
|
+
const placeId = config.placeId?.trim();
|
|
15
|
+
if (!apiKey || !placeId) {
|
|
16
|
+
res.status(503).json({ error: "Reviews API is not configured." });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const base = (config.apiUrl ?? DEFAULT_KERN_CENTRAL_API_URL).replace(
|
|
20
|
+
/\/$/,
|
|
21
|
+
""
|
|
22
|
+
);
|
|
23
|
+
const url = `${base}/api/google-reviews?placeId=${encodeURIComponent(placeId)}`;
|
|
24
|
+
try {
|
|
25
|
+
const upstream = await fetch(url, {
|
|
26
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
27
|
+
});
|
|
28
|
+
const cacheControl = "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, must-revalidate";
|
|
29
|
+
res.setHeader("Cache-Control", cacheControl);
|
|
30
|
+
const ct = upstream.headers.get("content-type");
|
|
31
|
+
if (ct) {
|
|
32
|
+
res.setHeader("Content-Type", ct);
|
|
33
|
+
}
|
|
34
|
+
res.status(upstream.status);
|
|
35
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
36
|
+
res.send(buf);
|
|
37
|
+
} catch {
|
|
38
|
+
res.status(502).json({ error: "Could not load reviews." });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
DEFAULT_KERN_CENTRAL_API_URL,
|
|
44
|
+
createVercelHandler
|
|
45
|
+
};
|
|
46
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["import type { VercelRequest, VercelResponse } from \"@vercel/node\";\n\n/** Production host for `@kern/api` (company central API). Override via `apiUrl` in `createVercelHandler`. */\nexport const DEFAULT_KERN_CENTRAL_API_URL = \"https://api.kern-di.de\";\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 */\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 apiKey = config.apiKey?.trim();\n\t\tconst placeId = config.placeId?.trim();\n\t\tif (!apiKey || !placeId) {\n\t\t\tres.status(503).json({ error: \"Reviews API is not configured.\" });\n\t\t\treturn;\n\t\t}\n\n\t\tconst base = (config.apiUrl ?? DEFAULT_KERN_CENTRAL_API_URL).replace(\n\t\t\t/\\/$/,\n\t\t\t\"\",\n\t\t);\n\t\tconst url = `${base}/api/google-reviews?placeId=${encodeURIComponent(placeId)}`;\n\n\t\ttry {\n\t\t\tconst upstream = await fetch(url, {\n\t\t\t\theaders: { Authorization: `Bearer ${apiKey}` },\n\t\t\t});\n\n\t\t\tconst cacheControl =\n\t\t\t\t\"public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, must-revalidate\";\n\t\t\tres.setHeader(\"Cache-Control\", cacheControl);\n\n\t\t\tconst ct = upstream.headers.get(\"content-type\");\n\t\t\tif (ct) {\n\t\t\t\tres.setHeader(\"Content-Type\", ct);\n\t\t\t}\n\n\t\t\tres.status(upstream.status);\n\t\t\tconst buf = Buffer.from(await upstream.arrayBuffer());\n\t\t\tres.send(buf);\n\t\t} catch {\n\t\t\tres.status(502).json({ error: \"Could not load reviews.\" });\n\t\t}\n\t};\n}\n"],"mappings":";AAGO,IAAM,+BAA+B;AAYrC,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,SAAS,OAAO,QAAQ,KAAK;AACnC,UAAM,UAAU,OAAO,SAAS,KAAK;AACrC,QAAI,CAAC,UAAU,CAAC,SAAS;AACxB,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,iCAAiC,CAAC;AAChE;AAAA,IACD;AAEA,UAAM,QAAQ,OAAO,UAAU,8BAA8B;AAAA,MAC5D;AAAA,MACA;AAAA,IACD;AACA,UAAM,MAAM,GAAG,IAAI,+BAA+B,mBAAmB,OAAO,CAAC;AAE7E,QAAI;AACH,YAAM,WAAW,MAAM,MAAM,KAAK;AAAA,QACjC,SAAS,EAAE,eAAe,UAAU,MAAM,GAAG;AAAA,MAC9C,CAAC;AAED,YAAM,eACL;AACD,UAAI,UAAU,iBAAiB,YAAY;AAE3C,YAAM,KAAK,SAAS,QAAQ,IAAI,cAAc;AAC9C,UAAI,IAAI;AACP,YAAI,UAAU,gBAAgB,EAAE;AAAA,MACjC;AAEA,UAAI,OAAO,SAAS,MAAM;AAC1B,YAAM,MAAM,OAAO,KAAK,MAAM,SAAS,YAAY,CAAC;AACpD,UAAI,KAAK,GAAG;AAAA,IACb,QAAQ;AACP,UAAI,OAAO,GAAG,EAAE,KAAK,EAAE,OAAO,0BAA0B,CAAC;AAAA,IAC1D;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.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"types": "./dist/server.d.ts",
|
|
16
|
+
"import": "./dist/server.js"
|
|
13
17
|
}
|
|
14
18
|
},
|
|
15
19
|
"sideEffects": false,
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
"@types/node": "^22.19.15",
|
|
30
34
|
"@types/react": "^18.3.28",
|
|
31
35
|
"@types/react-dom": "^18.3.7",
|
|
36
|
+
"@vercel/node": "^5.6.18",
|
|
32
37
|
"@vitejs/plugin-react-swc": "^3.11.0",
|
|
33
38
|
"autoprefixer": "^10.4.21",
|
|
34
39
|
"lucide-react": "^0.511.0",
|