@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 +125 -0
- package/dist/ApexPreferenceCenter.d.ts +40 -0
- package/dist/ApexPreferenceCenter.d.ts.map +1 -0
- package/dist/ApexPreferenceCenter.js +129 -0
- package/dist/ApexPreferenceCenter.js.map +1 -0
- package/dist/esm/ApexPreferenceCenter.js +125 -0
- package/dist/esm/experiments.js +200 -0
- package/dist/esm/index.js +21 -0
- package/dist/esm/types.js +8 -0
- package/dist/esm/useApexPreferences.js +189 -0
- package/dist/experiments.d.ts +50 -0
- package/dist/experiments.d.ts.map +1 -0
- package/dist/experiments.js +207 -0
- package/dist/experiments.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/dist/useApexPreferences.d.ts +48 -0
- package/dist/useApexPreferences.d.ts.map +1 -0
- package/dist/useApexPreferences.js +193 -0
- package/dist/useApexPreferences.js.map +1 -0
- package/package.json +43 -0
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";
|