@mrrocket/sdk 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package. It follows semantic versioning; before 1.0 a minor version may change an API.
4
+
5
+ ## 0.1.0
6
+
7
+ First public release on npm as `@mrrocket/sdk`, MIT licensed. Next.js lives under `@mrrocket/sdk/next`
8
+ (`/next/server`, `/next/proxy`); the package root is kept for a framework-free core.
9
+
10
+ - Affiliate referrals: `?mrr_ref`/`mrr_click` (tracked links) and `?ref` (direct links) are kept in a first-party `mrr_ref` cookie following consent (`attribution` prop), a click beacon goes to `/api/sdk/affiliate/click`, `$referral` rides on `identify()` and own events. New exports `getReferral`, `referralForCheckout`, `referralLink`, `readReferralCookie`. Tests: `npm test`.
11
+
12
+ ## 0.0.1 (never published)
13
+
14
+ First installable build, compiled to JavaScript (ESM) with type declarations: no `transpilePackages` needed.
15
+
16
+ - Page views, `data-ck-goal` clicks, `track`, `identify`, UTM and referrer capture, batched events.
17
+ - Consent modes `cookieless` (default), `cookies`, `off`, `respectDnt`.
18
+ - Experiments: `useVariant`, `<Experiment>`, `<Text>`, server helpers `getCockpitConfig`, `getVariant`, `getTexts`,
19
+ `previewFrom` (`/next/server`), `withCockpitId` (`/next/proxy`), preview links for MRRocket.
20
+ - `<FeedbackBoard>`, `<FeedbackWidget>`, `useFeedback`, `<Survey>` (automatic via the provider).
21
+ - `host` and `apiKey` default to `NEXT_PUBLIC_MRROCKET_HOST` and `NEXT_PUBLIC_MRROCKET_KEY`.
22
+ - Server helpers run on Node, Edge and Cloudflare Workers; `getCockpitConfig` times out after 2.5 s and returns `null`
23
+ (control variants) when MRRocket cannot be reached.
24
+ - Console warnings (once) when the host or key is wrong.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brombach Technologies GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,251 @@
1
- # Temporary Holding Version
1
+ # @mrrocket/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The MRRocket SDK. This release covers Next.js (App Router), imported from `@mrrocket/sdk/next`; other frameworks get their own entry in the same package (`@mrrocket/sdk/react` is next). One provider in your layout gives MRRocket page views, your events and A/B tests that the agents can run. MRRocket shows every variant of your real pages, because the SDK can render any variant on request.
4
+
5
+ Status: 0.1.0, MIT licensed, compiled to ESM JavaScript with type declarations (no `transpilePackages`). Works with Next.js 15 and 16, React 19, on Node and on Cloudflare Workers (OpenNext). The source lives in the MRRocket repo (`prototype/web/packages/cockpit-next`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @mrrocket/sdk # or npm install / yarn add
11
+ ```
12
+
13
+ Your public key is in MRRocket › Settings › Products, one per product (`mrr_pub_…`, or `ck_pub_…` for a product without a stored key). It is safe in the browser, like PostHog's project key. Never put a secret key in client code.
14
+
15
+ Two environment variables, both public (Next.js inlines `NEXT_PUBLIC_*` at build time, so they must exist when you run `next build`):
16
+
17
+ | Variable | What | Default |
18
+ | --- | --- | --- |
19
+ | `NEXT_PUBLIC_MRROCKET_HOST` | Where MRRocket runs, for example `https://app.example.com` (no path) | the page's own origin |
20
+ | `NEXT_PUBLIC_MRROCKET_KEY` | The product's public key | none: the SDK warns in the console and sends nothing |
21
+
22
+ `host` and `apiKey` props override them. The server answers every `/api/sdk` request with open CORS headers, so any website origin may call it.
23
+
24
+ ## 1. Provider in the root layout
25
+
26
+ ```tsx
27
+ // app/layout.tsx
28
+ import { CockpitProvider } from "@mrrocket/sdk/next";
29
+
30
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
31
+ return (
32
+ <html lang="en">
33
+ <body>
34
+ <CockpitProvider>
35
+ {children}
36
+ </CockpitProvider>
37
+ </body>
38
+ </html>
39
+ );
40
+ }
41
+ ```
42
+
43
+ From then on every route change is a `$pageview`, and clicks on elements with `data-ck-goal` count as that goal. By default the SDK is cookieless: it stores nothing in the browser (see [Privacy and consent](#privacy-and-consent)).
44
+
45
+ ```tsx
46
+ <button data-ck-goal="signup_started">Start free trial</button>
47
+ ```
48
+
49
+ ## 2. Your own events
50
+
51
+ ```tsx
52
+ "use client";
53
+ import { useCockpit } from "@mrrocket/sdk/next";
54
+
55
+ const { track, identify } = useCockpit();
56
+ track("checkout_started", { plan: "team" });
57
+ identify(user.id, { plan: user.plan });
58
+ ```
59
+
60
+ ## 3. A/B tests
61
+
62
+ MRRocket's agents create experiments; the key is shown on each test in MRRocket.
63
+
64
+ Swap a whole block:
65
+
66
+ ```tsx
67
+ import { Experiment } from "@mrrocket/sdk/next";
68
+
69
+ <Experiment name="rr-home-logos" variants={{ control: <Hero />, a: <HeroWithLogos /> }} />;
70
+ ```
71
+
72
+ Or ask for the variant:
73
+
74
+ ```tsx
75
+ const variant = useVariant("rr-pricing-annual-first"); // "control" | "a"
76
+ ```
77
+
78
+ Let tests change copy without a deploy: wrap the text once, and MRRocket can send new words for it.
79
+
80
+ ```tsx
81
+ <h1><Text id="home.headline">Sales training that sticks</Text></h1>
82
+ ```
83
+
84
+ Give each text an id that is unique across your site (`home.headline`, `pricing.cta`), because a running test changes that id wherever it appears. Control never changes text: your defaults stay.
85
+
86
+ With `consent="cookies"` every visitor stays in one variant across visits (the `ck_id` cookie). Cookieless, the variant holds for client-side navigation but can change on a reload, unless you pass your own `distinctId` or call `identify()`. Exposures are recorded as `$exposure` with the experiment and the variant.
87
+
88
+ ## 4. No flicker: render the variant on the server
89
+
90
+ ```tsx
91
+ // app/pricing/page.tsx
92
+ import { cookies } from "next/headers";
93
+ import { CockpitProvider } from "@mrrocket/sdk/next";
94
+ import { getCockpitConfig, getVariant, previewFrom } from "@mrrocket/sdk/next/server";
95
+
96
+ export default async function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
97
+ const sp = await searchParams;
98
+ const config = await getCockpitConfig(); // key and host from the environment; pass { apiKey, host } to override
99
+ const variant = getVariant("rr-pricing-annual-first", { config, searchParams: sp, cookies: await cookies() });
100
+ return (
101
+ <CockpitProvider config={config ?? undefined} {...previewFrom(sp)}>
102
+ {variant === "a" ? <YearlyFirst /> : <MonthlyFirst />}
103
+ </CockpitProvider>
104
+ );
105
+ }
106
+ ```
107
+
108
+ `getCockpitConfig` waits at most 2.5 seconds (`timeoutMs`) and returns `null` when MRRocket is unreachable, slow or answers an error: the page then renders with control variants and never throws.
109
+
110
+ The server needs an id it can read. Cookieless (the default) there is none, so pass your own, for example the signed-in user's id, to both sides: `getVariant(key, { config, searchParams: sp, distinctId: user.id })` and `<CockpitProvider distinctId={user.id}>`.
111
+
112
+ With `consent="cookies"`, give first-time visitors their `ck_id` before the page renders. This writes a cookie, so only do it once the visitor agreed:
113
+
114
+ ```ts
115
+ // proxy.ts (Next.js 16; middleware.ts before)
116
+ import { NextResponse, type NextRequest } from "next/server";
117
+ import { withCockpitId } from "@mrrocket/sdk/next/proxy";
118
+
119
+ export function proxy(request: NextRequest) {
120
+ const response = NextResponse.next();
121
+ withCockpitId(request.cookies, response.cookies);
122
+ return response;
123
+ }
124
+ ```
125
+
126
+ ## 5. Cloudflare Workers (OpenNext)
127
+
128
+ The server helpers (`getCockpitConfig`, `getVariant`, `getTexts`, `previewFrom`) and the proxy helper use only `fetch`, `AbortController` and plain objects, so they run on Node, the Edge runtime and Workers via `@opennextjs/cloudflare`. Put the two variables where `next build` reads them (`.env`, `.env.production`, or your CI environment), because Next.js inlines `NEXT_PUBLIC_*` during the build, not at request time.
129
+
130
+ ## 6. Previews in MRRocket
131
+
132
+ MRRocket shows each variant of your page by loading it with `?ck_preview=<experiment>:<variant>&ck_embed=1`. The SDK renders that variant and tracks nothing while embedded, so previews never count as visits. If your site sends `X-Frame-Options: DENY` or a strict `frame-ancestors`, allow your MRRocket domain.
133
+
134
+ ## 7. Feedback board
135
+
136
+ A Canny-style board for your own `/feedback` page: requests with votes and comments, a roadmap and a changelog. Visitors vote once; a duplicate hint shows similar requests while they type.
137
+
138
+ ```tsx
139
+ // app/feedback/page.tsx
140
+ import { FeedbackBoard } from "@mrrocket/sdk/next";
141
+
142
+ export default function Page() {
143
+ return <FeedbackBoard title="Feedback" theme="auto" accent="#3f76ff" />;
144
+ }
145
+ ```
146
+
147
+ It must sit inside `CockpitProvider`. Call `identify(user.id, { name, email })` and comments and requests carry the user. MRRocket also hosts the same board at `/b/<productId>`.
148
+
149
+ ## 8. Feedback widget
150
+
151
+ A floating button with the top five requests and a short suggest form.
152
+
153
+ ```tsx
154
+ import { FeedbackWidget, useFeedback } from "@mrrocket/sdk/next";
155
+
156
+ <FeedbackWidget position="bottom-right" label="Feedback" boardUrl="/feedback" />;
157
+
158
+ // Open it from your own button:
159
+ const { open, close } = useFeedback();
160
+ <button onClick={open}>Send feedback</button>;
161
+
162
+ // Or with no code at all:
163
+ <a data-ck-feedback>Feedback</a>;
164
+ ```
165
+
166
+ ## 9. Surveys
167
+
168
+ Surveys you build in MRRocket (NPS, rating, choice, text) appear automatically: three seconds after the page opens, a small card shows bottom-left (a bottom sheet on phones). Nothing shows inside MRRocket previews or with `consent="off"`. A dismissed survey stays away for that visitor: across visits with `consent="cookies"` (localStorage), otherwise for the page load.
169
+
170
+ ```tsx
171
+ <CockpitProvider apiKey={key} surveys={false}>{children}</CockpitProvider> // switch surveys off
172
+ ```
173
+
174
+ Widgets style themselves with one scoped stylesheet (class prefix `ckw-`), light and dark by `prefers-color-scheme`. Use `theme="light" | "dark" | "auto"` and `accent` to adjust.
175
+
176
+ ## 10. Affiliate referrals
177
+
178
+ When the product runs an affiliate program in MRRocket, the provider picks up partner referrals by itself: `?mrr_ref=<code>&mrr_click=<id>` from a partner's tracked link (`<mrrocket>/r/<code>`) or `?ref=<code>` from a direct link. It keeps the referral in a first-party cookie `mrr_ref` on your domain plus localStorage (for the program's days, default 60), sends a click beacon for direct links, and adds `$referral: { code, clickId }` to `identify()` and your own events.
179
+
180
+ ```tsx
181
+ import { getReferral, referralForCheckout, referralLink } from "@mrrocket/sdk/next";
182
+
183
+ getReferral(); // { code, clickId, at } | null
184
+ referralForCheckout(); // { client_reference_id: "mrr_ref:<code>:<clickId>", metadata: { mrr_ref, mrr_click } } | null
185
+ referralLink("https://buy.stripe.com/abc"); // a Payment Link with client_reference_id=mrr_ref__<code>__<clickId>
186
+ ```
187
+
188
+ On the server (the route that creates a Stripe Checkout Session):
189
+
190
+ ```ts
191
+ import { readReferralCookie, referralForCheckout } from "@mrrocket/sdk/next/server";
192
+ const ref = referralForCheckout(readReferralCookie((await cookies()).get("mrr_ref")?.value));
193
+ await stripe.checkout.sessions.create({ ...params, ...(ref ?? {}), subscription_data: { metadata: ref?.metadata ?? {} } });
194
+ ```
195
+
196
+ Apps with RevenueCat: set the subscriber attribute `$mrr_ref` (and `$mrr_click`), or give the partner an App Store offer code equal to their code.
197
+
198
+ | Prop | Default | What |
199
+ | --- | --- | --- |
200
+ | `attribution` | `"consent"` | `"consent"`: stored only while `consent="cookies"`, otherwise in memory for the page load; `"always"`: stored whatever the consent (only with a legal basis); `"off"`: referrals ignored |
201
+ | `referralParam` | `"ref"` | the query parameter of direct links |
202
+
203
+ The referral cookie is a marketing cookie: where the law asks for consent (EU, UK), it needs it. Withdrawing consent (leaving `"cookies"`) deletes it.
204
+
205
+ ## Privacy and consent
206
+
207
+ ```tsx
208
+ <CockpitProvider apiKey={key} consent="cookieless" respectDnt>
209
+ ```
210
+
211
+ | `consent` | Stored in the browser | Sent |
212
+ | --- | --- | --- |
213
+ | `"cookieless"` (default) | Nothing: no cookie, no localStorage, no sessionStorage | Events without a visitor id |
214
+ | `"cookies"` | `ck_id` (1 year), `ck_sid` (30 minutes), dismissed surveys, `mrr_ref` (affiliate referral, program days) | Events with `ck_id` as `distinctId` |
215
+ | `"off"` | Nothing | Nothing, and no surveys |
216
+
217
+ - **Cookieless:** the server counts visitors with a hash of key, date, IP and user agent that changes every day, like Plausible; the IP is never stored. A visitor coming back on another day counts again, so retention needs `identify()` or cookies. After `identify(id)` events carry that id. Experiments, surveys and feedback use your `distinctId` prop, else a random id in memory for the page load: variants hold across client-side navigation, not across reloads, and a dismissed survey can come back after a reload.
218
+ - **Changing it:** `const { consent, setConsent } = useCockpit()`. From `"off"` to another mode, tracking starts and the current page view is sent right away. Leaving `"cookies"` deletes `ck_id`, `ck_sid` and the stored survey dismissals; `"off"` also drops unsent events. If the `consent` prop changes, the SDK follows it.
219
+ - **The SDK does not remember the choice.** Your consent banner stores it and calls `setConsent()` on every page load.
220
+ - **`respectDnt`** (default false): when the browser sends Do Not Track or Global Privacy Control, nothing is sent or stored, whatever the consent.
221
+ - **Sessions** end after 30 minutes without an event: in the `ck_sid` cookie with `"cookies"`, in memory otherwise (a reload starts a new session id).
222
+ - Previews inside MRRocket (`ck_embed=1`) track nothing.
223
+
224
+ ## What it sends
225
+
226
+ `POST {host}/api/sdk/events`, batched every two seconds and on page hide, at most 100 events and 64 KB per request:
227
+
228
+ ```ts
229
+ {
230
+ key: string;
231
+ events: {
232
+ event: string;
233
+ distinctId?: string; // ck_id or the identify() id; missing cookieless until identify()
234
+ sessionId?: string;
235
+ props?: Record<string, unknown>; // page views carry { path }
236
+ ts?: number; // ms
237
+ url?: string; // location.href
238
+ referrer?: string; // document.referrer on the first page, then the previous in-app URL
239
+ utm_source?: string; // utm_* from the landing URL, kept for the page load
240
+ utm_medium?: string;
241
+ utm_campaign?: string;
242
+ utm_term?: string;
243
+ utm_content?: string;
244
+ screen?: { w: number; h: number };
245
+ }[];
246
+ }
247
+ ```
248
+
249
+ Answers `202 { ok: true, received, dropped }`; `400` bad body, `404` unknown key, `413` too large, `429` over 600 requests a minute per key. The server adds the country (from `x-vercel-ip-country` or `cf-ipcountry`) and browser, OS and device (from the user agent); the IP only feeds the daily hash. Bot user agents are not stored. `signup`, `checkout` and `purchase` feed Web analytics, `$identify` creates a person in People. Raw events are kept 90 days.
250
+
251
+ `GET {host}/api/sdk/config?key=…` returns the experiments for the key.
package/dist/api.d.ts ADDED
@@ -0,0 +1,113 @@
1
+ export type PostStatus = "review" | "planned" | "progress" | "shipped";
2
+ export declare const STATUS_LABEL: Record<PostStatus, string>;
3
+ export interface FeedbackBoardInfo {
4
+ id: string;
5
+ label: string;
6
+ }
7
+ export interface FeedbackPost {
8
+ id: string;
9
+ title: string;
10
+ body: string;
11
+ board: string;
12
+ status: PostStatus;
13
+ votes: number;
14
+ comments: number;
15
+ voted: boolean;
16
+ createdAt: string;
17
+ }
18
+ export interface FeedbackList {
19
+ boards: FeedbackBoardInfo[];
20
+ posts: FeedbackPost[];
21
+ }
22
+ export interface FeedbackRoadmap {
23
+ columns: {
24
+ planned: FeedbackPost[];
25
+ progress: FeedbackPost[];
26
+ shipped: FeedbackPost[];
27
+ };
28
+ }
29
+ export interface ChangelogEntry {
30
+ id: string;
31
+ title: string;
32
+ body: string;
33
+ publishedAt: string;
34
+ }
35
+ export interface FeedbackComment {
36
+ id: string;
37
+ body: string;
38
+ author: string;
39
+ isTeam: boolean;
40
+ postedAt: string;
41
+ }
42
+ export type SurveyQuestionKind = "nps" | "rating" | "single" | "multi" | "text";
43
+ export interface SurveyQuestion {
44
+ key: string;
45
+ kind: SurveyQuestionKind;
46
+ title: string;
47
+ description?: string;
48
+ required: boolean;
49
+ options?: string[];
50
+ scale?: number;
51
+ low?: string;
52
+ high?: string;
53
+ placeholder?: string;
54
+ }
55
+ export interface SurveyDefinition {
56
+ id: string;
57
+ name: string;
58
+ type: string;
59
+ thanks: string;
60
+ questions: SurveyQuestion[];
61
+ }
62
+ export type SurveyAnswer = number | string | string[];
63
+ export declare class ApiError extends Error {
64
+ status: number;
65
+ constructor(message: string, status: number);
66
+ }
67
+ interface Conn {
68
+ host: string;
69
+ apiKey: string;
70
+ distinctId: string;
71
+ user?: {
72
+ id: string;
73
+ [key: string]: unknown;
74
+ };
75
+ }
76
+ /** The small fetch client behind the widgets. Every read takes an AbortSignal so unmounting cancels it. */
77
+ export declare const createApi: (c: Conn) => {
78
+ list: (o: {
79
+ board?: string;
80
+ sort?: "top" | "new";
81
+ q?: string;
82
+ }, signal?: AbortSignal) => Promise<FeedbackList>;
83
+ roadmap: (signal?: AbortSignal) => Promise<FeedbackRoadmap>;
84
+ changelog: (signal?: AbortSignal) => Promise<{
85
+ entries: ChangelogEntry[];
86
+ }>;
87
+ submit: (p: {
88
+ title: string;
89
+ body?: string;
90
+ board: string;
91
+ }) => Promise<FeedbackPost>;
92
+ vote: (id: string) => Promise<{
93
+ voted: boolean;
94
+ votes: number;
95
+ }>;
96
+ comments: (id: string, signal?: AbortSignal) => Promise<{
97
+ comments: FeedbackComment[];
98
+ }>;
99
+ comment: (id: string, body: string) => Promise<FeedbackComment>;
100
+ survey: (url: string, signal?: AbortSignal) => Promise<{
101
+ survey: SurveyDefinition | null;
102
+ }>;
103
+ seen: (id: string) => Promise<unknown>;
104
+ respond: (id: string, r: {
105
+ answers: Record<string, SurveyAnswer>;
106
+ completed: boolean;
107
+ seconds: number;
108
+ }) => Promise<unknown>;
109
+ };
110
+ export type Api = ReturnType<typeof createApi>;
111
+ /** The client for the provider's key, host and visitor. */
112
+ export declare const useApi: () => Api;
113
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,71 @@
1
+ // src/api.ts
2
+ import { useMemo } from "react";
3
+ import { useCtx } from "./context.js";
4
+ var STATUS_LABEL = {
5
+ review: "Under review",
6
+ planned: "Planned",
7
+ progress: "In progress",
8
+ shipped: "Shipped"
9
+ };
10
+ var ApiError = class extends Error {
11
+ status;
12
+ constructor(message, status) {
13
+ super(message);
14
+ this.status = status;
15
+ }
16
+ };
17
+ var qs = (params) => {
18
+ const p = new URLSearchParams();
19
+ for (const [k, v] of Object.entries(params)) if (v) p.set(k, v);
20
+ return p.toString();
21
+ };
22
+ var call = async (url, init, signal) => {
23
+ let res;
24
+ try {
25
+ res = await fetch(url, {
26
+ ...init,
27
+ signal,
28
+ headers: init?.body ? { "content-type": "application/json" } : void 0
29
+ });
30
+ } catch (e) {
31
+ if (e.name === "AbortError") throw e;
32
+ throw new ApiError("Could not reach the server. Check your connection and try again.", 0);
33
+ }
34
+ if (!res.ok) {
35
+ let detail = "";
36
+ try {
37
+ detail = (await res.json()).error ?? "";
38
+ } catch {
39
+ }
40
+ throw new ApiError(detail || (res.status === 429 ? "Too many requests. Try again in a minute." : "Something went wrong. Please try again."), res.status);
41
+ }
42
+ return await res.json();
43
+ };
44
+ var createApi = (c) => {
45
+ const base = `${c.host}/api/sdk`;
46
+ const who = { key: c.apiKey, distinctId: c.distinctId, user: c.user };
47
+ const post = (path, body, signal) => call(`${base}${path}`, { method: "POST", body: JSON.stringify({ ...who, ...body }) }, signal);
48
+ return {
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
+ roadmap: (signal) => call(`${base}/feedback/roadmap?${qs({ key: c.apiKey })}`, void 0, signal),
51
+ changelog: (signal) => call(`${base}/feedback/changelog?${qs({ key: c.apiKey })}`, void 0, signal),
52
+ submit: (p) => post("/feedback", p),
53
+ vote: (id) => post(`/feedback/${encodeURIComponent(id)}/vote`, {}),
54
+ comments: (id, signal) => call(`${base}/feedback/${encodeURIComponent(id)}/comments?${qs({ key: c.apiKey })}`, void 0, signal),
55
+ comment: (id, body) => post(`/feedback/${encodeURIComponent(id)}/comments`, { body }),
56
+ survey: (url, signal) => call(`${base}/surveys?${qs({ key: c.apiKey, distinctId: c.distinctId, url })}`, void 0, signal),
57
+ seen: (id) => post(`/surveys/${encodeURIComponent(id)}/seen`, { user: void 0 }),
58
+ respond: (id, r) => post(`/surveys/${encodeURIComponent(id)}/respond`, r)
59
+ };
60
+ };
61
+ var useApi = () => {
62
+ const { apiKey, host, distinctId, user } = useCtx();
63
+ return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);
64
+ };
65
+ export {
66
+ ApiError,
67
+ STATUS_LABEL,
68
+ createApi,
69
+ useApi
70
+ };
71
+ //# sourceMappingURL=api.js.map
@@ -0,0 +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: 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 roadmap: (signal?: AbortSignal) => call<FeedbackRoadmap>(`${base}/feedback/roadmap?${qs({ key: c.apiKey })}`, 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/** The client for the provider's key, host and visitor. */\nexport const useApi = (): Api => {\n const { apiKey, host, distinctId, user } = useCtx();\n return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);\n};\n"],"mappings":"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","names":[]}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Everything of the SDK that needs the browser or React state. It is its own file with the "use client" directive so
3
+ * that the build keeps one client chunk: server components import these as client references, while the plain
4
+ * helpers (`core`, `server`, `proxy`) stay free of the directive.
5
+ */
6
+ export { CockpitProvider, Experiment, Text, useCockpit, useVariant, type CockpitProviderProps } from "./client.js";
7
+ export { FeedbackBoard, FeedbackWidget, useFeedback, type FeedbackBoardProps, type FeedbackWidgetProps } from "./feedback.js";
8
+ export { Survey } from "./survey.js";
9
+ export type { CockpitUser } from "./context.js";
10
+ export { type ChangelogEntry, type FeedbackBoardInfo, type FeedbackComment, type FeedbackList, type FeedbackPost, type FeedbackRoadmap, type PostStatus, type SurveyAnswer, type SurveyDefinition, type SurveyQuestion, type SurveyQuestionKind, createApi, } from "./api.js";
11
+ export { CSS as WIDGET_CSS, useStyles } from "./styles.js";
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ // src/browser.ts
4
+ import { CockpitProvider, Experiment, Text, useCockpit, useVariant } from "./client.js";
5
+ import { FeedbackBoard, FeedbackWidget, useFeedback } from "./feedback.js";
6
+ import { Survey } from "./survey.js";
7
+ import {
8
+ createApi
9
+ } from "./api.js";
10
+ import { CSS, useStyles } from "./styles.js";
11
+ export {
12
+ CockpitProvider,
13
+ Experiment,
14
+ FeedbackBoard,
15
+ FeedbackWidget,
16
+ Survey,
17
+ Text,
18
+ CSS as WIDGET_CSS,
19
+ createApi,
20
+ useCockpit,
21
+ useFeedback,
22
+ useStyles,
23
+ useVariant
24
+ };
25
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Everything of the SDK that needs the browser or React state. It is its own file with the \"use client\" directive so\n * that the build keeps one client chunk: server components import these as client references, while the plain\n * helpers (`core`, `server`, `proxy`) stay free of the directive.\n */\nexport { CockpitProvider, Experiment, Text, useCockpit, useVariant, type CockpitProviderProps } from \"./client\";\nexport { FeedbackBoard, FeedbackWidget, useFeedback, type FeedbackBoardProps, type FeedbackWidgetProps } from \"./feedback\";\nexport { Survey } from \"./survey\";\nexport type { CockpitUser } from \"./context\";\nexport {\n type ChangelogEntry,\n type FeedbackBoardInfo,\n type FeedbackComment,\n type FeedbackList,\n type FeedbackPost,\n type FeedbackRoadmap,\n type PostStatus,\n type SurveyAnswer,\n type SurveyDefinition,\n type SurveyQuestion,\n type SurveyQuestionKind,\n createApi,\n} from \"./api\";\nexport { CSS as WIDGET_CSS, useStyles } from \"./styles\";\n"],"mappings":";;;AAOA,SAAS,iBAAiB,YAAY,MAAM,YAAY,kBAA6C;AACrG,SAAS,eAAe,gBAAgB,mBAAsE;AAC9G,SAAS,cAAc;AAEvB;AAAA,EAYI;AAAA,OACG;AACP,SAAgB,KAAY,iBAAiB;","names":[]}
@@ -0,0 +1,68 @@
1
+ import { type ReactNode } from "react";
2
+ import { type AttributionMode } from "./referral.js";
3
+ import { type CockpitConfig, type ConsentMode, type VariantId } from "./core.js";
4
+ export interface CockpitProviderProps {
5
+ /** The product's public key from Cockpit › Settings › SDK. Safe in the browser. Default: `NEXT_PUBLIC_MRROCKET_KEY`. */
6
+ apiKey?: string;
7
+ /** Where MRRocket runs, like `https://app.example.com`. Default: `NEXT_PUBLIC_MRROCKET_HOST`, else the page's own origin. */
8
+ host?: string;
9
+ preview?: Record<string, VariantId>;
10
+ embedded?: boolean;
11
+ /** Config fetched on the server, to render the right variant on the first paint. */
12
+ config?: CockpitConfig;
13
+ /** Your own id for this visitor, for example a signed-in user id. Keeps variants stable without cookies. */
14
+ distinctId?: string;
15
+ /**
16
+ * "cookieless" (default) stores nothing in the browser, "cookies" keeps a visitor cookie for a year, "off" sends
17
+ * nothing. The SDK does not remember the choice: set it on every page load, from the prop or setConsent().
18
+ */
19
+ consent?: ConsentMode;
20
+ /** Send nothing when the browser asks not to be tracked (Do Not Track or Global Privacy Control). */
21
+ respectDnt?: boolean;
22
+ /** Ask visitors the surveys you run in Cockpit. Set false to switch them off. */
23
+ surveys?: boolean;
24
+ /**
25
+ * Affiliate referrals (`?ref=` links and MRRocket's tracked links). "consent" (default): stored in the first-party
26
+ * `mrr_ref` cookie only while consent is "cookies", in memory otherwise. "always": stored whatever the consent (only
27
+ * where your legal basis allows it). "off": referrals are ignored.
28
+ */
29
+ attribution?: AttributionMode;
30
+ /** The query parameter of direct partner links, default `ref` (`https://your.site/?ref=maya`). */
31
+ referralParam?: string;
32
+ children: ReactNode;
33
+ }
34
+ /**
35
+ * Put it once around your app, in `app/layout.tsx`. It loads your experiments, keeps each visitor in one variant,
36
+ * records page views and sends your events to Cockpit in small batches.
37
+ *
38
+ * `preview` forces variants for this render, for example from the `ck_preview` search param on the server,
39
+ * so a Cockpit preview never flickers. Without it the provider reads the param itself after the first render.
40
+ */
41
+ export declare const CockpitProvider: ({ apiKey: apiKeyProp, host: hostProp, preview: forced, embedded: embeddedProp, config: bootstrapConfig, distinctId: bootstrapId, consent: consentProp, respectDnt, surveys, attribution, referralParam, children, }: CockpitProviderProps) => import("react").JSX.Element;
42
+ /** `track`, `identify`, the consent mode and its setter, the visitor id and whether the experiments have loaded. */
43
+ export declare const useCockpit: () => {
44
+ track: (event: string, props?: Record<string, unknown>) => void;
45
+ identify: (id: string, props?: Record<string, unknown>) => void;
46
+ distinctId: string;
47
+ ready: boolean;
48
+ embedded: boolean;
49
+ consent: ConsentMode;
50
+ setConsent: (mode: ConsentMode) => void;
51
+ };
52
+ /** The variant this visitor sees, for example `"control"` or `"a"`. Records the exposure. */
53
+ export declare const useVariant: (key: string) => VariantId;
54
+ /** Renders the element for the visitor's variant; control when the variant has no element. */
55
+ export declare const Experiment: ({ name, variants }: {
56
+ name: string;
57
+ variants: Record<VariantId, ReactNode> & {
58
+ control: ReactNode;
59
+ };
60
+ }) => import("react").JSX.Element;
61
+ /**
62
+ * A text that experiments may change without a deploy: Cockpit sends the new copy for `id` and this renders it.
63
+ * Write your default as the children.
64
+ */
65
+ export declare const Text: ({ id, children }: {
66
+ id: string;
67
+ children: string;
68
+ }) => import("react").JSX.Element;