@mrrocket/sdk 0.4.0 → 0.8.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/CHANGELOG.md +30 -0
- package/README.md +102 -7
- package/dist/api.d.ts +23 -3
- package/dist/api.js +7 -3
- package/dist/api.js.map +1 -1
- package/dist/browser/mrrocket-flows.js +64 -0
- package/dist/browser/mrrocket-push.js +1 -0
- package/dist/browser/mrrocket-replay.js +3 -3
- package/dist/browser/mrrocket-sw.js +1 -0
- package/dist/browser/mrrocket.js +2 -1
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +3 -1
- package/dist/browser.js.map +1 -1
- package/dist/client.d.ts +36 -1
- package/dist/client.js +161 -6
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +10 -0
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +64 -3
- package/dist/core.js +54 -2
- package/dist/core.js.map +1 -1
- package/dist/feedback.d.ts +19 -4
- package/dist/feedback.js +652 -190
- package/dist/feedback.js.map +1 -1
- package/dist/flows-ui.d.ts +23 -0
- package/dist/flows-ui.js +384 -0
- package/dist/flows-ui.js.map +1 -0
- package/dist/flows-web.d.ts +19 -0
- package/dist/flows-web.js +42 -0
- package/dist/flows-web.js.map +1 -0
- package/dist/flows.d.ts +222 -0
- package/dist/flows.js +409 -0
- package/dist/flows.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +34 -1
- package/dist/proxy.js +14 -1
- package/dist/proxy.js.map +1 -1
- package/dist/push-web.d.ts +43 -0
- package/dist/push-web.js +81 -0
- package/dist/push-web.js.map +1 -0
- package/dist/react-native/index.d.cts +305 -3
- package/dist/react-native/index.d.ts +305 -3
- package/dist/react-native/index.js +852 -31
- package/dist/react-native/index.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.js +127 -24
- package/dist/styles.js.map +1 -1
- package/dist/ui.d.ts +3 -0
- package/dist/ui.js +12 -0
- package/dist/ui.js.map +1 -1
- package/package.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package. It follows semantic versioning; before 1.0 a minor version may change an API.
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
- **Texts in React Native:** `useText(id, fallback)` and `client.getText(id, fallback)` / `client.peekText(id)` / `client.exposeText(id)` return the copy published in MRRocket for `id` with the person's variant of a running text test on top, the same rules as `<Text id>` on the web (`textChanges`). Paywall copy (`paywall.<placement>.headline`, `.body`, `.cta`) and text A/B tests now work in apps; showing a text records `$exposure` for every test that changes it, once per screen. Works from the cached config on a cold start.
|
|
8
|
+
- Version reported in event batches: `0.8.0`.
|
|
9
|
+
|
|
10
|
+
## 0.7.0
|
|
11
|
+
|
|
12
|
+
- **Cancel flow:** `useCancelFlow().open({ customer, price, onCancel, onSave, onClose })` (Next.js and React Native), `mrrocket.cancelFlow.open(…)` (script tag) show the flow built in MRRocket › Churn › Cancel flow from the config's new `cancelFlow`: business customers get the survey and the offer for their answer (discount, pause, downgrade, talk to us), anyone else the plain confirmation (§ 312k BGB); without a live flow the plain confirmation, so the Cancel button always works. Resolves with `{ outcome: "cancelled" | "saved" | "kept", reason, offer, comment, run }`. MRRocket never changes a subscription: your `onCancel` / `onSave` do, and only after they succeed is the outcome recorded (`$cancel_flow`); MRRocket shows it under Churn as a cancellation or a save.
|
|
13
|
+
- **Onboarding:** the tour, checklist and modal steps of live onboarding flows (config `onboarding`) start on their own when the signed-in person (or a visitor with `consent="cookies"`) does the trigger (`track("Signed up")`, the first page for "First launch"), once per person; `startOnboarding()` starts one by hand, `onboarding={false}` switches the automatic start off. Tour steps point at `data-ck-target` elements on the web; checklist items tick themselves on their event. Progress comes back from MRRocket for signed-in people (`GET /api/sdk/inapp`) and records as `$onboarding`.
|
|
14
|
+
- **In-app messages:** journey steps of the kind "In-app message" show to the signed-in person as a banner or a dialog (`$inapp` shown, clicked, dismissed); the journey moves them on.
|
|
15
|
+
- **Look:** `appearance={{ theme, accent, className }}`, CSS variables `--ckf-*` on `.ckf-root`; script tag `data-theme`, `data-accent`. The web dialogs load lazily (a dynamic import; `sdk-flows.js` for the script tag).
|
|
16
|
+
- React Native: `MRRocketProvider` draws them (`flows={false}` to opt out), new `useCancelFlow`, `useOnboarding`, `MRRocketFlows`, `client.openCancelFlow`, `client.startOnboarding`, `client.flows`.
|
|
17
|
+
- **Push notifications** for Lifecycle journeys' push steps. React Native and Expo: `usePush().register()` / `client.registerForPush({ ask, projectId })` after `identify` asks the permission, gets the Expo push token (`expo-notifications`, new optional peer dependency with `expo-constants`) and sends it to `POST /api/sdk/push`; `reset()` takes the token back; opens of MRRocket notifications record `$push {action: "opened"}` and open the step's link when it has a scheme. Web: `usePush().subscribe()` (Next.js), `mrrocket.push.subscribe()` (script tag) asks the permission from the click, registers the service worker the site hosts (`/mrrocket-sw.js`, built as `dist/browser/mrrocket-sw.js`; `serviceWorker: false` for sites that import it into their own) and sends the subscription made with the workspace's VAPID key (`GET /api/sdk/push`); `unsubscribe()` on sign-out. The sign-up code loads lazily (a dynamic import; `sdk-push.js` for the script tag).
|
|
18
|
+
- **Redirects:** the config's `redirects` (page redirects) and redirect tests (an experiment with `path`, variants with `url`) send the visitor on before the page shows, keeping the query and hash; a running test adds `ck_x=<test>:<variant>`, and the page it lands on records the exposure and drops the parameter. New `redirectFor`, `exposureFrom`, `EXPOSURE_PARAM`; `experimentRedirect(request, config, { distinctId })` and a re-exported `getCockpitConfig` in `@mrrocket/sdk/next/proxy` redirect in `proxy.ts` before the page renders. The script tag removes the `ck-hide` class from `<html>` once it decided (the optional anti-flicker snippet).
|
|
19
|
+
- Version reported in event batches: `0.7.0`.
|
|
20
|
+
|
|
21
|
+
## 0.6.0
|
|
22
|
+
|
|
23
|
+
- **Feedback board after Featurebase:** `<FeedbackBoard>` gets a top bar with your product's logo and name (from MRRocket), Feedback · Roadmap · Changelog, search, a light/dark toggle and "Submit feedback"; post cards with type chip, excerpt, author and votes; the Roadmap has four columns, Backlog (under consideration), Next up, In progress and Done; a post page with comments, upvote and status; Changelog with dates beside the entries; the submit form and dialogs rise as a sheet on phones, the roadmap scrolls column by column. New props `header` (default true; `false` keeps the old plain heading), `syncUrl` (`?view=` and `?post=` in the address, so a post can be linked), `fullPage` and `initialTab`.
|
|
24
|
+
- **Who posts:** a visitor gives a name and email once before posting, voting or commenting; the board keeps them for this site in localStorage (`ckw-board-who:<key>`) so their votes stay theirs. A visitor your app called `identify()` for with a name or email is not asked. Others see "First L.". The floating widget asks the same way.
|
|
25
|
+
- API: posts carry `author`; the list carries `product` ({ name, logo }); the roadmap carries a `review` column; `createApi(...).post(id)` reads one post. Older servers still work (the new fields are optional).
|
|
26
|
+
- Fix: primary buttons, the floating button and tabs keep their own colors on pages whose CSS styles every `button` (the widget's reset no longer outranks its own classes).
|
|
27
|
+
|
|
28
|
+
## 0.5.0
|
|
29
|
+
|
|
30
|
+
- **Published texts:** the config carries `texts`, the copy published in MRRocket for texts the site marks: a landing page's headline, subheadline and button (`<Text id="pricing.headline">`, `data-ck-text="pricing.headline"`; `/` is `home`, `/features/ai` is `features-ai`) and a paywall's (`paywall.<placement>.headline`, `.body`, `.cta`). `textChanges` (and with it `<Text>`, `getTexts` and the script tag) applies them first and a running test's variant on top; without them the page keeps its own words. Older versions ignore the field.
|
|
31
|
+
- **Experiments are served:** MRRocket's config now lists the product's running tests with their variants (`control`, `a`, `b` …) and splits, and a shipped winner for everyone; nothing changes in how a visitor is assigned (a stable hash of test and visitor id).
|
|
32
|
+
- `<Text id>` records the exposure of every running test that changes that text, as the script tag already did, so a test that only swaps texts is counted without `useVariant`. New export `experimentsOfText`.
|
|
33
|
+
- Version reported in event batches: `0.5.0`.
|
|
34
|
+
|
|
5
35
|
## 0.4.0
|
|
6
36
|
|
|
7
37
|
- **React Native and Expo:** new entry `@mrrocket/sdk/react-native` (one CommonJS file for Metro, no DOM, no `"use client"`, no rrweb). `MRRocketProvider`, `useMRRocket`, `useFlag`, `useFlagVariant`, `useVariant`, `createMRRocket` / `getMRRocket` outside React; `track`, `identify`, `reset`, `screen`, `setConsent`, `captureError`, `flush`. Screen views through `useExpoRouterTracking()` (Expo Router) or `useNavigationTracking()` (React Navigation `onStateChange`). Lifecycle events `$app_open` (with `cold`) and `$app_background`; a new session after 30 minutes in the background. Events are batched, sent every 10 s and when the app goes to the background, and kept on the device while offline (AsyncStorage when installed, else memory; at most 1,000, oldest dropped; retries with backoff). Consent `"anonymous"` (default: no id stored), `"persistent"` (a random install id stored on the device) or `"off"` (nothing sent, stored data deleted). Every event carries the app and device (`platform`, `appVersion`, `build`, `osVersion`, `deviceModel`, `deviceType`, `locale`) from `expo-application`, `expo-device` and `expo-localization` when installed. Uncaught JavaScript errors (through `ErrorUtils`, the app's own handler still runs) and `captureError` go to Build › Errors with the app version as release. The config for flags and experiments is cached, so a cold start shows the same variant as the last run. Install attribution: UTM and referral parameters of the deep link that opened the app the first time, sent once with `$install`; no IDFA, no MMP.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The MRRocket SDK, one package with three ways in:
|
|
|
10
10
|
|
|
11
11
|
One provider in your layout (or one script tag) gives MRRocket page views, your events, A/B tests, feature flags and errors that the agents can run and read. MRRocket shows every variant of your real pages, because the SDK can render any variant on request.
|
|
12
12
|
|
|
13
|
-
Status: 0.
|
|
13
|
+
Status: 0.8.0, MIT licensed, compiled to ESM JavaScript with type declarations (no `transpilePackages`); the React Native entry is CommonJS for Metro. Works with Next.js 15 and 16, React 18 and 19, on Node and on Cloudflare Workers (OpenNext), and with React Native 0.72+ / Expo. The source lives in the MRRocket repo (`prototype/web/packages/cockpit-next`).
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -141,18 +141,18 @@ MRRocket shows each variant of your page by loading it with `?ck_preview=<experi
|
|
|
141
141
|
|
|
142
142
|
## 7. Feedback board
|
|
143
143
|
|
|
144
|
-
A
|
|
144
|
+
A Featurebase-style board for your own `/feedback` page: your product's logo and name, requests with votes and comments, a roadmap (Backlog, Next up, In progress, Done), a changelog, search and a light/dark toggle. A duplicate hint shows similar requests while visitors type.
|
|
145
145
|
|
|
146
146
|
```tsx
|
|
147
147
|
// app/feedback/page.tsx
|
|
148
148
|
import { FeedbackBoard } from "@mrrocket/sdk/next";
|
|
149
149
|
|
|
150
150
|
export default function Page() {
|
|
151
|
-
return <FeedbackBoard
|
|
151
|
+
return <FeedbackBoard theme="auto" accent="#3f76ff" syncUrl />;
|
|
152
152
|
}
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
It must sit inside `CockpitProvider`.
|
|
155
|
+
It must sit inside `CockpitProvider`. Visitors give a name and email once before they post, vote or comment (kept in their browser); call `identify(user.id, { name, email })` and signed-in users are not asked. `syncUrl` keeps the tab and the open post in the address (`?view=roadmap`, `?post=…`), `fullPage` fills the window, `header={false}` shows a plain heading instead of the top bar. MRRocket also hosts the same board at `/b/<productId>` (Feedback › Public board).
|
|
156
156
|
|
|
157
157
|
## 8. Feedback widget
|
|
158
158
|
|
|
@@ -267,7 +267,7 @@ mrrocket.ready(function (m) {
|
|
|
267
267
|
To call it before the file has loaded, put this stub above the script tag; the calls wait in a queue and run once it has:
|
|
268
268
|
|
|
269
269
|
```html
|
|
270
|
-
<script>!function(w,m){w[m]=w[m]||{q:[]};"track identify reset captureError setConsent ready".split(" ").forEach(function(k){w[m][k]=w[m][k]||function(){w[m].q.push([k,arguments])}})}(window,"mrrocket");</script>
|
|
270
|
+
<script>!function(w,m){w[m]=w[m]||{q:[]};"track identify reset captureError setConsent ready openCancelFlow startOnboarding".split(" ").forEach(function(k){w[m][k]=w[m][k]||function(){w[m].q.push([k,arguments])}})}(window,"mrrocket");</script>
|
|
271
271
|
```
|
|
272
272
|
|
|
273
273
|
Settings are data attributes on the script tag:
|
|
@@ -283,11 +283,13 @@ Settings are data attributes on the script tag:
|
|
|
283
283
|
| `data-release` | Your release, for Build › Errors | none |
|
|
284
284
|
| `data-attribution` / `data-referral-param` | Affiliate referrals: `consent`, `always`, `off`; the direct-link parameter | `consent`, `ref` |
|
|
285
285
|
|
|
286
|
-
**Experiments on texts:** mark an element `data-ck-text="hero-title"` and MRRocket's variant texts replace its text; the original text is the control. `<Text>` and `<Experiment>` (React components) are Next.js-only
|
|
286
|
+
**Experiments on texts:** mark an element `data-ck-text="hero-title"` and MRRocket's variant texts replace its text; the original text is the control. `<Text>` and `<Experiment>` (React components) are Next.js-only; React Native reads texts with `useText`.
|
|
287
287
|
|
|
288
288
|
**Session recordings:** when the product records and the visitor's replay consent is given (`data-replay="true"` or `setConsent({ replay: true })`), the recorder loads from `sdk-replay.js` (about 26 KB gzip) next to `sdk.js`; pages that do not record never load it. Same privacy defaults as 12.
|
|
289
289
|
|
|
290
|
-
`
|
|
290
|
+
**Cancel flow, onboarding, in-app messages (0.7.0):** `mrrocket.cancelFlow.open({ … })` and `mrrocket.onboarding.start()` (see 15); `data-onboarding="false"` keeps flows from starting on their own, `data-theme` and `data-accent` style the dialogs. The dialogs load from `sdk-flows.js` (about 5 KB gzip) only when one shows.
|
|
291
|
+
|
|
292
|
+
`sdk.js` is about 11 KB gzip. MRRocket serves it with open CORS and a short cache (browsers 5 minutes, CDN 1 hour), so a new release reaches every site within the hour.
|
|
291
293
|
|
|
292
294
|
## 14. React Native and Expo
|
|
293
295
|
|
|
@@ -331,6 +333,25 @@ const { track, identify, reset, screen, setConsent, captureError, flush, ready }
|
|
|
331
333
|
const newCheckout = useFlag("new-checkout"); // false until the config is known
|
|
332
334
|
const plan = useFlagVariant("plan-picker"); // string | null
|
|
333
335
|
const variant = useVariant("paywall"); // "control" until known; records the exposure once per screen
|
|
336
|
+
const headline = useText("paywall.onboarding.headline", "Unlock everything"); // your words until MRRocket has some
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Texts (paywall copy, text tests):** `useText(id, fallback)` is the app's `<Text id>`. It returns the copy published in MRRocket for `id` (a paywall's `paywall.<placement>.headline`, `.body` and `.cta`, as Conversion › Paywalls shows them), with this person's variant of a running text test on top, the same rules as the website: control keeps the published words, and your `fallback` shows while nothing is published (or on the very first start before the config arrives; later starts use the cached config). Showing the text records `$exposure` for every test that changes `id`, once per screen, so a test that only swaps copy needs no `useVariant`. Outside React: `client.getText(id, fallback)` (records the exposure), or `client.peekText(id)` with `client.exposeText(id)` when you render it later.
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
const PLACEMENT = "onboarding";
|
|
343
|
+
function Paywall() {
|
|
344
|
+
const headline = useText(`paywall.${PLACEMENT}.headline`, "Unlock everything");
|
|
345
|
+
const body = useText(`paywall.${PLACEMENT}.body`, "All features, cancel anytime.");
|
|
346
|
+
const cta = useText(`paywall.${PLACEMENT}.cta`, "Start free trial");
|
|
347
|
+
return (
|
|
348
|
+
<View>
|
|
349
|
+
<Text style={styles.title}>{headline}</Text>
|
|
350
|
+
<Text>{body}</Text>
|
|
351
|
+
<Button title={cta} onPress={buy} />
|
|
352
|
+
</View>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
334
355
|
```
|
|
335
356
|
|
|
336
357
|
Outside React (a store, a background task): `createMRRocket({ apiKey })` returns the one client the provider would use; `getMRRocket()` returns the running one.
|
|
@@ -370,6 +391,80 @@ The SDK does not remember the choice: pass it on every start from your consent s
|
|
|
370
391
|
|
|
371
392
|
Not in this version (coming later): session recordings and heatmaps, the feedback and survey UI, store install referrer and SKAdNetwork.
|
|
372
393
|
|
|
394
|
+
## 15. Cancel flow, onboarding and in-app messages (0.7.0)
|
|
395
|
+
|
|
396
|
+
What you build in MRRocket shows inside your app; the config carries it, and the dialogs load only when one shows.
|
|
397
|
+
|
|
398
|
+
**Cancel flow** (Churn › Cancel flow): call it from your Cancel button. Business customers get the survey ("Why are you leaving?") and the offer you set for their answer; anyone else gets the plain confirmation, so a consumer can always cancel in one step (§ 312k BGB). Without a live flow it shows the plain confirmation too, so the button always works.
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { useCancelFlow } from "@mrrocket/sdk/next"; // React Native: from "@mrrocket/sdk/react-native"
|
|
402
|
+
|
|
403
|
+
const cancelFlow = useCancelFlow();
|
|
404
|
+
<button
|
|
405
|
+
onClick={async () => {
|
|
406
|
+
const result = await cancelFlow.open({
|
|
407
|
+
customer: account.isBusiness ? "business" : "consumer",
|
|
408
|
+
price: { amount: 29, currency: "EUR" }, // optional: shows what the discount costs
|
|
409
|
+
onCancel: (r) => api.cancelSubscription({ reason: r.reason, comment: r.comment }),
|
|
410
|
+
onSave: (r) => api.applyOffer(r.offer), // { kind: "discount", percent, months } | { kind: "pause", months } | { kind: "downgrade", plan } | { kind: "contact" }
|
|
411
|
+
});
|
|
412
|
+
// result.outcome: "cancelled" | "saved" | "kept"
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
Cancel subscription
|
|
416
|
+
</button>;
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
MRRocket never changes a subscription (its payment key is read-only): `onCancel` and `onSave` are your code. Only after they succeed is the outcome recorded, and MRRocket shows it under Churn as a cancellation or a save with the customer's plan and monthly revenue (matched by your `identify()` id, or the email you passed with it). If one throws, the dialog stays open with "Something went wrong. Please try again." Script tag: `mrrocket.cancelFlow.open({ … })`.
|
|
420
|
+
|
|
421
|
+
**Onboarding** (Conversion › Onboarding): the tour, checklist and modal steps of a live flow. A flow starts on its own when the person does its trigger: `track("Signed up")` (or `signup`, `sign_up`) for "Signed up", `track("first_export")` for "First export", and the first page or screen for "First launch" / "First visit", once per person. Automatic starts need a signed-in person (`identify`) or stored progress (`consent="cookies"` / `"persistent"`), so nobody gets the same tour on every page. `useCockpit().startOnboarding()` (script tag `mrrocket.onboarding.start()`, React Native `useOnboarding().start()`) starts one whenever you want; `onboarding={false}` on the provider switches the automatic start off. A tour step points at the element marked `data-ck-target="…"` (web); a checklist item with an event ticks itself when you track it. Emails of the flow go out from MRRocket.
|
|
422
|
+
|
|
423
|
+
**In-app messages** (Lifecycle › Journeys, an "In-app message" step): a signed-in person's pending messages load after `identify()` and show as a banner or a dialog. Clicking or closing it moves the person on in the journey; after 7 days without an answer they go on anyway.
|
|
424
|
+
|
|
425
|
+
**Look:** `appearance={{ theme: "dark", accent: "#0f766e", className: "my-dialogs" }}` on `CockpitProvider` (React Native: `{ theme, accent }`; script tag: `data-theme`, `data-accent`). On the web the dialogs read CSS variables on `.ckf-root`: `--ckf-accent`, `--ckf-bg`, `--ckf-fg`, `--ckf-muted`, `--ckf-line`, `--ckf-radius`, `--ckf-font`. React Native draws them next to your app through `MRRocketProvider` (`flows={false}` to draw `client.flows` yourself with `<MRRocketFlows flows={client.flows} />`); tour steps show as a card there.
|
|
426
|
+
|
|
427
|
+
**Events** (under your analytics consent): `$cancel_flow` `{ action: "shown" | "answered" | "offer" | "finished", run, version, reason?, offer?, outcome?, comment? }`, `$onboarding` `{ flow, step?, action: "started" | "shown" | "completed" | "dismissed" }`, `$inapp` `{ message, journey, action: "shown" | "clicked" | "dismissed" }`. With consent `"off"` the cancel flow still works, but nothing is recorded, and no onboarding or messages show.
|
|
428
|
+
|
|
429
|
+
## 16. Push notifications (0.7.0)
|
|
430
|
+
|
|
431
|
+
A Lifecycle journey's Push step goes to the phones and browsers where the signed-in person turned notifications on.
|
|
432
|
+
|
|
433
|
+
```tsx
|
|
434
|
+
// Expo (needs expo-notifications; expo-constants for the EAS project id). After identify():
|
|
435
|
+
import { usePush } from "@mrrocket/sdk/react-native";
|
|
436
|
+
const push = usePush();
|
|
437
|
+
await push.register(); // { status: "registered" | "denied" | "not-asked" | "unavailable" | "not-signed-in" | "error" }
|
|
438
|
+
|
|
439
|
+
// Next.js, from a click, after identify(). Your site serves /mrrocket-sw.js (a copy of dist/browser/mrrocket-sw.js):
|
|
440
|
+
import { usePush as useWebPush } from "@mrrocket/sdk/next";
|
|
441
|
+
const webPush = useWebPush();
|
|
442
|
+
<button onClick={() => webPush.subscribe()}>Turn on notifications</button>;
|
|
443
|
+
|
|
444
|
+
// Script tag
|
|
445
|
+
mrrocket.push.subscribe();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`register({ ask: false })` / `subscribe({ ask: false })` only send an existing token or subscription again. `reset()` on React Native takes the token back; on the web call `push.unsubscribe()` before `reset()`. A site with its own service worker adds `importScripts("/mrrocket-sw.js")` to it and calls `subscribe({ serviceWorker: false })`. Opening a MRRocket notification records `$push { action: "opened", message }`.
|
|
449
|
+
|
|
450
|
+
## 17. Redirects and redirect tests (0.7.0)
|
|
451
|
+
|
|
452
|
+
Page redirects (Landing pages › Redirect) and redirect tests ("Whole page" in Experiments) come with the config: the SDK sends the visitor on before the page shows, keeping the query and hash, and the page a test's variant lands on records the exposure (`ck_x=<test>:<variant>`, removed from the address). Nothing to add for the script tag (the optional `ck-hide` snippet stops a flash of the old page). In Next.js `<CockpitProvider>` redirects after the first paint; to answer before rendering, use the proxy helper:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// proxy.ts
|
|
456
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
457
|
+
import { experimentRedirect, getCockpitConfig } from "@mrrocket/sdk/next/proxy";
|
|
458
|
+
|
|
459
|
+
export async function proxy(request: NextRequest) {
|
|
460
|
+
const to = experimentRedirect(request, await getCockpitConfig({ timeoutMs: 800 }));
|
|
461
|
+
return to ? NextResponse.redirect(to, 307) : NextResponse.next();
|
|
462
|
+
}
|
|
463
|
+
export const config = { matcher: ["/pricing"] };
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
A running test needs a stable id on the server (the `ck_id` cookie with `consent="cookies"`, or `{ distinctId }`); without one it is left to the provider.
|
|
467
|
+
|
|
373
468
|
## Privacy and consent
|
|
374
469
|
|
|
375
470
|
```tsx
|
package/dist/api.d.ts
CHANGED
|
@@ -14,13 +14,23 @@ export interface FeedbackPost {
|
|
|
14
14
|
comments: number;
|
|
15
15
|
voted: boolean;
|
|
16
16
|
createdAt: string;
|
|
17
|
+
/** "Lena K.", "Anonymous" or "Team". Missing from servers before SDK 0.6. */
|
|
18
|
+
author?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The product's public face for the board's header. */
|
|
21
|
+
export interface FeedbackProduct {
|
|
22
|
+
name: string;
|
|
23
|
+
logo: string | null;
|
|
17
24
|
}
|
|
18
25
|
export interface FeedbackList {
|
|
19
26
|
boards: FeedbackBoardInfo[];
|
|
20
27
|
posts: FeedbackPost[];
|
|
28
|
+
product?: FeedbackProduct;
|
|
21
29
|
}
|
|
22
30
|
export interface FeedbackRoadmap {
|
|
31
|
+
/** `review` (Backlog) comes from servers of SDK 0.6 on. */
|
|
23
32
|
columns: {
|
|
33
|
+
review?: FeedbackPost[];
|
|
24
34
|
planned: FeedbackPost[];
|
|
25
35
|
progress: FeedbackPost[];
|
|
26
36
|
shipped: FeedbackPost[];
|
|
@@ -69,7 +79,7 @@ interface Conn {
|
|
|
69
79
|
apiKey: string;
|
|
70
80
|
distinctId: string;
|
|
71
81
|
user?: {
|
|
72
|
-
id
|
|
82
|
+
id?: string;
|
|
73
83
|
[key: string]: unknown;
|
|
74
84
|
};
|
|
75
85
|
}
|
|
@@ -80,6 +90,7 @@ export declare const createApi: (c: Conn) => {
|
|
|
80
90
|
sort?: "top" | "new";
|
|
81
91
|
q?: string;
|
|
82
92
|
}, signal?: AbortSignal) => Promise<FeedbackList>;
|
|
93
|
+
post: (id: string, signal?: AbortSignal) => Promise<FeedbackPost>;
|
|
83
94
|
roadmap: (signal?: AbortSignal) => Promise<FeedbackRoadmap>;
|
|
84
95
|
changelog: (signal?: AbortSignal) => Promise<{
|
|
85
96
|
entries: ChangelogEntry[];
|
|
@@ -108,6 +119,15 @@ export declare const createApi: (c: Conn) => {
|
|
|
108
119
|
}) => Promise<unknown>;
|
|
109
120
|
};
|
|
110
121
|
export type Api = ReturnType<typeof createApi>;
|
|
111
|
-
/**
|
|
112
|
-
export
|
|
122
|
+
/** Who writes: the provider's visitor, or the name and email a visitor gave the board (see useBoardIdentity). */
|
|
123
|
+
export interface Writer {
|
|
124
|
+
distinctId: string;
|
|
125
|
+
user?: {
|
|
126
|
+
id?: string;
|
|
127
|
+
name?: string;
|
|
128
|
+
email?: string;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The client for the provider's key, host and visitor, or for `as` when given. */
|
|
132
|
+
export declare const useApi: (as?: Writer | null) => Api;
|
|
113
133
|
export {};
|
package/dist/api.js
CHANGED
|
@@ -47,7 +47,8 @@ var createApi = (c) => {
|
|
|
47
47
|
const post = (path, body, signal) => call(`${base}${path}`, { method: "POST", body: JSON.stringify({ ...who, ...body }) }, signal);
|
|
48
48
|
return {
|
|
49
49
|
list: (o, signal) => call(`${base}/feedback?${qs({ key: c.apiKey, distinctId: c.distinctId, board: o.board, sort: o.sort, q: o.q })}`, void 0, signal),
|
|
50
|
-
|
|
50
|
+
post: (id, signal) => call(`${base}/feedback/${encodeURIComponent(id)}?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, void 0, signal),
|
|
51
|
+
roadmap: (signal) => call(`${base}/feedback/roadmap?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, void 0, signal),
|
|
51
52
|
changelog: (signal) => call(`${base}/feedback/changelog?${qs({ key: c.apiKey })}`, void 0, signal),
|
|
52
53
|
submit: (p) => post("/feedback", p),
|
|
53
54
|
vote: (id) => post(`/feedback/${encodeURIComponent(id)}/vote`, {}),
|
|
@@ -58,8 +59,11 @@ var createApi = (c) => {
|
|
|
58
59
|
respond: (id, r) => post(`/surveys/${encodeURIComponent(id)}/respond`, r)
|
|
59
60
|
};
|
|
60
61
|
};
|
|
61
|
-
var useApi = () => {
|
|
62
|
-
const
|
|
62
|
+
var useApi = (as) => {
|
|
63
|
+
const ctx = useCtx();
|
|
64
|
+
const distinctId = as?.distinctId ?? ctx.distinctId;
|
|
65
|
+
const user = as ? as.user : ctx.user;
|
|
66
|
+
const { apiKey, host } = ctx;
|
|
63
67
|
return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);
|
|
64
68
|
};
|
|
65
69
|
export {
|
package/dist/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/api.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useCtx } from \"./context\";\n\nexport type PostStatus = \"review\" | \"planned\" | \"progress\" | \"shipped\";\n\nexport const STATUS_LABEL: Record<PostStatus, string> = {\n review: \"Under review\",\n planned: \"Planned\",\n progress: \"In progress\",\n shipped: \"Shipped\",\n};\n\nexport interface FeedbackBoardInfo {\n id: string;\n label: string;\n}\n\nexport interface FeedbackPost {\n id: string;\n title: string;\n body: string;\n board: string;\n status: PostStatus;\n votes: number;\n comments: number;\n voted: boolean;\n createdAt: string;\n}\n\nexport interface FeedbackList {\n boards: FeedbackBoardInfo[];\n posts: FeedbackPost[];\n}\n\nexport interface FeedbackRoadmap {\n columns: { planned: FeedbackPost[]; progress: FeedbackPost[]; shipped: FeedbackPost[] };\n}\n\nexport interface ChangelogEntry {\n id: string;\n title: string;\n body: string;\n publishedAt: string;\n}\n\nexport interface FeedbackComment {\n id: string;\n body: string;\n author: string;\n isTeam: boolean;\n postedAt: string;\n}\n\nexport type SurveyQuestionKind = \"nps\" | \"rating\" | \"single\" | \"multi\" | \"text\";\n\nexport interface SurveyQuestion {\n key: string;\n kind: SurveyQuestionKind;\n title: string;\n description?: string;\n required: boolean;\n options?: string[];\n scale?: number;\n low?: string;\n high?: string;\n placeholder?: string;\n}\n\nexport interface SurveyDefinition {\n id: string;\n name: string;\n type: string;\n thanks: string;\n questions: SurveyQuestion[];\n}\n\nexport type SurveyAnswer = number | string | string[];\n\nexport class ApiError extends Error {\n status: number;\n constructor(message: string, status: number) {\n super(message);\n this.status = status;\n }\n}\n\ninterface Conn {\n host: string;\n apiKey: string;\n distinctId: string;\n user?: { id
|
|
1
|
+
{"version":3,"sources":["../src/api.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useCtx } from \"./context\";\n\nexport type PostStatus = \"review\" | \"planned\" | \"progress\" | \"shipped\";\n\nexport const STATUS_LABEL: Record<PostStatus, string> = {\n review: \"Under review\",\n planned: \"Planned\",\n progress: \"In progress\",\n shipped: \"Shipped\",\n};\n\nexport interface FeedbackBoardInfo {\n id: string;\n label: string;\n}\n\nexport interface FeedbackPost {\n id: string;\n title: string;\n body: string;\n board: string;\n status: PostStatus;\n votes: number;\n comments: number;\n voted: boolean;\n createdAt: string;\n /** \"Lena K.\", \"Anonymous\" or \"Team\". Missing from servers before SDK 0.6. */\n author?: string;\n}\n\n/** The product's public face for the board's header. */\nexport interface FeedbackProduct {\n name: string;\n logo: string | null;\n}\n\nexport interface FeedbackList {\n boards: FeedbackBoardInfo[];\n posts: FeedbackPost[];\n product?: FeedbackProduct;\n}\n\nexport interface FeedbackRoadmap {\n /** `review` (Backlog) comes from servers of SDK 0.6 on. */\n columns: { review?: FeedbackPost[]; planned: FeedbackPost[]; progress: FeedbackPost[]; shipped: FeedbackPost[] };\n}\n\nexport interface ChangelogEntry {\n id: string;\n title: string;\n body: string;\n publishedAt: string;\n}\n\nexport interface FeedbackComment {\n id: string;\n body: string;\n author: string;\n isTeam: boolean;\n postedAt: string;\n}\n\nexport type SurveyQuestionKind = \"nps\" | \"rating\" | \"single\" | \"multi\" | \"text\";\n\nexport interface SurveyQuestion {\n key: string;\n kind: SurveyQuestionKind;\n title: string;\n description?: string;\n required: boolean;\n options?: string[];\n scale?: number;\n low?: string;\n high?: string;\n placeholder?: string;\n}\n\nexport interface SurveyDefinition {\n id: string;\n name: string;\n type: string;\n thanks: string;\n questions: SurveyQuestion[];\n}\n\nexport type SurveyAnswer = number | string | string[];\n\nexport class ApiError extends Error {\n status: number;\n constructor(message: string, status: number) {\n super(message);\n this.status = status;\n }\n}\n\ninterface Conn {\n host: string;\n apiKey: string;\n distinctId: string;\n user?: { id?: string; [key: string]: unknown };\n}\n\nconst qs = (params: Record<string, string | undefined>) => {\n const p = new URLSearchParams();\n for (const [k, v] of Object.entries(params)) if (v) p.set(k, v);\n return p.toString();\n};\n\nconst call = async <T>(url: string, init: RequestInit | undefined, signal?: AbortSignal): Promise<T> => {\n let res: Response;\n try {\n res = await fetch(url, {\n ...init,\n signal,\n headers: init?.body ? { \"content-type\": \"application/json\" } : undefined,\n });\n } catch (e) {\n if ((e as Error).name === \"AbortError\") throw e;\n throw new ApiError(\"Could not reach the server. Check your connection and try again.\", 0);\n }\n if (!res.ok) {\n let detail = \"\";\n try {\n detail = ((await res.json()) as { error?: string }).error ?? \"\";\n } catch {\n // Not JSON; the status is enough.\n }\n throw new ApiError(detail || (res.status === 429 ? \"Too many requests. Try again in a minute.\" : \"Something went wrong. Please try again.\"), res.status);\n }\n return (await res.json()) as T;\n};\n\n/** The small fetch client behind the widgets. Every read takes an AbortSignal so unmounting cancels it. */\nexport const createApi = (c: Conn) => {\n const base = `${c.host}/api/sdk`;\n const who = { key: c.apiKey, distinctId: c.distinctId, user: c.user };\n const post = <T>(path: string, body: Record<string, unknown>, signal?: AbortSignal) =>\n call<T>(`${base}${path}`, { method: \"POST\", body: JSON.stringify({ ...who, ...body }) }, signal);\n return {\n list: (o: { board?: string; sort?: \"top\" | \"new\"; q?: string }, signal?: AbortSignal) =>\n call<FeedbackList>(`${base}/feedback?${qs({ key: c.apiKey, distinctId: c.distinctId, board: o.board, sort: o.sort, q: o.q })}`, undefined, signal),\n post: (id: string, signal?: AbortSignal) =>\n call<FeedbackPost>(`${base}/feedback/${encodeURIComponent(id)}?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, undefined, signal),\n roadmap: (signal?: AbortSignal) => call<FeedbackRoadmap>(`${base}/feedback/roadmap?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, undefined, signal),\n changelog: (signal?: AbortSignal) => call<{ entries: ChangelogEntry[] }>(`${base}/feedback/changelog?${qs({ key: c.apiKey })}`, undefined, signal),\n submit: (p: { title: string; body?: string; board: string }) => post<FeedbackPost>(\"/feedback\", p),\n vote: (id: string) => post<{ voted: boolean; votes: number }>(`/feedback/${encodeURIComponent(id)}/vote`, {}),\n comments: (id: string, signal?: AbortSignal) =>\n call<{ comments: FeedbackComment[] }>(`${base}/feedback/${encodeURIComponent(id)}/comments?${qs({ key: c.apiKey })}`, undefined, signal),\n comment: (id: string, body: string) => post<FeedbackComment>(`/feedback/${encodeURIComponent(id)}/comments`, { body }),\n survey: (url: string, signal?: AbortSignal) =>\n call<{ survey: SurveyDefinition | null }>(`${base}/surveys?${qs({ key: c.apiKey, distinctId: c.distinctId, url })}`, undefined, signal),\n seen: (id: string) => post<unknown>(`/surveys/${encodeURIComponent(id)}/seen`, { user: undefined }),\n respond: (id: string, r: { answers: Record<string, SurveyAnswer>; completed: boolean; seconds: number }) =>\n post<unknown>(`/surveys/${encodeURIComponent(id)}/respond`, r),\n };\n};\n\nexport type Api = ReturnType<typeof createApi>;\n\n/** Who writes: the provider's visitor, or the name and email a visitor gave the board (see useBoardIdentity). */\nexport interface Writer {\n distinctId: string;\n user?: { id?: string; name?: string; email?: string };\n}\n\n/** The client for the provider's key, host and visitor, or for `as` when given. */\nexport const useApi = (as?: Writer | null): Api => {\n const ctx = useCtx();\n const distinctId = as?.distinctId ?? ctx.distinctId;\n const user = as ? as.user : ctx.user;\n const { apiKey, host } = ctx;\n return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);\n};\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
(function(){'use strict';var E=(n,a)=>Math.round(n*(1-a/100)*100)/100,v=(n,a)=>{try{return new Intl.NumberFormat(void 0,{style:"currency",currency:a}).format(n)}catch(p){return `${n.toFixed(2)} ${a}`}},S=n=>n.kind==="contact"?"Send to the team":n.kind==="pause"?"Pause instead":n.kind==="downgrade"?"Switch plan":"Accept offer";var O="ckf-styles",_=`
|
|
2
|
+
.ckf-root{--ckf-accent:#3f76ff;--ckf-accent-ink:#fff;--ckf-bg:#fff;--ckf-fg:#1c1d20;--ckf-muted:#5d6068;--ckf-soft:#f4f5f7;--ckf-line:#e4e5e8;--ckf-danger:#d92d20;--ckf-radius:14px;--ckf-font:inherit;--ckf-shadow:0 16px 40px -12px rgba(16,18,24,.28),0 0 0 1px rgba(16,18,24,.07)}
|
|
3
|
+
.ckf-root[data-theme=dark]{--ckf-bg:#17181b;--ckf-fg:#f1f2f4;--ckf-muted:#a9acb3;--ckf-soft:#232428;--ckf-line:#2e2f35;--ckf-shadow:0 16px 40px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.08)}
|
|
4
|
+
@media (prefers-color-scheme:dark){.ckf-root[data-theme=auto]{--ckf-bg:#17181b;--ckf-fg:#f1f2f4;--ckf-muted:#a9acb3;--ckf-soft:#232428;--ckf-line:#2e2f35;--ckf-shadow:0 16px 40px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.08)}}
|
|
5
|
+
.ckf-root,.ckf-root *{box-sizing:border-box}
|
|
6
|
+
.ckf-root{font-family:var(--ckf-font);font-size:14px;line-height:1.45;color:var(--ckf-fg);-webkit-font-smoothing:antialiased}
|
|
7
|
+
.ckf-root :where(button,textarea){font:inherit;color:inherit;margin:0}
|
|
8
|
+
.ckf-root :focus-visible{outline:2px solid var(--ckf-accent);outline-offset:2px}
|
|
9
|
+
.ckf-scrim{position:fixed;inset:0;z-index:2147483002;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;padding:16px;animation:ckf-fade .15s ease-out}
|
|
10
|
+
.ckf-card{position:relative;width:100%;max-width:400px;max-height:calc(100dvh - 32px);overflow-y:auto;background:var(--ckf-bg);color:var(--ckf-fg);border-radius:var(--ckf-radius);box-shadow:var(--ckf-shadow);padding:18px 20px 20px;display:flex;flex-direction:column;gap:12px;animation:ckf-in .18s ease-out}
|
|
11
|
+
.ckf-float{position:fixed;z-index:2147483001;right:20px;bottom:20px;width:340px;max-width:calc(100vw - 24px)}
|
|
12
|
+
.ckf-top{display:flex;align-items:center;gap:8px;min-height:20px}
|
|
13
|
+
.ckf-dots{display:flex;gap:4px}
|
|
14
|
+
.ckf-dot{width:16px;height:4px;border-radius:4px;background:var(--ckf-line)}
|
|
15
|
+
.ckf-dot[data-on]{background:var(--ckf-accent)}
|
|
16
|
+
.ckf-x{margin-left:auto;width:28px;height:28px;border:0;border-radius:8px;background:none;color:var(--ckf-muted);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:18px;line-height:1}
|
|
17
|
+
.ckf-x:hover{background:var(--ckf-soft);color:var(--ckf-fg)}
|
|
18
|
+
.ckf-h{margin:0;font-size:17px;font-weight:650;letter-spacing:-.01em;text-wrap:balance}
|
|
19
|
+
.ckf-p{margin:0;color:var(--ckf-muted);text-wrap:pretty;white-space:pre-wrap}
|
|
20
|
+
.ckf-small{margin:0;font-size:12px;color:var(--ckf-muted)}
|
|
21
|
+
.ckf-opts{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}
|
|
22
|
+
.ckf-opt{display:flex;align-items:center;gap:10px;width:100%;min-height:40px;padding:8px 12px;border:1px solid var(--ckf-line);border-radius:10px;background:var(--ckf-bg);text-align:left;cursor:pointer;font-weight:500}
|
|
23
|
+
.ckf-opt:hover{background:var(--ckf-soft)}
|
|
24
|
+
.ckf-opt[aria-checked=true]{border-color:var(--ckf-accent);background:color-mix(in srgb,var(--ckf-accent) 8%,var(--ckf-bg))}
|
|
25
|
+
.ckf-radio{width:16px;height:16px;flex:none;border-radius:50%;border:1.5px solid var(--ckf-line);display:inline-flex;align-items:center;justify-content:center}
|
|
26
|
+
.ckf-opt[aria-checked=true] .ckf-radio{border-color:var(--ckf-accent)}
|
|
27
|
+
.ckf-opt[aria-checked=true] .ckf-radio::after{content:"";width:8px;height:8px;border-radius:50%;background:var(--ckf-accent)}
|
|
28
|
+
.ckf-box{border-radius:10px;background:var(--ckf-soft);padding:12px;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
29
|
+
.ckf-big{font-size:22px;font-weight:650;font-variant-numeric:tabular-nums}
|
|
30
|
+
.ckf-strike{color:var(--ckf-muted);text-decoration:line-through;font-variant-numeric:tabular-nums}
|
|
31
|
+
.ckf-text{width:100%;min-height:64px;resize:vertical;border:1px solid var(--ckf-line);border-radius:10px;background:var(--ckf-bg);padding:8px 10px}
|
|
32
|
+
.ckf-text::placeholder{color:var(--ckf-muted)}
|
|
33
|
+
.ckf-btns{display:flex;flex-direction:column;gap:6px}
|
|
34
|
+
.ckf-btn{height:38px;padding:0 14px;border-radius:10px;border:1px solid var(--ckf-line);background:var(--ckf-bg);font-weight:600;cursor:pointer;transition:filter .1s linear,background-color .1s linear}
|
|
35
|
+
.ckf-btn:hover{background:var(--ckf-soft)}
|
|
36
|
+
.ckf-btn[disabled]{opacity:.55;cursor:not-allowed}
|
|
37
|
+
.ckf-primary{background:var(--ckf-accent);border-color:var(--ckf-accent);color:var(--ckf-accent-ink)}
|
|
38
|
+
.ckf-primary:hover{background:var(--ckf-accent);filter:brightness(1.07)}
|
|
39
|
+
.ckf-danger{background:var(--ckf-danger);border-color:var(--ckf-danger);color:#fff}
|
|
40
|
+
.ckf-danger:hover{background:var(--ckf-danger);filter:brightness(1.07)}
|
|
41
|
+
.ckf-quiet{border-color:transparent;background:none;color:var(--ckf-muted);height:30px;font-weight:500}
|
|
42
|
+
.ckf-quiet:hover{background:none;color:var(--ckf-fg)}
|
|
43
|
+
.ckf-err{margin:0;color:var(--ckf-danger);font-size:13px;font-weight:500}
|
|
44
|
+
.ckf-row{display:flex;justify-content:flex-end;gap:8px;align-items:center}
|
|
45
|
+
.ckf-row .ckf-small{margin-right:auto}
|
|
46
|
+
.ckf-ring{position:fixed;z-index:2147483000;pointer-events:none;border-radius:10px;box-shadow:0 0 0 2px var(--ckf-accent),0 0 0 6px color-mix(in srgb,var(--ckf-accent) 25%,transparent);transition:all .15s ease-out}
|
|
47
|
+
.ckf-check{display:flex;align-items:flex-start;gap:10px;width:100%;padding:8px 6px;border:0;border-radius:8px;background:none;text-align:left}
|
|
48
|
+
.ckf-check:where(button){cursor:pointer}
|
|
49
|
+
.ckf-check:where(button):hover{background:var(--ckf-soft)}
|
|
50
|
+
.ckf-tick{width:18px;height:18px;flex:none;margin-top:1px;border-radius:50%;border:1.5px solid var(--ckf-line);display:inline-flex;align-items:center;justify-content:center;color:var(--ckf-accent-ink);font-size:11px;font-weight:700}
|
|
51
|
+
.ckf-check[data-done] .ckf-tick{background:var(--ckf-accent);border-color:var(--ckf-accent)}
|
|
52
|
+
.ckf-check[data-done] b{color:var(--ckf-muted);text-decoration:line-through}
|
|
53
|
+
.ckf-check span{display:flex;flex-direction:column;gap:2px;min-width:0}
|
|
54
|
+
.ckf-bar{height:4px;border-radius:4px;background:var(--ckf-soft);overflow:hidden}
|
|
55
|
+
.ckf-bar i{display:block;height:100%;background:var(--ckf-accent)}
|
|
56
|
+
.ckf-banner{position:fixed;z-index:2147483001;left:50%;top:12px;transform:translateX(-50%);width:min(640px,calc(100vw - 24px));background:var(--ckf-bg);border-radius:12px;box-shadow:var(--ckf-shadow);padding:10px 10px 10px 16px;display:flex;align-items:center;gap:12px;animation:ckf-in .18s ease-out}
|
|
57
|
+
.ckf-banner>span{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
58
|
+
.ckf-banner .ckf-btn{height:32px}
|
|
59
|
+
@keyframes ckf-fade{from{opacity:0}}
|
|
60
|
+
@keyframes ckf-in{from{opacity:0;transform:translateY(6px)}}
|
|
61
|
+
.ckf-banner{animation-name:ckf-fade}
|
|
62
|
+
@media (max-width:520px){.ckf-scrim{align-items:flex-end;padding:0}.ckf-scrim .ckf-card{max-width:none;border-radius:var(--ckf-radius) var(--ckf-radius) 0 0;max-height:92dvh}.ckf-float{right:12px;bottom:12px}}
|
|
63
|
+
@media (prefers-reduced-motion:reduce){.ckf-root *{animation:none!important;transition:none!important}}
|
|
64
|
+
`,e=(n,a={},...p)=>{let d=document.createElement(n);for(let[f,c]of Object.entries(a))c===void 0||c===false||(typeof c=="function"?d.addEventListener(f.slice(2).toLowerCase(),c):f==="className"?d.className=String(c):d.setAttribute(f,c===true?"":c));for(let f of p)(f||f==="")&&d.append(typeof f=="string"?document.createTextNode(f):f);return d},b=(n,a,p,d={})=>e("button",{type:"button",className:`ckf-btn${a==="secondary"?"":` ckf-${a}`}`,disabled:!!d.disabled,"data-ckf-id":d.id,onClick:()=>p()},n),w=(n,a)=>e("button",{type:"button",className:"ckf-x","aria-label":n,"data-ckf-id":"x",onClick:()=>a()},"\xD7"),H=(n,a)=>a>1?e("span",{className:"ckf-dots",role:"img","aria-label":`Step ${n} of ${a}`},...Array.from({length:a},(p,d)=>e("span",{className:"ckf-dot","data-on":d<n}))):e("span"),P=n=>n.type==="cancel"?`c:${n.screen}`:n.type==="step"?`s:${n.step.id}`:n.type==="checklist"?`l:${n.flow.id}`:`m:${n.message.id}`,L=(n,a={})=>{var $;if(typeof document=="undefined")return ()=>{};if(!document.getElementById(O)){let t=document.createElement("style");t.id=O,t.textContent=_,document.head.appendChild(t);}let p=e("div",{className:`ckf-root${a.className?` ${a.className}`:""}`,"data-theme":($=a.theme)!=null?$:"auto"});a.accent&&p.style.setProperty("--ckf-accent",a.accent),document.body.appendChild(p);let d="",f=null,c="",h=null,T=t=>{var l,u;let o="ckf-title",r=t.busy,i=[];if(t.screen==="survey")i.push(e("h2",{className:"ckf-h",id:o},t.flow.question||"Why are you leaving?"),e("div",{className:"ckf-opts",role:"radiogroup","aria-labelledby":o},...t.flow.reasons.map(s=>e("button",{type:"button",role:"radio",className:"ckf-opt","aria-checked":String(t.reason===s.id),"data-ckf-id":`r:${s.id}`,onClick:()=>n.cancel.pick(s.id)},e("span",{className:"ckf-radio"}),s.label))),e("div",{className:"ckf-btns"},b("Continue","primary",n.cancel.next,{disabled:!t.reason,id:"go"}),b("Never mind, keep my plan","quiet",n.cancel.close)));else if(t.screen==="offer"&&t.offer){let s=t.offer,m=(l=t.flow.reasons.find(y=>y.id===t.reason))==null?void 0:l.label,g=false;s.kind==="discount"&&s.percent&&(g=t.price?e("div",{className:"ckf-box"},e("span",{className:"ckf-big"},v(E(t.price.amount,s.percent),t.price.currency)),e("span",{className:"ckf-strike"},v(t.price.amount,t.price.currency)),e("span",{className:"ckf-small"},`/${(u=t.price.interval)!=null?u:"month"}${s.months?` for ${s.months} ${s.months===1?"month":"months"}`:""}`)):e("div",{className:"ckf-box"},e("span",{className:"ckf-big"},`${s.percent}% off`),s.months?e("span",{className:"ckf-small"},`for ${s.months} ${s.months===1?"month":"months"}`):false)),s.kind==="pause"&&s.months&&(g=e("div",{className:"ckf-box"},e("span",{},`Pause for up to ${s.months} ${s.months===1?"month":"months"}. Your data stays.`))),s.kind==="downgrade"&&s.planName&&(g=e("div",{className:"ckf-box"},e("b",{},s.planName)));let k=s.kind==="contact"?e("textarea",{className:"ckf-text",placeholder:"What would make you stay?","aria-label":"Your message",maxlength:"500","data-ckf-id":"comment",onInput:y=>c=y.target.value}):null;k&&(k.value=c),i.push(m?e("p",{className:"ckf-small"},`You said: ${m}`):false,e("h2",{className:"ckf-h",id:o},s.headline),g,k!=null?k:false,e("div",{className:"ckf-btns"},b(r?"One moment\u2026":S(s),"primary",()=>n.cancel.accept(c),{disabled:r,id:"go"}),b("No thanks, continue cancelling","quiet",n.cancel.decline,{disabled:r})));}else {let m=t.steps===1?null:e("textarea",{className:"ckf-text",placeholder:"Anything else we should know? (optional)","aria-label":"Anything else we should know? (optional)",maxlength:"500","data-ckf-id":"comment",onInput:g=>c=g.target.value});m&&(m.value=c),i.push(e("h2",{className:"ckf-h",id:o},t.flow.confirm.title),t.flow.confirm.body?e("p",{className:"ckf-p"},t.flow.confirm.body):false,m!=null?m:false,e("div",{className:"ckf-btns"},b(r?"One moment\u2026":t.flow.confirm.button,"danger",()=>n.cancel.confirm(c),{disabled:r,id:"go"}),b("Keep my plan","secondary",n.cancel.close,{disabled:r})));}return e("div",{className:"ckf-scrim",onClick:s=>s.target===s.currentTarget&&!r&&n.cancel.close()},e("div",{className:"ckf-card",role:"dialog","aria-modal":"true","aria-labelledby":o},e("div",{className:"ckf-top"},H(t.step,t.steps),w("Close",n.cancel.close)),...i,t.error?e("p",{className:"ckf-err",role:"alert"},t.error):false))},I=t=>{let o=t.step,r=e("div",{className:"ckf-card",role:"dialog","aria-modal":o.kind==="modal"?"true":void 0,"aria-labelledby":"ckf-title"},e("div",{className:"ckf-top"},t.total>1?e("p",{className:"ckf-small"},`Step ${t.index+1} of ${t.total}`):e("span"),w("Close the tour",n.onboarding.dismiss)),e("h2",{className:"ckf-h",id:"ckf-title"},o.title),o.body?e("p",{className:"ckf-p"},o.body):false,e("div",{className:"ckf-row"},b(o.button||(t.last?"Done":"Next"),"primary",n.onboarding.next,{id:"go"})));if(o.kind==="modal")return e("div",{className:"ckf-scrim"},r);let i=o.target?document.querySelector(`[data-ck-target="${CSS.escape(o.target)}"]`):null,l=e("div",{className:"ckf-float"},r);if(i){let u=e("div",{className:"ckf-ring","aria-hidden":"true"});return h={float:l,ring:u,target:i},N(l,u,i),e("div",{},u,l)}return l},N=(t,o,r)=>{let i=r.getBoundingClientRect();Object.assign(o.style,{left:`${i.left-4}px`,top:`${i.top-4}px`,width:`${i.width+8}px`,height:`${i.height+8}px`});let l=Math.min(340,window.innerWidth-24),u=Math.max(12,Math.min(i.left,window.innerWidth-l-12)),s=i.bottom+12;Object.assign(t.style,{left:`${u}px`,right:"auto",width:`${l}px`}),s+200<window.innerHeight?Object.assign(t.style,{top:`${s}px`,bottom:"auto"}):Object.assign(t.style,{top:"auto",bottom:`${Math.max(12,window.innerHeight-i.top+12)}px`});},A=t=>{let o=t.items.filter(r=>r.done).length;return e("div",{className:"ckf-float"},e("div",{className:"ckf-card",role:"region","aria-labelledby":"ckf-title"},e("div",{className:"ckf-top"},e("h2",{className:"ckf-h",id:"ckf-title"},t.flow.name),w("Hide the checklist",n.onboarding.dismiss)),e("p",{className:"ckf-small"},`${o} of ${t.items.length} done`),e("div",{className:"ckf-bar",role:"progressbar","aria-valuemin":"0","aria-valuemax":String(t.items.length),"aria-valuenow":String(o)},e("i",{style:`width:${o/t.items.length*100}%`})),e("div",{className:"ckf-opts"},...t.items.map(({step:r,done:i})=>{let l=[e("span",{className:"ckf-tick","aria-hidden":"true"},i?"\u2713":""),e("span",{},e("b",{},r.title),r.body?e("span",{className:"ckf-small"},r.body):false)];return i||r.event?e("div",{className:"ckf-check","data-done":i,role:"listitem"},...l):e("button",{type:"button",className:"ckf-check","aria-label":`Mark \u201C${r.title}\u201D done`,"data-ckf-id":`i:${r.id}`,onClick:()=>n.onboarding.check(r.id)},...l)}))))},M=t=>{let o=t.message,r=o.button?b(o.button,"primary",n.message.click,{id:"go"}):false;return o.style==="banner"?e("div",{className:"ckf-banner",role:"status"},e("span",{},e("b",{},o.title),o.body?e("span",{className:"ckf-small"},o.body):false),r,w("Dismiss",n.message.dismiss)):e("div",{className:"ckf-scrim",onClick:i=>i.target===i.currentTarget&&n.message.dismiss()},e("div",{className:"ckf-card",role:"dialog","aria-modal":"true","aria-labelledby":"ckf-title"},e("div",{className:"ckf-top"},e("span"),w("Dismiss",n.message.dismiss)),e("h2",{className:"ckf-h",id:"ckf-title"},o.title),o.body?e("p",{className:"ckf-p"},o.body):false,r?e("div",{className:"ckf-row"},r):false))},C=()=>{var i,l,u,s,m;let t=n.view(),o=t?P(t):"",r=(l=(i=document.activeElement)==null?void 0:i.closest)!=null&&l.call(i,".ckf-root")?(u=document.activeElement)==null?void 0:u.getAttribute("data-ckf-id"):null;if(o&&!d&&document.activeElement instanceof HTMLElement&&!document.activeElement.closest(".ckf-root")&&(f=document.activeElement),o!==d&&!o.startsWith("c:")&&(c=""),o||(c=""),h=null,p.replaceChildren(...t?[t.type==="cancel"?T(t):t.type==="step"?I(t):t.type==="checklist"?A(t):M(t)]:[]),o!==d){let g=p.querySelector("[aria-modal=true]"),k=(s=g==null?void 0:g.querySelector("[role=radio], [data-ckf-id=go], textarea"))!=null?s:null;k==null||k.focus(),!o&&f&&(f.focus(),f=null);}else r&&((m=p.querySelector(`[data-ckf-id="${CSS.escape(r)}"]`))==null||m.focus());d=o;},F=t=>{if(t.key!=="Escape")return;let o=n.view();(o==null?void 0:o.type)==="cancel"?n.cancel.close():(o==null?void 0:o.type)==="message"?n.message.dismiss():(o==null?void 0:o.type)==="step"&&o.step.kind==="modal"&&n.onboarding.dismiss();},x=()=>{h&&N(h.float,h.ring,h.target);};document.addEventListener("keydown",F),window.addEventListener("resize",x),window.addEventListener("scroll",x,{passive:true,capture:true});let R=n.subscribe(C);return C(),()=>{R(),document.removeEventListener("keydown",F),window.removeEventListener("resize",x),window.removeEventListener("scroll",x,{capture:true}),p.remove();}};window.__mrrocketFlows={mountFlows:L};})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){'use strict';var g=e=>{let s=atob(e.replace(/-/g,"+").replace(/_/g,"/").padEnd(Math.ceil(e.length/4)*4,"=")),t=new Uint8Array(s.length);for(let r=0;r<s.length;r++)t[r]=s.charCodeAt(r);return t},P=(e,s)=>{if(!e)return false;let t=new Uint8Array(e);return t.length===s.length&&t.every((r,o)=>r===s[o])},a=()=>typeof window!="undefined"&&"serviceWorker"in navigator&&"PushManager"in window&&"Notification"in window,w=(e=true)=>{if(!a())return Promise.resolve("denied");if(Notification.permission!=="default"||!e)return Promise.resolve(Notification.permission);try{return Promise.resolve(Notification.requestPermission())}catch(s){return Promise.resolve("denied")}},m=async(e,s,t)=>{if(t!==false){let r=`${t}?key=${encodeURIComponent(s)}&host=${encodeURIComponent(e)}`;await navigator.serviceWorker.register(r);}return navigator.serviceWorker.ready},l=async(e,s={})=>{var o,u,c,d;if(!a())return {status:"unsupported",reason:"This browser has no web push."};if(!e.distinctId)return {status:"not-signed-in"};let t=s.ask!==false,r=await((o=e.permission)!=null?o:w(t));if(r==="denied")return {status:"denied"};if(r!=="granted")return {status:t?"denied":"not-asked"};try{let n=await fetch(`${e.host}/api/sdk/push?key=${encodeURIComponent(e.apiKey)}`),p=n.ok?(u=(await n.json()).web)==null?void 0:u.vapidKey:void 0;if(!p)return {status:"error",reason:`MRRocket answered ${n.status} for the push key.`};let h=g(p),b=await m(e.host,e.apiKey,(c=s.serviceWorker)!=null?c:"/mrrocket-sw.js"),i=await b.pushManager.getSubscription();i&&!P((d=i.options)==null?void 0:d.applicationServerKey,h)&&(await i.unsubscribe().catch(()=>!1),i=null),i!=null||(i=await b.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:h}));let f=await fetch(`${e.host}/api/sdk/push`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({key:e.apiKey,distinctId:e.distinctId,platform:"web",subscription:i.toJSON(),host:e.host})});return f.ok?{status:"subscribed"}:{status:"error",reason:`MRRocket answered ${f.status}.`}}catch(n){return {status:"error",reason:n instanceof Error?n.message:String(n)}}},y=async e=>{if(!a())return false;try{let s=await navigator.serviceWorker.getRegistration(),t=await(s==null?void 0:s.pushManager.getSubscription());return t?(await fetch(`${e.host}/api/sdk/push`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({key:e.apiKey,distinctId:e.distinctId,platform:"web",remove:!0,endpoint:t.endpoint})}).catch(()=>null),await t.unsubscribe()):!1}catch(s){return false}};window.__mrrocketPush={subscribeWebPush:l,unsubscribeWebPush:y};})();
|