@apex-inc/react 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @apex-inc/react
2
+
3
+ Embeddable React components for [Apex](https://apex.inc) communication surfaces.
4
+
5
+ **v0.1 — MVP.** Ships `<ApexPreferenceCenter>` only. Embeddable inbox and web-push subscription components arrive in v0.2 once a design partner validates demand.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @apex-inc/react @apex-inc/sdk
11
+ ```
12
+
13
+ ## Quick start (styled default)
14
+
15
+ ```tsx
16
+ import { ApexPreferenceCenter } from "@apex-inc/react";
17
+
18
+ export default function PreferencesPage({ token }: { token: string }) {
19
+ return <ApexPreferenceCenter endUserAuth={{ token }} />;
20
+ }
21
+ ```
22
+
23
+ Server-side, mint the token with the Apex SDK:
24
+
25
+ ```ts
26
+ // app/api/apex-token/route.ts (Next.js example)
27
+ import { signEndUserToken } from "@apex-inc/sdk/tokens";
28
+
29
+ export async function GET(request: Request) {
30
+ const user = await getCurrentUser(request);
31
+ const token = signEndUserToken({
32
+ workspaceKey: process.env.APEX_PROJECT_KEY!,
33
+ endUserId: user.id,
34
+ expiresIn: 3600,
35
+ });
36
+ return Response.json({ token });
37
+ }
38
+ ```
39
+
40
+ The token is short-lived by design — refresh from your server when it expires.
41
+
42
+ ## Customizing the look
43
+
44
+ ```tsx
45
+ <ApexPreferenceCenter
46
+ endUserAuth={{ token }}
47
+ theme={{
48
+ accentColor: "#0066FF",
49
+ fontFamily: '"Inter", system-ui, sans-serif',
50
+ borderRadius: 12,
51
+ }}
52
+ />
53
+ ```
54
+
55
+ ## Headless — bring your own UI
56
+
57
+ When the styled default doesn't match your design system, render the component with your own JSX:
58
+
59
+ ```tsx
60
+ import { ApexPreferenceCenter, type ApexChannel } from "@apex-inc/react";
61
+
62
+ const CHANNELS: ApexChannel[] = ["email", "inbox", "web_push", "mobile_push"];
63
+
64
+ export default function PreferencesPage({ token }: { token: string }) {
65
+ return (
66
+ <ApexPreferenceCenter
67
+ endUserAuth={{ token }}
68
+ render={(state) => {
69
+ if (state.loading) return <Spinner />;
70
+ if (!state.prefs) return <ErrorState />;
71
+ return (
72
+ <YourCard>
73
+ {CHANNELS.map((ch) => (
74
+ <YourSwitch
75
+ key={ch}
76
+ label={ch}
77
+ checked={state.prefs!.channelPreferences[ch] ?? true}
78
+ onChange={(v) => state.setChannel(ch, v)}
79
+ />
80
+ ))}
81
+ <YourDangerSwitch
82
+ label="Unsubscribe from everything"
83
+ checked={state.prefs.globalOptOut}
84
+ onChange={state.setGlobalOptOut}
85
+ />
86
+ </YourCard>
87
+ );
88
+ }}
89
+ />
90
+ );
91
+ }
92
+ ```
93
+
94
+ Or use the `useApexPreferences` hook directly if you'd rather not nest your UI under a render prop.
95
+
96
+ ## API
97
+
98
+ ### `<ApexPreferenceCenter>` props
99
+
100
+ | Prop | Type | Required | Description |
101
+ |---|---|---|---|
102
+ | `endUserAuth` | `{ token: string }` or `{ sessionCookie: true }` | yes | Auth credentials. Use `{ token }` for any external host. |
103
+ | `apiBaseUrl` | `string` | no | Defaults to `https://app.apex.inc`. Override for self-hosted Apex. |
104
+ | `workspaceKey` | `string` | no | Workspace ID. Read from token claims when using `{ token }` auth. |
105
+ | `endUserId` | `string` | no | End-user ID. Read from token claims when using `{ token }` auth. |
106
+ | `theme` | `ApexThemeTokens` | no | Colour and font overrides for the styled default. |
107
+ | `render` | `(state) => JSX` | no | Headless override. When provided, the styled default is skipped. |
108
+
109
+ ### `useApexPreferences(options)`
110
+
111
+ Returns `{ prefs, loading, saving, saved, error, refresh, setGlobalOptOut, setChannel, setCommunicationOverride }`. Same options as the component minus `theme` and `render`.
112
+
113
+ ## What ships next
114
+
115
+ The roadmap for v0.2 is gated on at least one production design partner asking for it. The most likely additions are:
116
+
117
+ - `<ApexInbox>` — drop-in bell + dropdown that mirrors the dashboard's `NotificationBell`.
118
+ - `useApexWebPushSubscription` — headless hook around `navigator.serviceWorker.register('/apex-push-sw.js')` + the subscribe endpoint.
119
+ - An Apex-provided service-worker template you can copy into your `public/` folder.
120
+
121
+ Until then, keep using Apex's hosted preferences page at `https://app.apex.inc/preferences/<token>` for the customer-facing inbox + web push surfaces.
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `<ApexPreferenceCenter>` — drop-in component customers embed on
3
+ * their settings page so end-users can manage their Apex
4
+ * communication preferences.
5
+ *
6
+ * Two render modes share the same data layer:
7
+ *
8
+ * 1. Styled default (`<ApexPreferenceCenter />`). Inline styles
9
+ * derived from `theme` props. Zero CSS-in-JS dependency, zero
10
+ * CSS import, zero design-system mismatch risk on first paint —
11
+ * "just drop the tag and ship."
12
+ * 2. Headless override. Pass a `render` prop or render the
13
+ * `<ApexPreferenceCenter.Headless>` variant directly and use
14
+ * `useApexPreferences` to bring your own UI. The hook does all
15
+ * the fetching + mutation; your render function returns
16
+ * whatever JSX matches your design system.
17
+ *
18
+ * The data layer is `useApexPreferences` — see that file for the
19
+ * exact contract. Both modes share it so the behaviour and error
20
+ * handling stay identical.
21
+ */
22
+ import { type UseApexPreferencesOptions, type UseApexPreferencesResult } from "./useApexPreferences";
23
+ import type { ApexThemeTokens } from "./types";
24
+ export interface ApexPreferenceCenterProps extends UseApexPreferencesOptions {
25
+ /** Optional theme tokens for the styled default. Ignored for the headless variant. */
26
+ theme?: ApexThemeTokens;
27
+ /**
28
+ * Render override. When provided, the styled default is bypassed
29
+ * and the consumer renders the entire surface using the supplied
30
+ * hook state. Use this when your app has its own design system.
31
+ */
32
+ render?: (state: UseApexPreferencesResult) => React.ReactElement;
33
+ }
34
+ export declare function ApexPreferenceCenter(props: ApexPreferenceCenterProps): React.ReactElement;
35
+ export declare namespace ApexPreferenceCenter {
36
+ var Headless: (props: UseApexPreferencesOptions & {
37
+ render: (state: UseApexPreferencesResult) => React.ReactElement;
38
+ }) => React.ReactElement;
39
+ }
40
+ //# sourceMappingURL=ApexPreferenceCenter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ApexPreferenceCenter.d.ts","sourceRoot":"","sources":["../src/ApexPreferenceCenter.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,EAEL,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC9B,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAe,eAAe,EAAE,MAAM,SAAS,CAAC;AAE5D,MAAM,WAAW,yBACf,SAAQ,yBAAyB;IACjC,sFAAsF;IACtF,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,KAAK,CAAC,YAAY,CAAC;CAClE;AAwBD,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,yBAAyB,GAC/B,KAAK,CAAC,YAAY,CAKpB;yBAPe,oBAAoB;0BAgB3B,yBAAyB,GAAG;QACjC,MAAM,EAAE,CAAC,KAAK,EAAE,wBAAwB,KAAK,KAAK,CAAC,YAAY,CAAC;KACjE,KACA,KAAK,CAAC,YAAY"}
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ApexPreferenceCenter = ApexPreferenceCenter;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /**
6
+ * `<ApexPreferenceCenter>` — drop-in component customers embed on
7
+ * their settings page so end-users can manage their Apex
8
+ * communication preferences.
9
+ *
10
+ * Two render modes share the same data layer:
11
+ *
12
+ * 1. Styled default (`<ApexPreferenceCenter />`). Inline styles
13
+ * derived from `theme` props. Zero CSS-in-JS dependency, zero
14
+ * CSS import, zero design-system mismatch risk on first paint —
15
+ * "just drop the tag and ship."
16
+ * 2. Headless override. Pass a `render` prop or render the
17
+ * `<ApexPreferenceCenter.Headless>` variant directly and use
18
+ * `useApexPreferences` to bring your own UI. The hook does all
19
+ * the fetching + mutation; your render function returns
20
+ * whatever JSX matches your design system.
21
+ *
22
+ * The data layer is `useApexPreferences` — see that file for the
23
+ * exact contract. Both modes share it so the behaviour and error
24
+ * handling stay identical.
25
+ */
26
+ const react_1 = require("react");
27
+ const useApexPreferences_1 = require("./useApexPreferences");
28
+ const CHANNEL_META = {
29
+ email: {
30
+ label: "Email",
31
+ description: "Marketing emails and product updates",
32
+ },
33
+ inbox: {
34
+ label: "Inbox",
35
+ description: "In-app messages that stay until you read them",
36
+ },
37
+ web_push: {
38
+ label: "Web push",
39
+ description: "Browser notifications on your laptop or desktop",
40
+ },
41
+ mobile_push: {
42
+ label: "Mobile push",
43
+ description: "Push notifications on your phone",
44
+ },
45
+ };
46
+ function ApexPreferenceCenter(props) {
47
+ const { theme, render, ...hookOptions } = props;
48
+ const state = (0, useApexPreferences_1.useApexPreferences)(hookOptions);
49
+ if (render)
50
+ return render(state);
51
+ return (0, jsx_runtime_1.jsx)(StyledPreferenceCenter, { state: state, theme: theme });
52
+ }
53
+ /**
54
+ * Headless variant — same component, but `render` is required. Use
55
+ * when your editor's auto-imports surface "the right way" to use the
56
+ * headless variant. Behaviourally identical to passing `render` to
57
+ * the default export.
58
+ */
59
+ ApexPreferenceCenter.Headless = function Headless(props) {
60
+ const { render, ...hookOptions } = props;
61
+ const state = (0, useApexPreferences_1.useApexPreferences)(hookOptions);
62
+ return render(state);
63
+ };
64
+ // ─── Styled default ────────────────────────────────────────────────────────
65
+ function StyledPreferenceCenter({ state, theme, }) {
66
+ const tokens = (0, react_1.useMemo)(() => ({
67
+ accentColor: theme?.accentColor ?? "#00BE7D",
68
+ backgroundColor: theme?.backgroundColor ?? "#ffffff",
69
+ textColor: theme?.textColor ?? "#1a1a2e",
70
+ borderRadius: theme?.borderRadius ?? 8,
71
+ fontFamily: theme?.fontFamily ?? "system-ui, -apple-system, sans-serif",
72
+ }), [theme]);
73
+ const containerStyle = {
74
+ fontFamily: tokens.fontFamily,
75
+ color: tokens.textColor,
76
+ backgroundColor: tokens.backgroundColor,
77
+ borderRadius: tokens.borderRadius,
78
+ maxWidth: 480,
79
+ margin: "0 auto",
80
+ };
81
+ if (state.loading) {
82
+ return ((0, jsx_runtime_1.jsx)("div", { style: { ...containerStyle, padding: 32, textAlign: "center" }, children: (0, jsx_runtime_1.jsx)("span", { style: { fontSize: 14, opacity: 0.6 }, children: "Loading preferences\u2026" }) }));
83
+ }
84
+ if (state.error && !state.prefs) {
85
+ return ((0, jsx_runtime_1.jsxs)("div", { style: { ...containerStyle, padding: 32, textAlign: "center" }, children: [(0, jsx_runtime_1.jsx)("p", { style: { fontSize: 14, color: "#EF4444", marginBottom: 8 }, children: "Couldn't load your preferences" }), (0, jsx_runtime_1.jsx)("p", { style: { fontSize: 12, opacity: 0.6 }, children: state.error })] }));
86
+ }
87
+ if (!state.prefs)
88
+ return (0, jsx_runtime_1.jsx)("div", { style: containerStyle });
89
+ const allOff = state.prefs.globalOptOut;
90
+ return ((0, jsx_runtime_1.jsxs)("div", { style: containerStyle, children: [(0, jsx_runtime_1.jsxs)("div", { style: { padding: "24px 0", borderBottom: "1px solid #e5e5e5" }, children: [(0, jsx_runtime_1.jsx)("h2", { style: { fontSize: 18, fontWeight: 600, margin: 0 }, children: "Notification preferences" }), (0, jsx_runtime_1.jsx)("p", { style: { fontSize: 13, opacity: 0.6, margin: "4px 0 0" }, children: "Choose how you want to hear from us. You can change this at any time." })] }), (0, jsx_runtime_1.jsxs)("section", { style: { padding: "20px 0" }, children: [(0, jsx_runtime_1.jsx)("h3", { style: {
91
+ fontSize: 13,
92
+ fontWeight: 600,
93
+ textTransform: "uppercase",
94
+ letterSpacing: 0.5,
95
+ opacity: 0.5,
96
+ margin: "0 0 12px",
97
+ }, children: "Channels" }), Object.keys(CHANNEL_META).map((ch) => {
98
+ const meta = CHANNEL_META[ch];
99
+ // Legacy `in_app_push` alias coverage — see useApexPreferences.
100
+ const stored = ch === "inbox"
101
+ ? state.prefs?.channelPreferences.inbox ??
102
+ state.prefs?.channelPreferences.in_app_push ??
103
+ true
104
+ : state.prefs?.channelPreferences[ch] ?? true;
105
+ return ((0, jsx_runtime_1.jsxs)("label", { style: {
106
+ display: "flex",
107
+ alignItems: "flex-start",
108
+ gap: 12,
109
+ padding: "12px 0",
110
+ borderBottom: "1px solid #f0f0f0",
111
+ cursor: allOff ? "not-allowed" : "pointer",
112
+ opacity: allOff ? 0.4 : 1,
113
+ }, children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: !allOff && stored, onChange: (e) => void state.setChannel(ch, e.currentTarget.checked), disabled: allOff, style: {
114
+ marginTop: 2,
115
+ accentColor: tokens.accentColor,
116
+ width: 16,
117
+ height: 16,
118
+ } }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 14, fontWeight: 500 }, children: meta.label }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: 12, opacity: 0.5, marginTop: 2 }, children: meta.description })] })] }, ch));
119
+ })] }), (0, jsx_runtime_1.jsx)("section", { style: {
120
+ padding: "20px 0",
121
+ borderTop: "1px solid #e5e5e5",
122
+ }, children: (0, jsx_runtime_1.jsxs)("label", { style: {
123
+ display: "flex",
124
+ alignItems: "center",
125
+ gap: 12,
126
+ cursor: "pointer",
127
+ }, children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: allOff, onChange: (e) => void state.setGlobalOptOut(e.currentTarget.checked), style: { accentColor: "#EF4444", width: 16, height: 16 } }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: 14, fontWeight: 500, color: "#EF4444" }, children: "Unsubscribe from all" }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: 12, opacity: 0.5, marginTop: 2 }, children: "Stop all communications. You can re-enable any time." })] })] }) }), (0, jsx_runtime_1.jsxs)("div", { style: { padding: "12px 0", textAlign: "center", minHeight: 24 }, children: [state.saving && ((0, jsx_runtime_1.jsx)("span", { style: { fontSize: 12, opacity: 0.5 }, children: "Saving\u2026" })), state.saved && ((0, jsx_runtime_1.jsx)("span", { style: { fontSize: 12, color: tokens.accentColor }, children: "\u2713 Preferences saved" })), state.error && state.prefs && ((0, jsx_runtime_1.jsx)("span", { style: { fontSize: 12, color: "#EF4444" }, children: state.error }))] })] }));
128
+ }
129
+ //# sourceMappingURL=ApexPreferenceCenter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ApexPreferenceCenter.js","sourceRoot":"","sources":["../src/ApexPreferenceCenter.tsx"],"names":[],"mappings":";;AAiEA,oDAOC;;AAxED;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,iCAAgC;AAEhC,6DAI8B;AAe9B,MAAM,YAAY,GAGd;IACF,KAAK,EAAE;QACL,KAAK,EAAE,OAAO;QACd,WAAW,EAAE,sCAAsC;KACpD;IACD,KAAK,EAAE;QACL,KAAK,EAAE,OAAO;QACd,WAAW,EAAE,+CAA+C;KAC7D;IACD,QAAQ,EAAE;QACR,KAAK,EAAE,UAAU;QACjB,WAAW,EAAE,iDAAiD;KAC/D;IACD,WAAW,EAAE;QACX,KAAK,EAAE,aAAa;QACpB,WAAW,EAAE,kCAAkC;KAChD;CACF,CAAC;AAEF,SAAgB,oBAAoB,CAClC,KAAgC;IAEhC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,WAAW,EAAE,GAAG,KAAK,CAAC;IAChD,MAAM,KAAK,GAAG,IAAA,uCAAkB,EAAC,WAAW,CAAC,CAAC;IAC9C,IAAI,MAAM;QAAE,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;IACjC,OAAO,uBAAC,sBAAsB,IAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,GAAI,CAAC;AAChE,CAAC;AAED;;;;;GAKG;AACH,oBAAoB,CAAC,QAAQ,GAAG,SAAS,QAAQ,CAC/C,KAEC;IAED,MAAM,EAAE,MAAM,EAAE,GAAG,WAAW,EAAE,GAAG,KAAK,CAAC;IACzC,MAAM,KAAK,GAAG,IAAA,uCAAkB,EAAC,WAAW,CAAC,CAAC;IAC9C,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;AACvB,CAAC,CAAC;AAEF,8EAA8E;AAE9E,SAAS,sBAAsB,CAAC,EAC9B,KAAK,EACL,KAAK,GAIN;IACC,MAAM,MAAM,GAAG,IAAA,eAAO,EACpB,GAAG,EAAE,CAAC,CAAC;QACL,WAAW,EAAE,KAAK,EAAE,WAAW,IAAI,SAAS;QAC5C,eAAe,EAAE,KAAK,EAAE,eAAe,IAAI,SAAS;QACpD,SAAS,EAAE,KAAK,EAAE,SAAS,IAAI,SAAS;QACxC,YAAY,EAAE,KAAK,EAAE,YAAY,IAAI,CAAC;QACtC,UAAU,EACR,KAAK,EAAE,UAAU,IAAI,sCAAsC;KAC9D,CAAC,EACF,CAAC,KAAK,CAAC,CACR,CAAC;IAEF,MAAM,cAAc,GAAwB;QAC1C,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,KAAK,EAAE,MAAM,CAAC,SAAS;QACvB,eAAe,EAAE,MAAM,CAAC,eAAe;QACvC,YAAY,EAAE,MAAM,CAAC,YAAY;QACjC,QAAQ,EAAE,GAAG;QACb,MAAM,EAAE,QAAQ;KACjB,CAAC;IAEF,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;QAClB,OAAO,CACL,gCAAK,KAAK,EAAE,EAAE,GAAG,cAAc,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,YACjE,iCAAM,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,0CAEpC,GACH,CACP,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,KAAK,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;QAChC,OAAO,CACL,iCAAK,KAAK,EAAE,EAAE,GAAG,cAAc,EAAE,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,aACjE,8BAAG,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC,EAAE,+CAEzD,EACJ,8BAAG,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,YAAG,KAAK,CAAC,KAAK,GAAK,IACvD,CACP,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,KAAK;QAAE,OAAO,gCAAK,KAAK,EAAE,cAAc,GAAI,CAAC;IAExD,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,YAAY,CAAC;IAExC,OAAO,CACL,iCAAK,KAAK,EAAE,cAAc,aACxB,iCAAK,KAAK,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,mBAAmB,EAAE,aAClE,+BAAI,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,EAAE,CAAC,EAAE,yCAElD,EACL,8BAAG,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,sFAEvD,IACA,EAEN,qCAAS,KAAK,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,aACnC,+BACE,KAAK,EAAE;4BACL,QAAQ,EAAE,EAAE;4BACZ,UAAU,EAAE,GAAG;4BACf,aAAa,EAAE,WAAW;4BAC1B,aAAa,EAAE,GAAG;4BAClB,OAAO,EAAE,GAAG;4BACZ,MAAM,EAAE,UAAU;yBACnB,yBAGE,EACH,MAAM,CAAC,IAAI,CAAC,YAAY,CAAmB,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE;wBACvD,MAAM,IAAI,GAAG,YAAY,CAAC,EAAE,CAAC,CAAC;wBAC9B,gEAAgE;wBAChE,MAAM,MAAM,GACV,EAAE,KAAK,OAAO;4BACZ,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,kBAAkB,CAAC,KAAK;gCACrC,KAAK,CAAC,KAAK,EAAE,kBAAkB,CAAC,WAAW;gCAC3C,IAAI;4BACN,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,kBAAkB,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC;wBAClD,OAAO,CACL,mCAEE,KAAK,EAAE;gCACL,OAAO,EAAE,MAAM;gCACf,UAAU,EAAE,YAAY;gCACxB,GAAG,EAAE,EAAE;gCACP,OAAO,EAAE,QAAQ;gCACjB,YAAY,EAAE,mBAAmB;gCACjC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS;gCAC1C,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;6BAC1B,aAED,kCACE,IAAI,EAAC,UAAU,EACf,OAAO,EAAE,CAAC,MAAM,IAAI,MAAM,EAC1B,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CACd,KAAK,KAAK,CAAC,UAAU,CAAC,EAAE,EAAE,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,EAEpD,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE;wCACL,SAAS,EAAE,CAAC;wCACZ,WAAW,EAAE,MAAM,CAAC,WAAW;wCAC/B,KAAK,EAAE,EAAE;wCACT,MAAM,EAAE,EAAE;qCACX,GACD,EACF,4CACE,gCAAK,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,YAC1C,IAAI,CAAC,KAAK,GACP,EACN,gCAAK,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,EAAE,YACrD,IAAI,CAAC,WAAW,GACb,IACF,KAhCD,EAAE,CAiCD,CACT,CAAC;oBACJ,CAAC,CAAC,IACM,EAEV,oCACE,KAAK,EAAE;oBACL,OAAO,EAAE,QAAQ;oBACjB,SAAS,EAAE,mBAAmB;iBAC/B,YAED,mCACE,KAAK,EAAE;wBACL,OAAO,EAAE,MAAM;wBACf,UAAU,EAAE,QAAQ;wBACpB,GAAG,EAAE,EAAE;wBACP,MAAM,EAAE,SAAS;qBAClB,aAED,kCACE,IAAI,EAAC,UAAU,EACf,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CACd,KAAK,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,EAErD,KAAK,EAAE,EAAE,WAAW,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,GACxD,EACF,4CACE,gCACE,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,SAAS,EAAE,qCAGtD,EACN,gCAAK,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,CAAC,EAAE,qEAElD,IACF,IACA,GACA,EAEV,iCAAK,KAAK,EAAE,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE,EAAE,aAClE,KAAK,CAAC,MAAM,IAAI,CACf,iCAAM,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,OAAO,EAAE,GAAG,EAAE,6BAAgB,CAC5D,EACA,KAAK,CAAC,KAAK,IAAI,CACd,iCAAM,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,WAAW,EAAE,yCAEjD,CACR,EACA,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,KAAK,IAAI,CAC7B,iCAAM,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,YAC5C,KAAK,CAAC,KAAK,GACP,CACR,IACG,IACF,CACP,CAAC;AACJ,CAAC"}
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * `<ApexPreferenceCenter>` — drop-in component customers embed on
4
+ * their settings page so end-users can manage their Apex
5
+ * communication preferences.
6
+ *
7
+ * Two render modes share the same data layer:
8
+ *
9
+ * 1. Styled default (`<ApexPreferenceCenter />`). Inline styles
10
+ * derived from `theme` props. Zero CSS-in-JS dependency, zero
11
+ * CSS import, zero design-system mismatch risk on first paint —
12
+ * "just drop the tag and ship."
13
+ * 2. Headless override. Pass a `render` prop or render the
14
+ * `<ApexPreferenceCenter.Headless>` variant directly and use
15
+ * `useApexPreferences` to bring your own UI. The hook does all
16
+ * the fetching + mutation; your render function returns
17
+ * whatever JSX matches your design system.
18
+ *
19
+ * The data layer is `useApexPreferences` — see that file for the
20
+ * exact contract. Both modes share it so the behaviour and error
21
+ * handling stay identical.
22
+ */
23
+ import { useMemo } from "react";
24
+ import { useApexPreferences, } from "./useApexPreferences";
25
+ const CHANNEL_META = {
26
+ email: {
27
+ label: "Email",
28
+ description: "Marketing emails and product updates",
29
+ },
30
+ inbox: {
31
+ label: "Inbox",
32
+ description: "In-app messages that stay until you read them",
33
+ },
34
+ web_push: {
35
+ label: "Web push",
36
+ description: "Browser notifications on your laptop or desktop",
37
+ },
38
+ mobile_push: {
39
+ label: "Mobile push",
40
+ description: "Push notifications on your phone",
41
+ },
42
+ };
43
+ export function ApexPreferenceCenter(props) {
44
+ const { theme, render, ...hookOptions } = props;
45
+ const state = useApexPreferences(hookOptions);
46
+ if (render)
47
+ return render(state);
48
+ return _jsx(StyledPreferenceCenter, { state: state, theme: theme });
49
+ }
50
+ /**
51
+ * Headless variant — same component, but `render` is required. Use
52
+ * when your editor's auto-imports surface "the right way" to use the
53
+ * headless variant. Behaviourally identical to passing `render` to
54
+ * the default export.
55
+ */
56
+ ApexPreferenceCenter.Headless = function Headless(props) {
57
+ const { render, ...hookOptions } = props;
58
+ const state = useApexPreferences(hookOptions);
59
+ return render(state);
60
+ };
61
+ // ─── Styled default ────────────────────────────────────────────────────────
62
+ function StyledPreferenceCenter({ state, theme, }) {
63
+ const tokens = useMemo(() => ({
64
+ accentColor: theme?.accentColor ?? "#00BE7D",
65
+ backgroundColor: theme?.backgroundColor ?? "#ffffff",
66
+ textColor: theme?.textColor ?? "#1a1a2e",
67
+ borderRadius: theme?.borderRadius ?? 8,
68
+ fontFamily: theme?.fontFamily ?? "system-ui, -apple-system, sans-serif",
69
+ }), [theme]);
70
+ const containerStyle = {
71
+ fontFamily: tokens.fontFamily,
72
+ color: tokens.textColor,
73
+ backgroundColor: tokens.backgroundColor,
74
+ borderRadius: tokens.borderRadius,
75
+ maxWidth: 480,
76
+ margin: "0 auto",
77
+ };
78
+ if (state.loading) {
79
+ return (_jsx("div", { style: { ...containerStyle, padding: 32, textAlign: "center" }, children: _jsx("span", { style: { fontSize: 14, opacity: 0.6 }, children: "Loading preferences\u2026" }) }));
80
+ }
81
+ if (state.error && !state.prefs) {
82
+ return (_jsxs("div", { style: { ...containerStyle, padding: 32, textAlign: "center" }, children: [_jsx("p", { style: { fontSize: 14, color: "#EF4444", marginBottom: 8 }, children: "Couldn't load your preferences" }), _jsx("p", { style: { fontSize: 12, opacity: 0.6 }, children: state.error })] }));
83
+ }
84
+ if (!state.prefs)
85
+ return _jsx("div", { style: containerStyle });
86
+ const allOff = state.prefs.globalOptOut;
87
+ return (_jsxs("div", { style: containerStyle, children: [_jsxs("div", { style: { padding: "24px 0", borderBottom: "1px solid #e5e5e5" }, children: [_jsx("h2", { style: { fontSize: 18, fontWeight: 600, margin: 0 }, children: "Notification preferences" }), _jsx("p", { style: { fontSize: 13, opacity: 0.6, margin: "4px 0 0" }, children: "Choose how you want to hear from us. You can change this at any time." })] }), _jsxs("section", { style: { padding: "20px 0" }, children: [_jsx("h3", { style: {
88
+ fontSize: 13,
89
+ fontWeight: 600,
90
+ textTransform: "uppercase",
91
+ letterSpacing: 0.5,
92
+ opacity: 0.5,
93
+ margin: "0 0 12px",
94
+ }, children: "Channels" }), Object.keys(CHANNEL_META).map((ch) => {
95
+ const meta = CHANNEL_META[ch];
96
+ // Legacy `in_app_push` alias coverage — see useApexPreferences.
97
+ const stored = ch === "inbox"
98
+ ? state.prefs?.channelPreferences.inbox ??
99
+ state.prefs?.channelPreferences.in_app_push ??
100
+ true
101
+ : state.prefs?.channelPreferences[ch] ?? true;
102
+ return (_jsxs("label", { style: {
103
+ display: "flex",
104
+ alignItems: "flex-start",
105
+ gap: 12,
106
+ padding: "12px 0",
107
+ borderBottom: "1px solid #f0f0f0",
108
+ cursor: allOff ? "not-allowed" : "pointer",
109
+ opacity: allOff ? 0.4 : 1,
110
+ }, children: [_jsx("input", { type: "checkbox", checked: !allOff && stored, onChange: (e) => void state.setChannel(ch, e.currentTarget.checked), disabled: allOff, style: {
111
+ marginTop: 2,
112
+ accentColor: tokens.accentColor,
113
+ width: 16,
114
+ height: 16,
115
+ } }), _jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, fontWeight: 500 }, children: meta.label }), _jsx("div", { style: { fontSize: 12, opacity: 0.5, marginTop: 2 }, children: meta.description })] })] }, ch));
116
+ })] }), _jsx("section", { style: {
117
+ padding: "20px 0",
118
+ borderTop: "1px solid #e5e5e5",
119
+ }, children: _jsxs("label", { style: {
120
+ display: "flex",
121
+ alignItems: "center",
122
+ gap: 12,
123
+ cursor: "pointer",
124
+ }, children: [_jsx("input", { type: "checkbox", checked: allOff, onChange: (e) => void state.setGlobalOptOut(e.currentTarget.checked), style: { accentColor: "#EF4444", width: 16, height: 16 } }), _jsxs("div", { children: [_jsx("div", { style: { fontSize: 14, fontWeight: 500, color: "#EF4444" }, children: "Unsubscribe from all" }), _jsx("div", { style: { fontSize: 12, opacity: 0.5, marginTop: 2 }, children: "Stop all communications. You can re-enable any time." })] })] }) }), _jsxs("div", { style: { padding: "12px 0", textAlign: "center", minHeight: 24 }, children: [state.saving && (_jsx("span", { style: { fontSize: 12, opacity: 0.5 }, children: "Saving\u2026" })), state.saved && (_jsx("span", { style: { fontSize: 12, color: tokens.accentColor }, children: "\u2713 Preferences saved" })), state.error && state.prefs && (_jsx("span", { style: { fontSize: 12, color: "#EF4444" }, children: state.error }))] })] }));
125
+ }
@@ -0,0 +1,200 @@
1
+ "use client";
2
+ /**
3
+ * Code-level A/B experiment hooks for customer React apps.
4
+ *
5
+ * This is the publishable generalization of the app-internal
6
+ * `useApexVariant` hook. The behavior is identical to the first-party
7
+ * web hook — preview params, localStorage cache, visitor cookie,
8
+ * assignment fetch, murmur-hash fallback, and the canonical
9
+ * `experiment_exposure` event (deduped per arm) — but the API base
10
+ * and workspace are configurable so it works from a CUSTOMER's origin,
11
+ * not just same-origin first-party apps.
12
+ *
13
+ * Configure once with `<ApexProvider apiBase="https://app.apex.inc"
14
+ * workspaceKey="ws_…">`, or pass `{ apiBase, workspaceKey }` per call.
15
+ * With no config the hook defaults to same-origin (`apiBase: ""`), so
16
+ * first-party apps keep working unchanged.
17
+ */
18
+ import { createContext, createElement, useContext, useEffect, useState } from "react";
19
+ const ApexExperimentContext = createContext({});
20
+ /**
21
+ * Provides experiment resolution config (apiBase / workspaceKey) to
22
+ * every `useApexVariant` call below it. Optional — per-call overrides
23
+ * and same-origin defaults work without it.
24
+ */
25
+ export function ApexProvider(props) {
26
+ const value = {
27
+ apiBase: props.apiBase,
28
+ workspaceKey: props.workspaceKey,
29
+ };
30
+ return createElement(ApexExperimentContext.Provider, { value }, props.children);
31
+ }
32
+ function murmurhash3(key) {
33
+ let h = 0x811c9dc5;
34
+ for (let i = 0; i < key.length; i++) {
35
+ h ^= key.charCodeAt(i);
36
+ h = Math.imul(h, 0x01000193);
37
+ }
38
+ return (h >>> 0) % 100;
39
+ }
40
+ function getVisitorId() {
41
+ if (typeof window === "undefined")
42
+ return "";
43
+ const key = "apex_vid";
44
+ const match = document.cookie.match(new RegExp(`(?:^|; )${key}=([^;]*)`));
45
+ if (match)
46
+ return decodeURIComponent(match[1]);
47
+ const id = crypto.randomUUID();
48
+ const d = new Date();
49
+ d.setTime(d.getTime() + 365 * 86400000);
50
+ document.cookie = `${key}=${encodeURIComponent(id)};expires=${d.toUTCString()};path=/;SameSite=Lax`;
51
+ return id;
52
+ }
53
+ function normalizeBase(apiBase) {
54
+ return (apiBase ?? "").replace(/\/$/, "");
55
+ }
56
+ function workspaceHeaders(workspaceKey) {
57
+ return workspaceKey ? { "x-apex-workspace": workspaceKey } : {};
58
+ }
59
+ /**
60
+ * Fire-once-per-arm exposure tracking. The denominator every experiment
61
+ * surface counts against (canonical `experiment_exposure` event). Deduped
62
+ * per (experiment, variant) for the page session so re-renders don't spam.
63
+ */
64
+ const firedExposures = new Set();
65
+ function fireExposure(experimentId, variant, config) {
66
+ if (typeof window === "undefined")
67
+ return;
68
+ const key = `${experimentId}:${variant}`;
69
+ if (firedExposures.has(key))
70
+ return;
71
+ firedExposures.add(key);
72
+ const visitorId = getVisitorId();
73
+ const base = normalizeBase(config.apiBase);
74
+ try {
75
+ void fetch(`${base}/api/events`, {
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json",
79
+ ...workspaceHeaders(config.workspaceKey),
80
+ },
81
+ credentials: "include",
82
+ keepalive: true,
83
+ body: JSON.stringify({
84
+ type: "experiment_exposure",
85
+ visitorId,
86
+ url: window.location.href,
87
+ timestamp: new Date().toISOString(),
88
+ experimentId,
89
+ variant,
90
+ data: { experiment_id: experimentId, variant_key: variant, surface: "web" },
91
+ }),
92
+ }).catch(() => { });
93
+ }
94
+ catch {
95
+ /* exposure tracking is best-effort */
96
+ }
97
+ }
98
+ function getPreviewParams() {
99
+ if (typeof window === "undefined")
100
+ return null;
101
+ const params = new URLSearchParams(window.location.search);
102
+ const variant = params.get("_apex_preview");
103
+ const expId = params.get("_apex_exp");
104
+ if (variant)
105
+ return { variant, expId };
106
+ return null;
107
+ }
108
+ /**
109
+ * React hook for code-level A/B experiments.
110
+ *
111
+ * Returns "control" or "variant_b" based on:
112
+ * 1. Preview mode (?_apex_preview=variant_b&_apex_exp=<id>)
113
+ * 2. Cached assignment in localStorage
114
+ * 3. Server assignment (GET /api/experiments/{id}/assign?source=sdk)
115
+ * 4. Deterministic hash of visitorId + experimentId (fallback)
116
+ *
117
+ * Falls back to "control" on server render and on any error.
118
+ *
119
+ * Config resolution: per-call `overrides` win over `<ApexProvider>`
120
+ * context, which wins over the same-origin default (`apiBase: ""`).
121
+ */
122
+ export function useApexVariant(experimentId, overrides) {
123
+ const ctx = useContext(ApexExperimentContext);
124
+ const apiBase = overrides?.apiBase ?? ctx.apiBase ?? "";
125
+ const workspaceKey = overrides?.workspaceKey ?? ctx.workspaceKey;
126
+ const config = { apiBase, workspaceKey };
127
+ const [variant, setVariant] = useState(() => {
128
+ const preview = getPreviewParams();
129
+ if (preview && (!preview.expId || preview.expId === experimentId)) {
130
+ return preview.variant;
131
+ }
132
+ if (typeof window === "undefined")
133
+ return "control";
134
+ const cached = localStorage.getItem(`apex_var_${experimentId}`);
135
+ if (cached === "control" || cached === "variant_b")
136
+ return cached;
137
+ return "control";
138
+ });
139
+ useEffect(() => {
140
+ // Preview mode is the author previewing — not a real exposure.
141
+ const preview = getPreviewParams();
142
+ if (preview && (!preview.expId || preview.expId === experimentId))
143
+ return;
144
+ const cacheKey = `apex_var_${experimentId}`;
145
+ const cached = localStorage.getItem(cacheKey);
146
+ if (cached === "control" || cached === "variant_b") {
147
+ // Already assigned this session — still count the exposure (deduped).
148
+ fireExposure(experimentId, cached, config);
149
+ return;
150
+ }
151
+ const visitorId = getVisitorId();
152
+ if (!visitorId)
153
+ return;
154
+ const base = normalizeBase(apiBase);
155
+ fetch(`${base}/api/experiments/${experimentId}/assign?source=sdk`, {
156
+ credentials: "include",
157
+ headers: workspaceHeaders(workspaceKey),
158
+ })
159
+ .then((r) => {
160
+ if (!r.ok)
161
+ throw new Error(`${r.status}`);
162
+ return r.json();
163
+ })
164
+ .then((data) => {
165
+ localStorage.setItem(cacheKey, data.variant);
166
+ setVariant(data.variant);
167
+ fireExposure(experimentId, data.variant, config);
168
+ })
169
+ .catch(() => {
170
+ const bucket = murmurhash3(visitorId + experimentId);
171
+ const fallback = bucket < 50 ? "control" : "variant_b";
172
+ localStorage.setItem(cacheKey, fallback);
173
+ setVariant(fallback);
174
+ fireExposure(experimentId, fallback, config);
175
+ });
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, [experimentId, apiBase, workspaceKey]);
178
+ return variant;
179
+ }
180
+ /**
181
+ * Clear a cached assignment (useful when an experiment is archived/completed).
182
+ */
183
+ export function clearApexVariant(experimentId) {
184
+ if (typeof window !== "undefined") {
185
+ localStorage.removeItem(`apex_var_${experimentId}`);
186
+ }
187
+ }
188
+ /**
189
+ * Check if currently in Apex preview mode for a specific experiment.
190
+ */
191
+ export function useApexPreview(experimentId) {
192
+ const [state] = useState(() => {
193
+ const preview = getPreviewParams();
194
+ if (preview && (!experimentId || !preview.expId || preview.expId === experimentId)) {
195
+ return { isPreview: true, previewVariant: preview.variant };
196
+ }
197
+ return { isPreview: false, previewVariant: null };
198
+ });
199
+ return state;
200
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @apex-inc/react — embeddable React components for Apex
3
+ * communication surfaces.
4
+ *
5
+ * MVP scope (v0.1):
6
+ * - <ApexPreferenceCenter> — styled + headless variants.
7
+ * - useApexPreferences — headless hook for full-custom UIs.
8
+ *
9
+ * Deferred to v0.2 (gated on design-partner demand):
10
+ * - <ApexInbox> embeddable bell + drawer
11
+ * - useApexWebPushSubscription
12
+ * - Customer service-worker template
13
+ *
14
+ * Design rationale: every product on the planet needs a preferences
15
+ * UI; only some need an embedded inbox. Shipping the universal piece
16
+ * first lets us validate demand for the channel-specific pieces.
17
+ */
18
+ export { ApexPreferenceCenter } from "./ApexPreferenceCenter";
19
+ export { useApexPreferences, } from "./useApexPreferences";
20
+ // ─── Code-level A/B experiments ───────────────────────────────────────────
21
+ export { ApexProvider, useApexVariant, useApexPreview, clearApexVariant, } from "./experiments";