@burdenoff/website-sdk 0.0.0-dev
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/LICENSE +24 -0
- package/README.md +357 -0
- package/dist/analytics/index.d.mts +27 -0
- package/dist/analytics/index.d.ts +27 -0
- package/dist/analytics/index.js +36 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/index.mjs +34 -0
- package/dist/analytics/index.mjs.map +1 -0
- package/dist/client/index.d.mts +29 -0
- package/dist/client/index.d.ts +29 -0
- package/dist/client/index.js +261 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +255 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/components/contact.d.mts +144 -0
- package/dist/components/contact.d.ts +144 -0
- package/dist/components/contact.js +806 -0
- package/dist/components/contact.js.map +1 -0
- package/dist/components/contact.mjs +780 -0
- package/dist/components/contact.mjs.map +1 -0
- package/dist/components/electron-download.d.mts +32 -0
- package/dist/components/electron-download.d.ts +32 -0
- package/dist/components/electron-download.js +131 -0
- package/dist/components/electron-download.js.map +1 -0
- package/dist/components/electron-download.mjs +108 -0
- package/dist/components/electron-download.mjs.map +1 -0
- package/dist/components/launch-countdown.d.mts +15 -0
- package/dist/components/launch-countdown.d.ts +15 -0
- package/dist/components/launch-countdown.js +89 -0
- package/dist/components/launch-countdown.js.map +1 -0
- package/dist/components/launch-countdown.mjs +87 -0
- package/dist/components/launch-countdown.mjs.map +1 -0
- package/dist/components/newsletter.d.mts +155 -0
- package/dist/components/newsletter.d.ts +155 -0
- package/dist/components/newsletter.js +880 -0
- package/dist/components/newsletter.js.map +1 -0
- package/dist/components/newsletter.mjs +854 -0
- package/dist/components/newsletter.mjs.map +1 -0
- package/dist/components/partners.d.mts +88 -0
- package/dist/components/partners.d.ts +88 -0
- package/dist/components/partners.js +1169 -0
- package/dist/components/partners.js.map +1 -0
- package/dist/components/partners.mjs +1143 -0
- package/dist/components/partners.mjs.map +1 -0
- package/dist/components/pricing.d.mts +194 -0
- package/dist/components/pricing.d.ts +194 -0
- package/dist/components/pricing.js +813 -0
- package/dist/components/pricing.js.map +1 -0
- package/dist/components/pricing.mjs +791 -0
- package/dist/components/pricing.mjs.map +1 -0
- package/dist/components/problems-solved.d.mts +37 -0
- package/dist/components/problems-solved.d.ts +37 -0
- package/dist/components/problems-solved.js +157 -0
- package/dist/components/problems-solved.js.map +1 -0
- package/dist/components/problems-solved.mjs +135 -0
- package/dist/components/problems-solved.mjs.map +1 -0
- package/dist/components/resource-links.d.mts +28 -0
- package/dist/components/resource-links.d.ts +28 -0
- package/dist/components/resource-links.js +229 -0
- package/dist/components/resource-links.js.map +1 -0
- package/dist/components/resource-links.mjs +227 -0
- package/dist/components/resource-links.mjs.map +1 -0
- package/dist/components/social-links.d.mts +37 -0
- package/dist/components/social-links.d.ts +37 -0
- package/dist/components/social-links.js +268 -0
- package/dist/components/social-links.js.map +1 -0
- package/dist/components/social-links.mjs +266 -0
- package/dist/components/social-links.mjs.map +1 -0
- package/dist/components/unsubscribe.d.mts +14 -0
- package/dist/components/unsubscribe.d.ts +14 -0
- package/dist/components/unsubscribe.js +538 -0
- package/dist/components/unsubscribe.js.map +1 -0
- package/dist/components/unsubscribe.mjs +517 -0
- package/dist/components/unsubscribe.mjs.map +1 -0
- package/dist/components/waitlist.d.mts +70 -0
- package/dist/components/waitlist.d.ts +70 -0
- package/dist/components/waitlist.js +864 -0
- package/dist/components/waitlist.js.map +1 -0
- package/dist/components/waitlist.mjs +841 -0
- package/dist/components/waitlist.mjs.map +1 -0
- package/dist/components/website-switcher.d.mts +27 -0
- package/dist/components/website-switcher.d.ts +27 -0
- package/dist/components/website-switcher.js +168 -0
- package/dist/components/website-switcher.js.map +1 -0
- package/dist/components/website-switcher.mjs +146 -0
- package/dist/components/website-switcher.mjs.map +1 -0
- package/dist/content/index.d.mts +247 -0
- package/dist/content/index.d.ts +247 -0
- package/dist/content/index.js +2458 -0
- package/dist/content/index.js.map +1 -0
- package/dist/content/index.mjs +2428 -0
- package/dist/content/index.mjs.map +1 -0
- package/dist/index.d.mts +72 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +6273 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +6204 -0
- package/dist/index.mjs.map +1 -0
- package/dist/product/index.d.mts +94 -0
- package/dist/product/index.d.ts +94 -0
- package/dist/product/index.js +282 -0
- package/dist/product/index.js.map +1 -0
- package/dist/product/index.mjs +278 -0
- package/dist/product/index.mjs.map +1 -0
- package/dist/provider-D7Rij9UM.d.mts +86 -0
- package/dist/provider-D7Rij9UM.d.ts +86 -0
- package/dist/seo/index.d.mts +64 -0
- package/dist/seo/index.d.ts +64 -0
- package/dist/seo/index.js +61 -0
- package/dist/seo/index.js.map +1 -0
- package/dist/seo/index.mjs +59 -0
- package/dist/seo/index.mjs.map +1 -0
- package/dist/utils/index.d.mts +21 -0
- package/dist/utils/index.d.ts +21 -0
- package/dist/utils/index.js +12 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/index.mjs +10 -0
- package/dist/utils/index.mjs.map +1 -0
- package/package.json +168 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform keys the component knows how to render. Extends as we add icons.
|
|
5
|
+
* Note: this is intentionally a small allowlist; the CMS JSON schema is
|
|
6
|
+
* open-ended and the component silently skips unknown keys.
|
|
7
|
+
*/
|
|
8
|
+
type SocialPlatform = "twitter" | "linkedin" | "github" | "facebook" | "youtube" | "instagram" | "discord" | "pinterest";
|
|
9
|
+
interface SocialLinksProps {
|
|
10
|
+
/**
|
|
11
|
+
* Map of platform → URL. If unset, reads from `useProduct().product.socialLinks`.
|
|
12
|
+
* Useful for passing static config when there's no `<ProductProvider>` upstream.
|
|
13
|
+
*/
|
|
14
|
+
links?: Record<string, string> | null;
|
|
15
|
+
/**
|
|
16
|
+
* Contact email — renders a Mail icon linking to `mailto:…`. If unset,
|
|
17
|
+
* reads from `useProduct().product.contactEmail`. Pass `null` to suppress.
|
|
18
|
+
*/
|
|
19
|
+
email?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Allowlist + ORDER of platforms to render. Defaults to the natural CMS
|
|
22
|
+
* order (whatever the JSON returns). Use this to pin a stable visual order
|
|
23
|
+
* across products that may have different platform sets.
|
|
24
|
+
*/
|
|
25
|
+
platforms?: readonly SocialPlatform[];
|
|
26
|
+
/** Whether to render the Mail icon for `email`. Default: `true` when an email is available. */
|
|
27
|
+
showEmail?: boolean;
|
|
28
|
+
/** Tailwind class controlling icon size. Default: `"h-5 w-5"`. */
|
|
29
|
+
iconClassName?: string;
|
|
30
|
+
/** Tailwind class for each `<a>` wrapping link. Defaults to a muted-foreground / hover-foreground transition. */
|
|
31
|
+
itemClassName?: string;
|
|
32
|
+
/** Tailwind class for the outer container. Default: `"flex space-x-4"`. */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
declare function SocialLinks({ links: linksProp, email: emailProp, platforms: platformAllow, showEmail, iconClassName, itemClassName, className, }: SocialLinksProps): react_jsx_runtime.JSX.Element | null;
|
|
36
|
+
|
|
37
|
+
export { SocialLinks, type SocialLinksProps, type SocialPlatform };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform keys the component knows how to render. Extends as we add icons.
|
|
5
|
+
* Note: this is intentionally a small allowlist; the CMS JSON schema is
|
|
6
|
+
* open-ended and the component silently skips unknown keys.
|
|
7
|
+
*/
|
|
8
|
+
type SocialPlatform = "twitter" | "linkedin" | "github" | "facebook" | "youtube" | "instagram" | "discord" | "pinterest";
|
|
9
|
+
interface SocialLinksProps {
|
|
10
|
+
/**
|
|
11
|
+
* Map of platform → URL. If unset, reads from `useProduct().product.socialLinks`.
|
|
12
|
+
* Useful for passing static config when there's no `<ProductProvider>` upstream.
|
|
13
|
+
*/
|
|
14
|
+
links?: Record<string, string> | null;
|
|
15
|
+
/**
|
|
16
|
+
* Contact email — renders a Mail icon linking to `mailto:…`. If unset,
|
|
17
|
+
* reads from `useProduct().product.contactEmail`. Pass `null` to suppress.
|
|
18
|
+
*/
|
|
19
|
+
email?: string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Allowlist + ORDER of platforms to render. Defaults to the natural CMS
|
|
22
|
+
* order (whatever the JSON returns). Use this to pin a stable visual order
|
|
23
|
+
* across products that may have different platform sets.
|
|
24
|
+
*/
|
|
25
|
+
platforms?: readonly SocialPlatform[];
|
|
26
|
+
/** Whether to render the Mail icon for `email`. Default: `true` when an email is available. */
|
|
27
|
+
showEmail?: boolean;
|
|
28
|
+
/** Tailwind class controlling icon size. Default: `"h-5 w-5"`. */
|
|
29
|
+
iconClassName?: string;
|
|
30
|
+
/** Tailwind class for each `<a>` wrapping link. Defaults to a muted-foreground / hover-foreground transition. */
|
|
31
|
+
itemClassName?: string;
|
|
32
|
+
/** Tailwind class for the outer container. Default: `"flex space-x-4"`. */
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
declare function SocialLinks({ links: linksProp, email: emailProp, platforms: platformAllow, showEmail, iconClassName, itemClassName, className, }: SocialLinksProps): react_jsx_runtime.JSX.Element | null;
|
|
36
|
+
|
|
37
|
+
export { SocialLinks, type SocialLinksProps, type SocialPlatform };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var lucideReact = require('lucide-react');
|
|
4
|
+
var react = require('react');
|
|
5
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
6
|
+
|
|
7
|
+
var __defProp = Object.defineProperty;
|
|
8
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
9
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
10
|
+
|
|
11
|
+
// src/client/security.ts
|
|
12
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
13
|
+
var RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
14
|
+
"authorization",
|
|
15
|
+
"cookie",
|
|
16
|
+
"x-actor-id",
|
|
17
|
+
"x-actor-type",
|
|
18
|
+
"x-consent-granted-by",
|
|
19
|
+
"x-consent-scope",
|
|
20
|
+
"x-consent-granted-at",
|
|
21
|
+
"x-consent-expires-at"
|
|
22
|
+
]);
|
|
23
|
+
function validateGatewayUrl(value) {
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(value);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error("Gateway URL must be a valid absolute URL");
|
|
29
|
+
}
|
|
30
|
+
if (url.protocol === "https:") {
|
|
31
|
+
return url.toString();
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname.toLowerCase())) {
|
|
34
|
+
return url.toString();
|
|
35
|
+
}
|
|
36
|
+
throw new Error("Gateway URL must use HTTPS unless it targets localhost");
|
|
37
|
+
}
|
|
38
|
+
function sanitizeHeaders(headers) {
|
|
39
|
+
if (!headers) return void 0;
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(headers).filter(
|
|
42
|
+
([key]) => !RESERVED_HEADERS.has(key.toLowerCase())
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/client/graphql-client.ts
|
|
48
|
+
var _WebSDKClient = class _WebSDKClient {
|
|
49
|
+
constructor(config) {
|
|
50
|
+
__publicField(this, "config");
|
|
51
|
+
__publicField(this, "queryCache", /* @__PURE__ */ new Map());
|
|
52
|
+
__publicField(this, "inflight", /* @__PURE__ */ new Map());
|
|
53
|
+
this.config = {
|
|
54
|
+
...config,
|
|
55
|
+
gatewayUrl: validateGatewayUrl(config.gatewayUrl),
|
|
56
|
+
headers: sanitizeHeaders(config.headers)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
getConfig() {
|
|
60
|
+
return this.config;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Execute a GraphQL query (with deduplication and caching)
|
|
64
|
+
*/
|
|
65
|
+
async query(query, variables) {
|
|
66
|
+
const cacheKey = JSON.stringify({ query, variables });
|
|
67
|
+
const cached = this.queryCache.get(cacheKey);
|
|
68
|
+
if (cached) {
|
|
69
|
+
if (Date.now() - cached.ts < _WebSDKClient.CACHE_TTL) {
|
|
70
|
+
return cached.data;
|
|
71
|
+
}
|
|
72
|
+
this.queryCache.delete(cacheKey);
|
|
73
|
+
}
|
|
74
|
+
const existing = this.inflight.get(cacheKey);
|
|
75
|
+
if (existing) {
|
|
76
|
+
return existing;
|
|
77
|
+
}
|
|
78
|
+
const promise = this.request(query, variables).then((result) => {
|
|
79
|
+
if (result.data && !result.errors?.length) {
|
|
80
|
+
if (this.queryCache.size >= _WebSDKClient.MAX_CACHE_ENTRIES) {
|
|
81
|
+
const oldestEntry = this.queryCache.keys().next().value;
|
|
82
|
+
if (oldestEntry) {
|
|
83
|
+
this.queryCache.delete(oldestEntry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.queryCache.set(cacheKey, { data: result, ts: Date.now() });
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}).finally(() => {
|
|
90
|
+
this.inflight.delete(cacheKey);
|
|
91
|
+
});
|
|
92
|
+
this.inflight.set(cacheKey, promise);
|
|
93
|
+
return promise;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Execute a GraphQL mutation
|
|
97
|
+
*/
|
|
98
|
+
async mutate(mutation, variables) {
|
|
99
|
+
return this.request(mutation, variables);
|
|
100
|
+
}
|
|
101
|
+
async request(query, variables) {
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch(this.config.gatewayUrl, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"x-product-id": this.config.productId,
|
|
108
|
+
...this.config.headers
|
|
109
|
+
},
|
|
110
|
+
body: JSON.stringify({ query, variables })
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
console.error(
|
|
114
|
+
"[WebSDK] GraphQL request failed",
|
|
115
|
+
response.status,
|
|
116
|
+
response.statusText
|
|
117
|
+
);
|
|
118
|
+
return {
|
|
119
|
+
errors: [
|
|
120
|
+
{
|
|
121
|
+
message: `HTTP ${response.status}: ${response.statusText}`,
|
|
122
|
+
extensions: { code: `HTTP_${response.status}` }
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return await response.json();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error(
|
|
130
|
+
"[WebSDK] Network error",
|
|
131
|
+
error instanceof Error ? error.message : "unknown error"
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
errors: [
|
|
135
|
+
{
|
|
136
|
+
message: error instanceof Error ? error.message : "Network error",
|
|
137
|
+
extensions: { code: "NETWORK_ERROR" }
|
|
138
|
+
}
|
|
139
|
+
]
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
__publicField(_WebSDKClient, "CACHE_TTL", 5 * 60 * 1e3);
|
|
145
|
+
// 5 minutes
|
|
146
|
+
__publicField(_WebSDKClient, "MAX_CACHE_ENTRIES", 100);
|
|
147
|
+
react.createContext(null);
|
|
148
|
+
var ProductContext = react.createContext({
|
|
149
|
+
product: null,
|
|
150
|
+
loading: true,
|
|
151
|
+
error: null,
|
|
152
|
+
hasProvider: false,
|
|
153
|
+
refetch: () => {
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
function useProduct() {
|
|
157
|
+
return react.useContext(ProductContext);
|
|
158
|
+
}
|
|
159
|
+
function DiscordIcon({ className }) {
|
|
160
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
161
|
+
"svg",
|
|
162
|
+
{
|
|
163
|
+
role: "img",
|
|
164
|
+
viewBox: "0 0 24 24",
|
|
165
|
+
fill: "currentColor",
|
|
166
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
167
|
+
className,
|
|
168
|
+
"aria-hidden": "true",
|
|
169
|
+
children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M20.317 4.369A19.79 19.79 0 0 0 16.558 3a.075.075 0 0 0-.079.037c-.34.6-.717 1.382-.98 1.99a18.27 18.27 0 0 0-5.998 0 12.51 12.51 0 0 0-.99-1.99.077.077 0 0 0-.079-.037 19.74 19.74 0 0 0-3.76 1.369.07.07 0 0 0-.032.028C.533 8.045-.32 11.6.099 15.114a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.027 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.105 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .078-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .079.009c.12.099.246.198.372.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.106c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.055c.5-4.063-.838-7.587-3.549-10.717a.061.061 0 0 0-.031-.028zM8.02 13.001c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.094 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.974 0c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.094 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z" })
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
function PinterestIcon({ className }) {
|
|
174
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
175
|
+
"svg",
|
|
176
|
+
{
|
|
177
|
+
role: "img",
|
|
178
|
+
viewBox: "0 0 24 24",
|
|
179
|
+
fill: "currentColor",
|
|
180
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
181
|
+
className,
|
|
182
|
+
"aria-hidden": "true",
|
|
183
|
+
children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12.017 24c6.624 0 11.99-5.367 11.99-11.987C24.007 5.367 18.641 0 12.017 0z" })
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
var PLATFORMS = {
|
|
188
|
+
twitter: { Icon: lucideReact.Twitter, label: "Twitter / X", aliases: ["x"] },
|
|
189
|
+
linkedin: { Icon: lucideReact.Linkedin, label: "LinkedIn" },
|
|
190
|
+
github: { Icon: lucideReact.Github, label: "GitHub" },
|
|
191
|
+
facebook: { Icon: lucideReact.Facebook, label: "Facebook" },
|
|
192
|
+
youtube: { Icon: lucideReact.Youtube, label: "YouTube" },
|
|
193
|
+
instagram: { Icon: lucideReact.Instagram, label: "Instagram" },
|
|
194
|
+
discord: { Icon: DiscordIcon, label: "Discord" },
|
|
195
|
+
pinterest: { Icon: PinterestIcon, label: "Pinterest" }
|
|
196
|
+
};
|
|
197
|
+
var KEY_TO_PLATFORM = (() => {
|
|
198
|
+
const map = {};
|
|
199
|
+
for (const [key, def] of Object.entries(PLATFORMS)) {
|
|
200
|
+
map[key] = key;
|
|
201
|
+
for (const alias of def.aliases ?? []) {
|
|
202
|
+
map[alias] = key;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return map;
|
|
206
|
+
})();
|
|
207
|
+
var DEFAULT_ITEM_CLASS = "text-muted-foreground hover:text-foreground transition-colors";
|
|
208
|
+
function isSafeUrl(value) {
|
|
209
|
+
const trimmed = value.trim();
|
|
210
|
+
if (/^[/?#]/.test(trimmed)) return true;
|
|
211
|
+
return /^(https?:|mailto:|tel:)/i.test(trimmed);
|
|
212
|
+
}
|
|
213
|
+
function SocialLinks({
|
|
214
|
+
links: linksProp,
|
|
215
|
+
email: emailProp,
|
|
216
|
+
platforms: platformAllow,
|
|
217
|
+
showEmail,
|
|
218
|
+
iconClassName = "h-5 w-5",
|
|
219
|
+
itemClassName = DEFAULT_ITEM_CLASS,
|
|
220
|
+
className = "flex space-x-4"
|
|
221
|
+
}) {
|
|
222
|
+
const { product } = useProduct();
|
|
223
|
+
const links = linksProp ?? product?.socialLinks ?? {};
|
|
224
|
+
const email = emailProp !== void 0 ? emailProp : product?.contactEmail ?? null;
|
|
225
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
226
|
+
for (const [rawKey, url] of Object.entries(links)) {
|
|
227
|
+
if (!url || typeof url !== "string") continue;
|
|
228
|
+
if (!isSafeUrl(url)) continue;
|
|
229
|
+
const platform = KEY_TO_PLATFORM[rawKey.toLowerCase()];
|
|
230
|
+
if (!platform) continue;
|
|
231
|
+
resolved.set(platform, url);
|
|
232
|
+
}
|
|
233
|
+
const orderedPlatforms = platformAllow ? platformAllow.filter((p) => resolved.has(p)) : Array.from(resolved.keys());
|
|
234
|
+
const wantEmail = (showEmail ?? !!email) && !!email;
|
|
235
|
+
if (orderedPlatforms.length === 0 && !wantEmail) return null;
|
|
236
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, children: [
|
|
237
|
+
orderedPlatforms.map((platform) => {
|
|
238
|
+
const { Icon, label } = PLATFORMS[platform];
|
|
239
|
+
const url = resolved.get(platform);
|
|
240
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
241
|
+
"a",
|
|
242
|
+
{
|
|
243
|
+
href: url,
|
|
244
|
+
target: "_blank",
|
|
245
|
+
rel: "noopener noreferrer",
|
|
246
|
+
"aria-label": label,
|
|
247
|
+
className: itemClassName,
|
|
248
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(Icon, { className: iconClassName })
|
|
249
|
+
},
|
|
250
|
+
platform
|
|
251
|
+
);
|
|
252
|
+
}),
|
|
253
|
+
wantEmail && /* @__PURE__ */ jsxRuntime.jsx(
|
|
254
|
+
"a",
|
|
255
|
+
{
|
|
256
|
+
href: `mailto:${email}`,
|
|
257
|
+
"aria-label": "Email",
|
|
258
|
+
className: itemClassName,
|
|
259
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mail, { className: iconClassName })
|
|
260
|
+
},
|
|
261
|
+
"email"
|
|
262
|
+
)
|
|
263
|
+
] });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
exports.SocialLinks = SocialLinks;
|
|
267
|
+
//# sourceMappingURL=social-links.js.map
|
|
268
|
+
//# sourceMappingURL=social-links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/client/security.ts","../../src/client/graphql-client.ts","../../src/client/provider.tsx","../../src/product/product-provider.tsx","../../src/components/social-links.tsx"],"names":["createContext","useContext","jsx","Twitter","Linkedin","Github","Facebook","Youtube","Instagram","jsxs","Mail"],"mappings":"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","file":"social-links.js","sourcesContent":["const LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"::1\"]);\nconst RESERVED_HEADERS = new Set([\n \"authorization\",\n \"cookie\",\n \"x-actor-id\",\n \"x-actor-type\",\n \"x-consent-granted-by\",\n \"x-consent-scope\",\n \"x-consent-granted-at\",\n \"x-consent-expires-at\",\n]);\n\nexport function validateGatewayUrl(value: string): string {\n let url: URL;\n\n try {\n url = new URL(value);\n } catch {\n throw new Error(\"Gateway URL must be a valid absolute URL\");\n }\n\n if (url.protocol === \"https:\") {\n return url.toString();\n }\n\n if (\n url.protocol === \"http:\" &&\n LOOPBACK_HOSTS.has(url.hostname.toLowerCase())\n ) {\n return url.toString();\n }\n\n throw new Error(\"Gateway URL must use HTTPS unless it targets localhost\");\n}\n\nexport function sanitizeHeaders(\n headers?: Record<string, string>,\n): Record<string, string> | undefined {\n if (!headers) return undefined;\n\n return Object.fromEntries(\n Object.entries(headers).filter(\n ([key]) => !RESERVED_HEADERS.has(key.toLowerCase()),\n ),\n );\n}\n","/**\n * Lightweight GraphQL client for the Burdenoff Web SDK.\n * Uses native fetch - zero external dependencies.\n * All calls are unauthenticated (public gateway operations only).\n */\n\nimport { sanitizeHeaders, validateGatewayUrl } from \"./security\";\n\nexport interface WebSDKClientConfig {\n /** Global public gateway URL (e.g. \"https://api.burdenoff.com/global/graphql\") */\n gatewayUrl: string;\n /** Platform product ID for scoping operations */\n productId: string;\n /** Product slug (used for public product queries) */\n productSlug?: string;\n /** reCAPTCHA site key (if not fetched from product config) */\n recaptchaSiteKey?: string;\n /** Rybbit analytics site ID */\n rybbitSiteId?: string;\n /** Custom headers to include in all requests */\n headers?: Record<string, string>;\n}\n\nexport interface GraphQLResponse<T = Record<string, unknown>> {\n data?: T;\n errors?: Array<{\n message: string;\n locations?: Array<{ line: number; column: number }>;\n path?: Array<string | number>;\n extensions?: Record<string, unknown>;\n }>;\n}\n\nexport class WebSDKClient {\n private config: WebSDKClientConfig;\n private queryCache = new Map<\n string,\n { data: GraphQLResponse<unknown>; ts: number }\n >();\n private inflight = new Map<string, Promise<GraphQLResponse<unknown>>>();\n private static CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n private static MAX_CACHE_ENTRIES = 100;\n\n constructor(config: WebSDKClientConfig) {\n this.config = {\n ...config,\n gatewayUrl: validateGatewayUrl(config.gatewayUrl),\n headers: sanitizeHeaders(config.headers),\n };\n }\n\n getConfig(): WebSDKClientConfig {\n return this.config;\n }\n\n /**\n * Execute a GraphQL query (with deduplication and caching)\n */\n async query<T = Record<string, unknown>>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n const cacheKey = JSON.stringify({ query, variables });\n\n // Return cached response if still valid, evict if expired\n const cached = this.queryCache.get(cacheKey);\n if (cached) {\n if (Date.now() - cached.ts < WebSDKClient.CACHE_TTL) {\n return cached.data as GraphQLResponse<T>;\n }\n this.queryCache.delete(cacheKey);\n }\n\n // Deduplicate: if same query is already in-flight, reuse the promise\n const existing = this.inflight.get(cacheKey);\n if (existing) {\n return existing as Promise<GraphQLResponse<T>>;\n }\n\n const promise = this.request<T>(query, variables)\n .then((result) => {\n // Cache successful responses only\n if (result.data && !result.errors?.length) {\n if (this.queryCache.size >= WebSDKClient.MAX_CACHE_ENTRIES) {\n const oldestEntry = this.queryCache.keys().next().value;\n if (oldestEntry) {\n this.queryCache.delete(oldestEntry);\n }\n }\n this.queryCache.set(cacheKey, { data: result, ts: Date.now() });\n }\n return result;\n })\n .finally(() => {\n this.inflight.delete(cacheKey);\n });\n\n this.inflight.set(cacheKey, promise);\n return promise;\n }\n\n /**\n * Execute a GraphQL mutation\n */\n async mutate<T = Record<string, unknown>>(\n mutation: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n return this.request<T>(mutation, variables);\n }\n\n private async request<T>(\n query: string,\n variables?: Record<string, unknown>,\n ): Promise<GraphQLResponse<T>> {\n try {\n const response = await fetch(this.config.gatewayUrl, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"x-product-id\": this.config.productId,\n ...this.config.headers,\n },\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n console.error(\n \"[WebSDK] GraphQL request failed\",\n response.status,\n response.statusText,\n );\n return {\n errors: [\n {\n message: `HTTP ${response.status}: ${response.statusText}`,\n extensions: { code: `HTTP_${response.status}` },\n },\n ],\n };\n }\n\n return (await response.json()) as GraphQLResponse<T>;\n } catch (error) {\n console.error(\n \"[WebSDK] Network error\",\n error instanceof Error ? error.message : \"unknown error\",\n );\n return {\n errors: [\n {\n message: error instanceof Error ? error.message : \"Network error\",\n extensions: { code: \"NETWORK_ERROR\" },\n },\n ],\n };\n }\n }\n}\n","/**\n * React context provider for the Web SDK client.\n * Wraps the application with WebSDKClient configuration.\n */\n\"use client\";\n\nimport { createContext, useContext, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { resolveGatewayUrl } from \"./gateway-resolver\";\nimport { WebSDKClient, type WebSDKClientConfig } from \"./graphql-client\";\n\nconst WebSDKContext = createContext<WebSDKClient | null>(null);\n\nexport interface WebSDKProviderProps {\n /** SDK client configuration */\n config: WebSDKClientConfig;\n /** Child components */\n children: ReactNode;\n}\n\n/**\n * Provider component that makes the WebSDKClient available to all child components.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={{\n * gatewayUrl: \"https://api.burdenoff.com/global/graphql\",\n * productId: \"your-product-id\",\n * productSlug: \"your-product\",\n * }}>\n * <App />\n * </WebSDKProvider>\n * ```\n */\nexport function WebSDKProvider({ config, children }: WebSDKProviderProps) {\n const client = useMemo(() => {\n const resolvedConfig = {\n ...config,\n gatewayUrl: resolveGatewayUrl(config.gatewayUrl),\n };\n return new WebSDKClient(resolvedConfig);\n }, [config]);\n\n return (\n <WebSDKContext.Provider value={client}>{children}</WebSDKContext.Provider>\n );\n}\n\n/**\n * Hook to access the WebSDKClient instance.\n * Must be used within a WebSDKProvider.\n */\nexport function useWebSDK(): WebSDKClient {\n const client = useContext(WebSDKContext);\n if (!client) {\n throw new Error(\"useWebSDK must be used within a WebSDKProvider\");\n }\n return client;\n}\n\n/**\n * Hook to access the SDK configuration.\n * Useful for components that need config values like productId, recaptchaSiteKey, etc.\n */\nexport function useWebSDKConfig(): WebSDKClientConfig {\n const client = useWebSDK();\n return client.getConfig();\n}\n","/**\n * Product Provider\n *\n * React context and hooks for accessing product metadata from the global tenant service.\n * Fetches product info (name, logo, SEO, contact, reCAPTCHA key, etc.) on mount\n * and provides it to all child components.\n */\n\"use client\";\n\nimport {\n createContext,\n useContext,\n useState,\n useLayoutEffect,\n useCallback,\n useMemo,\n} from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useWebSDK, useWebSDKConfig } from \"../client/provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface ProductData {\n id: string;\n name: string;\n slug: string;\n description?: string | null;\n status: string;\n logoUrl?: string | null;\n website?: string | null;\n appUrl?: string | null;\n docsUrl?: string | null;\n contactEmail?: string | null;\n supportEmail?: string | null;\n contactPhone?: string | null;\n contactAddress?: string | null;\n seoTitle?: string | null;\n seoDescription?: string | null;\n seoKeywords?: string | null;\n ogImage?: string | null;\n socialLinks?: Record<string, string> | null;\n /**\n * Resource links for the website footer \"Resources\" column\n * (JSON: { docs, community, careers, team, companies, ... }).\n * Same shape as socialLinks. Free-form keys; the ResourceLinks\n * component renders known keys and silently ignores the rest.\n * Backed by Product.resourceLinks on the backend (global-tenant-svc).\n */\n resourceLinks?: Record<string, string> | null;\n recaptchaSiteKey?: string | null;\n rybbitSiteId?: string | null;\n notificationEmail?: string | null;\n metadata?: Record<string, unknown> | null;\n}\n\nexport interface ProductProviderProps {\n /** Child components */\n children: ReactNode;\n /** Optional fallback product data (used while loading) */\n fallback?: Partial<ProductData>;\n}\n\n// ============================================================================\n// GraphQL Query\n// ============================================================================\n\n/**\n * Body of fields requested for every product fetch — shared by the primary\n * query and the legacy-backend fallback below. Kept as a string fragment\n * (not a real GraphQL `fragment` block) so we can append/strip\n * `resourceLinks` without touching anything else.\n */\nconst PRODUCT_FIELDS_BASE = `\n id\n name\n slug\n description\n status\n logoUrl\n website\n appUrl\n docsUrl\n contactEmail\n supportEmail\n contactPhone\n contactAddress\n seoTitle\n seoDescription\n seoKeywords\n ogImage\n socialLinks\n recaptchaSiteKey\n rybbitSiteId\n notificationEmail\n metadata\n`;\n\n/**\n * Primary query — includes the recently-added `resourceLinks` field.\n * Backends that have run the corresponding `global-tenant-svc` migration\n * (PR #64) expose this field; older ones don't, in which case\n * GraphQL validation rejects the whole query with\n * `Cannot query field \"resourceLinks\" on type \"PublicProduct\"`. The fetch\n * helper detects that specific error and transparently retries the\n * fallback below, so the SDK works on both pre- and post-migration\n * deployments without consumer-side gating.\n */\nconst PUBLIC_PRODUCT_QUERY = `\n query PublicProduct($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n resourceLinks\n }\n }\n`;\n\n/**\n * Fallback query for older backend deployments. Identical to the primary\n * minus `resourceLinks`. Consumers see `product.resourceLinks === undefined`\n * and any consumer (e.g. `<ResourceLinks/>`) falls through to prop / default\n * resolution paths.\n */\nconst PUBLIC_PRODUCT_QUERY_FALLBACK = `\n query PublicProductFallback($slug: String!) {\n publicProduct(slug: $slug) {\n ${PRODUCT_FIELDS_BASE}\n }\n }\n`;\n\n// ============================================================================\n// Context\n// ============================================================================\n\ninterface ProductContextValue {\n product: ProductData | null;\n loading: boolean;\n error: string | null;\n hasProvider: boolean;\n refetch: () => void;\n}\n\nconst ProductContext = createContext<ProductContextValue>({\n product: null,\n loading: true,\n error: null,\n hasProvider: false,\n refetch: () => {},\n});\n\n// ============================================================================\n// Provider\n// ============================================================================\n\n/**\n * ProductProvider fetches and provides product metadata to child components.\n * Uses the product slug from WebSDKConfig to fetch from the public API.\n *\n * @example\n * ```tsx\n * <WebSDKProvider config={sdkConfig}>\n * <ProductProvider>\n * <App />\n * </ProductProvider>\n * </WebSDKProvider>\n * ```\n */\nexport function ProductProvider({ children, fallback }: ProductProviderProps) {\n const client = useWebSDK();\n const config = useWebSDKConfig();\n const [product, setProduct] = useState<ProductData | null>(\n fallback ? (fallback as ProductData) : null,\n );\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const fetchProduct = useCallback(async () => {\n if (!config.productSlug) {\n setLoading(false);\n return;\n }\n\n setLoading(true);\n setError(null);\n\n try {\n let result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY,\n { slug: config.productSlug },\n );\n\n // Backwards-compat fallback: if the backend's `publicProduct` schema\n // doesn't yet expose `resourceLinks` (deployed before\n // `global-tenant-svc` PR #64), the primary query fails validation\n // with a \"Cannot query field …\" error and `result.data` is null.\n // Retry the fallback query that omits `resourceLinks`; consumers\n // gracefully see `product.resourceLinks === undefined` and fall\n // through their own resolution paths.\n const schemaMissingResourceLinks = result.errors?.some((e) =>\n /Cannot query field [\"']?resourceLinks[\"']?/i.test(e?.message ?? \"\"),\n );\n if (schemaMissingResourceLinks) {\n result = await client.query<{ publicProduct: ProductData | null }>(\n PUBLIC_PRODUCT_QUERY_FALLBACK,\n { slug: config.productSlug },\n );\n }\n\n if (result.errors?.length) {\n setError(result.errors[0]?.message ?? \"Unknown error\");\n } else if (result.data?.publicProduct) {\n setProduct(result.data.publicProduct);\n }\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Failed to fetch product\");\n } finally {\n setLoading(false);\n }\n }, [client, config.productSlug]);\n\n // Use useLayoutEffect for earliest possible fetch after commit (before paint).\n // Runs when slug or client changes. Client-side query cache + dedup ensures\n // no duplicate network requests even if React StrictMode fires this twice.\n useLayoutEffect(() => {\n fetchProduct();\n }, [fetchProduct]);\n\n const value = useMemo(\n () => ({\n product,\n loading,\n error,\n hasProvider: true,\n refetch: fetchProduct,\n }),\n [product, loading, error, fetchProduct],\n );\n\n return (\n <ProductContext.Provider value={value}>{children}</ProductContext.Provider>\n );\n}\n\n// ============================================================================\n// Hooks\n// ============================================================================\n\n/**\n * Hook to access the current product data.\n *\n * @example\n * ```tsx\n * const { product, loading } = useProduct();\n * if (loading) return <Loading />;\n * return <h1>{product?.name}</h1>;\n * ```\n */\nexport function useProduct(): ProductContextValue {\n return useContext(ProductContext);\n}\n\n/**\n * Hook to get product-specific configuration.\n * Merges SDK config with fetched product data.\n * Useful for getting reCAPTCHA keys, analytics IDs, etc.\n *\n * @example\n * ```tsx\n * const { recaptchaSiteKey, rybbitSiteId } = useProductConfig();\n * ```\n */\nexport function useProductConfig(): {\n recaptchaSiteKey: string | null;\n rybbitSiteId: string | null;\n contactEmail: string | null;\n supportEmail: string | null;\n notificationEmail: string | null;\n productName: string | null;\n logoUrl: string | null;\n} {\n const { product } = useProduct();\n const config = useWebSDKConfig();\n\n return {\n // Prefer product-level config, fall back to SDK config\n recaptchaSiteKey:\n product?.recaptchaSiteKey ?? config.recaptchaSiteKey ?? null,\n rybbitSiteId: product?.rybbitSiteId ?? config.rybbitSiteId ?? null,\n contactEmail: product?.contactEmail ?? null,\n supportEmail: product?.supportEmail ?? null,\n notificationEmail:\n product?.notificationEmail ?? product?.contactEmail ?? null,\n productName: product?.name ?? null,\n logoUrl: product?.logoUrl ?? null,\n };\n}\n","\"use client\";\n\n/**\n * SocialLinks Component\n *\n * Renders a row of social-media icons sourced from the **product CMS**.\n * The `socialLinks` field on `Product` (and `PublicProduct`) is a free-form\n * JSON record: `{ twitter, github, linkedin, facebook, youtube, instagram,\n * discord, pinterest, … }`. The admin sets per-product URLs in\n * `microfe-product → Products → <product>`; this component reads them via\n * `useProduct()` and renders icons for the platforms we know how to map.\n *\n * Unknown keys are **silently ignored** — the schema accepts any string key\n * so new platforms can be wired in admin without an SDK change, but they\n * won't render until this map grows.\n *\n * Why a component instead of inlining in each product website's Footer\n *\n * - Single source of truth for the platform → icon mapping.\n * - One swap to add a new platform (e.g. Threads, Bluesky) across all 5\n * product websites.\n * - Eliminates the drift today between hard-coded URLs and CMS values\n * (e.g. vibecontrols-website Footer had `twitter.com/vibecontrols`\n * hard-coded while CMS had `twitter.com/burdenoff`).\n *\n * @example\n * ```tsx\n * import { SocialLinks } from '@burdenoff/website-sdk'\n *\n * // Auto-derives from useProduct().product.socialLinks\n * <SocialLinks />\n *\n * // Or pass explicitly\n * <SocialLinks\n * links={{ twitter: 'https://twitter.com/example', github: 'https://github.com/example' }}\n * email=\"contact@example.com\"\n * />\n *\n * // Restrict + reorder\n * <SocialLinks platforms={['github', 'twitter', 'linkedin']} />\n * ```\n */\n\nimport { type ComponentType } from \"react\";\n\nimport {\n Facebook,\n Github,\n Instagram,\n Linkedin,\n Mail,\n Twitter,\n Youtube,\n} from \"lucide-react\";\n\nimport { useProduct } from \"../product/product-provider\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Platform keys the component knows how to render. Extends as we add icons.\n * Note: this is intentionally a small allowlist; the CMS JSON schema is\n * open-ended and the component silently skips unknown keys.\n */\nexport type SocialPlatform =\n | \"twitter\"\n | \"linkedin\"\n | \"github\"\n | \"facebook\"\n | \"youtube\"\n | \"instagram\"\n | \"discord\"\n | \"pinterest\";\n\nexport interface SocialLinksProps {\n /**\n * Map of platform → URL. If unset, reads from `useProduct().product.socialLinks`.\n * Useful for passing static config when there's no `<ProductProvider>` upstream.\n */\n links?: Record<string, string> | null;\n /**\n * Contact email — renders a Mail icon linking to `mailto:…`. If unset,\n * reads from `useProduct().product.contactEmail`. Pass `null` to suppress.\n */\n email?: string | null;\n /**\n * Allowlist + ORDER of platforms to render. Defaults to the natural CMS\n * order (whatever the JSON returns). Use this to pin a stable visual order\n * across products that may have different platform sets.\n */\n platforms?: readonly SocialPlatform[];\n /** Whether to render the Mail icon for `email`. Default: `true` when an email is available. */\n showEmail?: boolean;\n /** Tailwind class controlling icon size. Default: `\"h-5 w-5\"`. */\n iconClassName?: string;\n /** Tailwind class for each `<a>` wrapping link. Defaults to a muted-foreground / hover-foreground transition. */\n itemClassName?: string;\n /** Tailwind class for the outer container. Default: `\"flex space-x-4\"`. */\n className?: string;\n}\n\n// ============================================================================\n// Platform → icon map\n// ============================================================================\n\n/**\n * Custom inline SVG for platforms not covered by lucide-react.\n * Stays minimal — single-path icons keep bundle size tight.\n */\nfunction DiscordIcon({ className }: { className?: string }) {\n return (\n <svg\n role=\"img\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n xmlns=\"http://www.w3.org/2000/svg\"\n className={className}\n aria-hidden=\"true\"\n >\n <path d=\"M20.317 4.369A19.79 19.79 0 0 0 16.558 3a.075.075 0 0 0-.079.037c-.34.6-.717 1.382-.98 1.99a18.27 18.27 0 0 0-5.998 0 12.51 12.51 0 0 0-.99-1.99.077.077 0 0 0-.079-.037 19.74 19.74 0 0 0-3.76 1.369.07.07 0 0 0-.032.028C.533 8.045-.32 11.6.099 15.114a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.027 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.105 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .078-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .079.009c.12.099.246.198.372.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.106c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.055c.5-4.063-.838-7.587-3.549-10.717a.061.061 0 0 0-.031-.028zM8.02 13.001c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.094 2.157 2.418 0 1.334-.956 2.419-2.157 2.419zm7.974 0c-1.182 0-2.157-1.085-2.157-2.419 0-1.333.956-2.418 2.157-2.418 1.21 0 2.176 1.094 2.157 2.418 0 1.334-.946 2.419-2.157 2.419z\" />\n </svg>\n );\n}\n\nfunction PinterestIcon({ className }: { className?: string }) {\n return (\n <svg\n role=\"img\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n xmlns=\"http://www.w3.org/2000/svg\"\n className={className}\n aria-hidden=\"true\"\n >\n <path d=\"M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.357-.629-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12.017 24c6.624 0 11.99-5.367 11.99-11.987C24.007 5.367 18.641 0 12.017 0z\" />\n </svg>\n );\n}\n\ninterface PlatformDef {\n /** lucide-react component or custom SVG component */\n Icon: ComponentType<{ className?: string }>;\n /** Accessible label for the link */\n label: string;\n /** Optional alias keys that should also resolve to this platform */\n aliases?: readonly string[];\n}\n\nconst PLATFORMS: Record<SocialPlatform, PlatformDef> = {\n twitter: { Icon: Twitter, label: \"Twitter / X\", aliases: [\"x\"] },\n linkedin: { Icon: Linkedin, label: \"LinkedIn\" },\n github: { Icon: Github, label: \"GitHub\" },\n facebook: { Icon: Facebook, label: \"Facebook\" },\n youtube: { Icon: Youtube, label: \"YouTube\" },\n instagram: { Icon: Instagram, label: \"Instagram\" },\n discord: { Icon: DiscordIcon, label: \"Discord\" },\n pinterest: { Icon: PinterestIcon, label: \"Pinterest\" },\n};\n\n/**\n * Build a `key → SocialPlatform` lookup so aliases (e.g. `\"x\"` → `\"twitter\"`)\n * resolve in O(1). Memoised at module scope; never recomputed.\n */\nconst KEY_TO_PLATFORM: Record<string, SocialPlatform> = (() => {\n const map: Record<string, SocialPlatform> = {};\n for (const [key, def] of Object.entries(PLATFORMS) as [\n SocialPlatform,\n PlatformDef,\n ][]) {\n map[key] = key;\n for (const alias of def.aliases ?? []) {\n map[alias] = key;\n }\n }\n return map;\n})();\n\nconst DEFAULT_ITEM_CLASS =\n \"text-muted-foreground hover:text-foreground transition-colors\";\n\n/**\n * Defense-in-depth XSS guard. Only http(s), mailto, and tel URLs are allowed\n * through to a rendered `href`; anything else (notably `javascript:`,\n * `data:`, `vbscript:`) is rejected and the link is silently skipped. Inputs\n * are admin-controlled today, but treating the URL field as untrusted is\n * cheap and matches the wider Burdenoff posture.\n */\nfunction isSafeUrl(value: string): boolean {\n const trimmed = value.trim();\n // Relative URLs (no scheme) are safe — browser treats them as same-origin.\n if (/^[/?#]/.test(trimmed)) return true;\n return /^(https?:|mailto:|tel:)/i.test(trimmed);\n}\n\n// ============================================================================\n// Component\n// ============================================================================\n\nexport function SocialLinks({\n links: linksProp,\n email: emailProp,\n platforms: platformAllow,\n showEmail,\n iconClassName = \"h-5 w-5\",\n itemClassName = DEFAULT_ITEM_CLASS,\n className = \"flex space-x-4\",\n}: SocialLinksProps) {\n const { product } = useProduct();\n\n // Source of truth: prop > product CMS > empty.\n const links: Record<string, string> = (linksProp ??\n product?.socialLinks ??\n {}) as Record<string, string>;\n // `emailProp === undefined` → fall back to CMS. Explicit `null` from the\n // caller means \"suppress the mail icon entirely\" — don't fall back. This\n // matches the documented prop contract.\n const email: string | null =\n emailProp !== undefined ? emailProp : (product?.contactEmail ?? null);\n\n // Normalise CMS keys → canonical platform names, dedupe, preserve URL.\n // Last-writer-wins on duplicate platforms (alias + canonical both set).\n // Rejects `javascript:`/`data:`/`vbscript:` URLs as a defense-in-depth XSS\n // guard — a compromised admin CMS could otherwise inject an active script\n // into the `href`. Only http(s), mailto, and tel schemes are allowed.\n const resolved = new Map<SocialPlatform, string>();\n for (const [rawKey, url] of Object.entries(links)) {\n if (!url || typeof url !== \"string\") continue;\n if (!isSafeUrl(url)) continue;\n const platform = KEY_TO_PLATFORM[rawKey.toLowerCase()];\n if (!platform) continue; // unknown platform key — silently skip\n resolved.set(platform, url);\n }\n\n // Optional allowlist + ordering. When provided, only render listed\n // platforms, and in that order. Otherwise render in insertion order\n // (which mirrors the JSON object key order from the CMS).\n const orderedPlatforms: SocialPlatform[] = platformAllow\n ? platformAllow.filter((p) => resolved.has(p))\n : Array.from(resolved.keys());\n\n const wantEmail = (showEmail ?? !!email) && !!email;\n\n // Render nothing at all when no links and no email — caller can render an\n // empty parent without an extra falsy-guard.\n if (orderedPlatforms.length === 0 && !wantEmail) return null;\n\n return (\n <div className={className}>\n {orderedPlatforms.map((platform) => {\n const { Icon, label } = PLATFORMS[platform];\n const url = resolved.get(platform)!;\n return (\n <a\n key={platform}\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-label={label}\n className={itemClassName}\n >\n <Icon className={iconClassName} />\n </a>\n );\n })}\n {wantEmail && (\n <a\n key=\"email\"\n href={`mailto:${email}`}\n aria-label=\"Email\"\n className={itemClassName}\n >\n <Mail className={iconClassName} />\n </a>\n )}\n </div>\n );\n}\n"]}
|