@businessdash/sdk 0.9.70 → 0.9.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -165
- package/dist/client.d.ts +56 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +82 -1
- package/dist/client.js.map +1 -1
- package/dist/consent-core/index.cjs +107 -0
- package/dist/consent-core/index.d.ts +103 -0
- package/dist/consent-core/index.d.ts.map +1 -0
- package/dist/consent-core/index.js +157 -0
- package/dist/consent-core/index.js.map +1 -0
- package/dist/contracts.d.ts +221 -10
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +30 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.cjs +92 -0
- package/openapi.json +202 -0
- package/package.json +8 -3
- package/src/client.ts +99 -0
- package/src/consent-core/index.ts +213 -0
- package/src/contracts.ts +38 -0
package/openapi.json
CHANGED
|
@@ -4785,6 +4785,113 @@
|
|
|
4785
4785
|
"additionalProperties": false,
|
|
4786
4786
|
"nullable": true
|
|
4787
4787
|
},
|
|
4788
|
+
"customerPortalQuizResultsResponseSchema": {
|
|
4789
|
+
"type": "object",
|
|
4790
|
+
"properties": {
|
|
4791
|
+
"items": {
|
|
4792
|
+
"type": "array",
|
|
4793
|
+
"items": {
|
|
4794
|
+
"type": "object",
|
|
4795
|
+
"properties": {
|
|
4796
|
+
"invitationId": {
|
|
4797
|
+
"type": "string"
|
|
4798
|
+
},
|
|
4799
|
+
"quizName": {
|
|
4800
|
+
"type": "string"
|
|
4801
|
+
},
|
|
4802
|
+
"competency": {
|
|
4803
|
+
"type": "string",
|
|
4804
|
+
"nullable": true
|
|
4805
|
+
},
|
|
4806
|
+
"status": {
|
|
4807
|
+
"type": "string"
|
|
4808
|
+
},
|
|
4809
|
+
"completedAt": {
|
|
4810
|
+
"type": "string",
|
|
4811
|
+
"nullable": true
|
|
4812
|
+
},
|
|
4813
|
+
"scoreWithheld": {
|
|
4814
|
+
"type": "boolean"
|
|
4815
|
+
},
|
|
4816
|
+
"result": {
|
|
4817
|
+
"type": "object",
|
|
4818
|
+
"properties": {
|
|
4819
|
+
"status": {
|
|
4820
|
+
"type": "string"
|
|
4821
|
+
},
|
|
4822
|
+
"scorePercent": {
|
|
4823
|
+
"type": "number",
|
|
4824
|
+
"nullable": true
|
|
4825
|
+
},
|
|
4826
|
+
"provisionalScorePercent": {
|
|
4827
|
+
"type": "number",
|
|
4828
|
+
"nullable": true
|
|
4829
|
+
},
|
|
4830
|
+
"passed": {
|
|
4831
|
+
"type": "boolean",
|
|
4832
|
+
"nullable": true
|
|
4833
|
+
},
|
|
4834
|
+
"pointsEarned": {
|
|
4835
|
+
"type": "number",
|
|
4836
|
+
"nullable": true
|
|
4837
|
+
},
|
|
4838
|
+
"pointsPossible": {
|
|
4839
|
+
"type": "number",
|
|
4840
|
+
"nullable": true
|
|
4841
|
+
},
|
|
4842
|
+
"passingScore": {
|
|
4843
|
+
"type": "number"
|
|
4844
|
+
},
|
|
4845
|
+
"results": {
|
|
4846
|
+
"type": "array",
|
|
4847
|
+
"items": {
|
|
4848
|
+
"type": "object",
|
|
4849
|
+
"additionalProperties": {}
|
|
4850
|
+
}
|
|
4851
|
+
},
|
|
4852
|
+
"submittedAt": {
|
|
4853
|
+
"type": "string",
|
|
4854
|
+
"nullable": true
|
|
4855
|
+
},
|
|
4856
|
+
"durationSeconds": {
|
|
4857
|
+
"type": "number",
|
|
4858
|
+
"nullable": true
|
|
4859
|
+
}
|
|
4860
|
+
},
|
|
4861
|
+
"required": [
|
|
4862
|
+
"status",
|
|
4863
|
+
"scorePercent",
|
|
4864
|
+
"provisionalScorePercent",
|
|
4865
|
+
"passed",
|
|
4866
|
+
"pointsEarned",
|
|
4867
|
+
"pointsPossible",
|
|
4868
|
+
"passingScore",
|
|
4869
|
+
"results",
|
|
4870
|
+
"submittedAt",
|
|
4871
|
+
"durationSeconds"
|
|
4872
|
+
],
|
|
4873
|
+
"additionalProperties": false,
|
|
4874
|
+
"nullable": true
|
|
4875
|
+
}
|
|
4876
|
+
},
|
|
4877
|
+
"required": [
|
|
4878
|
+
"invitationId",
|
|
4879
|
+
"quizName",
|
|
4880
|
+
"competency",
|
|
4881
|
+
"status",
|
|
4882
|
+
"completedAt",
|
|
4883
|
+
"scoreWithheld",
|
|
4884
|
+
"result"
|
|
4885
|
+
],
|
|
4886
|
+
"additionalProperties": false
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
},
|
|
4890
|
+
"required": [
|
|
4891
|
+
"items"
|
|
4892
|
+
],
|
|
4893
|
+
"additionalProperties": false
|
|
4894
|
+
},
|
|
4788
4895
|
"customerPortalUpdateProfileInputSchema": {
|
|
4789
4896
|
"type": "object",
|
|
4790
4897
|
"properties": {
|
|
@@ -12506,6 +12613,66 @@
|
|
|
12506
12613
|
]
|
|
12507
12614
|
}
|
|
12508
12615
|
},
|
|
12616
|
+
"/consent": {
|
|
12617
|
+
"get": {
|
|
12618
|
+
"operationId": "get_consent",
|
|
12619
|
+
"parameters": [],
|
|
12620
|
+
"responses": {
|
|
12621
|
+
"200": {
|
|
12622
|
+
"description": "OK",
|
|
12623
|
+
"content": {
|
|
12624
|
+
"application/json": {
|
|
12625
|
+
"schema": {
|
|
12626
|
+
"type": "object"
|
|
12627
|
+
}
|
|
12628
|
+
}
|
|
12629
|
+
}
|
|
12630
|
+
},
|
|
12631
|
+
"402": {
|
|
12632
|
+
"description": "Access gate (plan/payment/suspension)"
|
|
12633
|
+
}
|
|
12634
|
+
},
|
|
12635
|
+
"security": [
|
|
12636
|
+
{
|
|
12637
|
+
"bearerAuth": []
|
|
12638
|
+
}
|
|
12639
|
+
]
|
|
12640
|
+
},
|
|
12641
|
+
"post": {
|
|
12642
|
+
"operationId": "post_consent",
|
|
12643
|
+
"parameters": [],
|
|
12644
|
+
"responses": {
|
|
12645
|
+
"200": {
|
|
12646
|
+
"description": "OK",
|
|
12647
|
+
"content": {
|
|
12648
|
+
"application/json": {
|
|
12649
|
+
"schema": {
|
|
12650
|
+
"type": "object"
|
|
12651
|
+
}
|
|
12652
|
+
}
|
|
12653
|
+
}
|
|
12654
|
+
},
|
|
12655
|
+
"402": {
|
|
12656
|
+
"description": "Access gate (plan/payment/suspension)"
|
|
12657
|
+
}
|
|
12658
|
+
},
|
|
12659
|
+
"security": [
|
|
12660
|
+
{
|
|
12661
|
+
"bearerAuth": []
|
|
12662
|
+
}
|
|
12663
|
+
],
|
|
12664
|
+
"requestBody": {
|
|
12665
|
+
"required": true,
|
|
12666
|
+
"content": {
|
|
12667
|
+
"application/json": {
|
|
12668
|
+
"schema": {
|
|
12669
|
+
"$ref": "#/components/schemas/blogCommentPostInputSchema"
|
|
12670
|
+
}
|
|
12671
|
+
}
|
|
12672
|
+
}
|
|
12673
|
+
}
|
|
12674
|
+
}
|
|
12675
|
+
},
|
|
12509
12676
|
"/coupons": {
|
|
12510
12677
|
"post": {
|
|
12511
12678
|
"operationId": "post_coupons",
|
|
@@ -13824,6 +13991,41 @@
|
|
|
13824
13991
|
}
|
|
13825
13992
|
}
|
|
13826
13993
|
},
|
|
13994
|
+
"/customer-portal/quiz-results": {
|
|
13995
|
+
"get": {
|
|
13996
|
+
"operationId": "get_customer_portal_quiz_results",
|
|
13997
|
+
"parameters": [
|
|
13998
|
+
{
|
|
13999
|
+
"name": "token",
|
|
14000
|
+
"in": "query",
|
|
14001
|
+
"required": false,
|
|
14002
|
+
"schema": {
|
|
14003
|
+
"type": "string"
|
|
14004
|
+
}
|
|
14005
|
+
}
|
|
14006
|
+
],
|
|
14007
|
+
"responses": {
|
|
14008
|
+
"200": {
|
|
14009
|
+
"description": "OK",
|
|
14010
|
+
"content": {
|
|
14011
|
+
"application/json": {
|
|
14012
|
+
"schema": {
|
|
14013
|
+
"$ref": "#/components/schemas/customerPortalQuizResultsResponseSchema"
|
|
14014
|
+
}
|
|
14015
|
+
}
|
|
14016
|
+
}
|
|
14017
|
+
},
|
|
14018
|
+
"402": {
|
|
14019
|
+
"description": "Access gate (plan/payment/suspension)"
|
|
14020
|
+
}
|
|
14021
|
+
},
|
|
14022
|
+
"security": [
|
|
14023
|
+
{
|
|
14024
|
+
"bearerAuth": []
|
|
14025
|
+
}
|
|
14026
|
+
]
|
|
14027
|
+
}
|
|
14028
|
+
},
|
|
13827
14029
|
"/customer-portal/quotes": {
|
|
13828
14030
|
"get": {
|
|
13829
14031
|
"operationId": "get_customer_portal_quotes",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@businessdash/sdk",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.80",
|
|
4
4
|
"description": "BusinessDash developer SDK \u2014 the data layer that lets you build customer-facing surfaces on your own domain while BusinessDash handles the operations stack behind the scenes. 0.9.x ships a comprehensive programmatic surface: storefront (products, categories, cart, checkout via Stripe Connect with Link payment), customer portal (work feed, per-item detail, quote accept/reject, job activity, comment threads, reviews, notification preferences), blog (posts, categories, threaded comments, reactions, paywalls, Mux video), Front Desk chat (sessions, AI bot, live human handoff, queue + place-in-line), coupons (validation against synced Stripe Promotion Codes \u2014 BOGO/BXGY/free-shipping kinds), shipping (multi-carrier rates + tracking via Shippo), address validation (Google Places, platform-proxied), customer notification preferences across in-app/email/SMS/webhook, subscriptions admin + Stripe sync, and followers/subscribers. Also: tenant sign-in/up/out via the auth handler, schema-driven marketing pages with restore semantics, typed JSON-LD SEO builders (LocalBusiness/Service/FAQ/Article/Breadcrumb/Review), and a CLI (`biab-dev sync-schema`) for the schema flow. The consumer renders the UI in their own design system on their own domain; BusinessDash supplies the data, the business logic, and the Stripe/Mux/Google/Shippo plumbing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"module": "./dist/index.js",
|
|
15
15
|
"types": "./dist/index.d.ts",
|
|
16
16
|
"bin": {
|
|
17
|
-
"biab-dev": "
|
|
18
|
-
"businessdash": "
|
|
17
|
+
"biab-dev": "dist/cli.js",
|
|
18
|
+
"businessdash": "dist/cli.js"
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
21
|
".": {
|
|
@@ -221,6 +221,11 @@
|
|
|
221
221
|
"import": "./dist/model3d-core/index.js",
|
|
222
222
|
"require": "./dist/model3d-core/index.cjs"
|
|
223
223
|
},
|
|
224
|
+
"./consent": {
|
|
225
|
+
"types": "./dist/consent-core/index.d.ts",
|
|
226
|
+
"import": "./dist/consent-core/index.js",
|
|
227
|
+
"require": "./dist/consent-core/index.cjs"
|
|
228
|
+
},
|
|
224
229
|
"./biab-sw.js": "./assets/biab-sw.js"
|
|
225
230
|
},
|
|
226
231
|
"scripts": {
|
package/src/client.ts
CHANGED
|
@@ -111,6 +111,7 @@ import {
|
|
|
111
111
|
type CustomerPortalMessagesMarkReadResponse,
|
|
112
112
|
type CustomerPortalOtherOrgsResponse,
|
|
113
113
|
type CustomerPortalProfile,
|
|
114
|
+
type CustomerPortalQuizResultsResponse,
|
|
114
115
|
type CustomerPortalUpdateProfileInput,
|
|
115
116
|
type CustomerPortalUpdateProfileResponse,
|
|
116
117
|
type CustomerProductReviewCreated,
|
|
@@ -204,6 +205,7 @@ import {
|
|
|
204
205
|
customerPortalMessagesMarkReadResponseSchema,
|
|
205
206
|
customerPortalOtherOrgsResponseSchema,
|
|
206
207
|
customerPortalProfileSchema,
|
|
208
|
+
customerPortalQuizResultsResponseSchema,
|
|
207
209
|
customerPortalUpdateProfileInputSchema,
|
|
208
210
|
customerPortalUpdateProfileResponseSchema,
|
|
209
211
|
customerProductReviewCreatedSchema,
|
|
@@ -593,6 +595,16 @@ export class BiabDevClient {
|
|
|
593
595
|
return new BiabDevReviewsClient(this);
|
|
594
596
|
}
|
|
595
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Tracking consent for this org's site.
|
|
600
|
+
*
|
|
601
|
+
* Pair with `@businessdash/sdk/consent`, which handles the visitor cookie
|
|
602
|
+
* and gives you a gate to check before starting analytics.
|
|
603
|
+
*/
|
|
604
|
+
get consent() {
|
|
605
|
+
return new BiabDevConsentClient(this);
|
|
606
|
+
}
|
|
607
|
+
|
|
596
608
|
/** Public job postings — build a careers page on your own domain. */
|
|
597
609
|
get hiring() {
|
|
598
610
|
return new BiabDevHiringClient(this);
|
|
@@ -2827,6 +2839,73 @@ export class BiabDevLegalClient {
|
|
|
2827
2839
|
}
|
|
2828
2840
|
}
|
|
2829
2841
|
|
|
2842
|
+
const consentStateSchema = z.object({
|
|
2843
|
+
/** Categories this org uses at all — one banner row each. */
|
|
2844
|
+
categories: z.array(z.string()),
|
|
2845
|
+
noticeText: z.string().nullable(),
|
|
2846
|
+
noticeVersion: z.number(),
|
|
2847
|
+
/** What may be used right now. */
|
|
2848
|
+
allowed: z.array(z.string()),
|
|
2849
|
+
mustAsk: z.boolean(),
|
|
2850
|
+
reason: z.string(),
|
|
2851
|
+
});
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* Tracking consent, scoped to the calling org.
|
|
2855
|
+
*
|
|
2856
|
+
* Every call is answered for the org whose key made it. There is no shape here
|
|
2857
|
+
* that could read or write another org's consent — consent is given to a
|
|
2858
|
+
* controller, and one controller's record is not another's to act on.
|
|
2859
|
+
*
|
|
2860
|
+
* The visitor is identified by an `x-biab-visitor` header carrying an opaque id
|
|
2861
|
+
* from the caller's own first-party cookie. `@businessdash/sdk/consent` manages
|
|
2862
|
+
* that for you; these methods are the transport underneath it.
|
|
2863
|
+
*/
|
|
2864
|
+
export class BiabDevConsentClient {
|
|
2865
|
+
constructor(private readonly client: BiabDevClient) {}
|
|
2866
|
+
|
|
2867
|
+
/** The notice to show, and what this visitor currently allows. */
|
|
2868
|
+
async get(visitorKey?: string | null): Promise<{
|
|
2869
|
+
categories: string[];
|
|
2870
|
+
noticeText: string | null;
|
|
2871
|
+
noticeVersion: number;
|
|
2872
|
+
allowed: string[];
|
|
2873
|
+
mustAsk: boolean;
|
|
2874
|
+
reason: string;
|
|
2875
|
+
}> {
|
|
2876
|
+
return this.client.request({
|
|
2877
|
+
path: "consent",
|
|
2878
|
+
responseSchema: consentStateSchema,
|
|
2879
|
+
...(visitorKey ? { headers: { "x-biab-visitor": visitorKey } } : {}),
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/**
|
|
2884
|
+
* Record the visitor's answer.
|
|
2885
|
+
*
|
|
2886
|
+
* `granted: []` is a real answer — "asked, refused everything" — and is
|
|
2887
|
+
* stored as one, so the banner does not reappear on the next page.
|
|
2888
|
+
*/
|
|
2889
|
+
async record(input: {
|
|
2890
|
+
visitorKey: string;
|
|
2891
|
+
granted: string[];
|
|
2892
|
+
noticeVersion: number;
|
|
2893
|
+
source?: "banner" | "settings";
|
|
2894
|
+
}): Promise<{ ok: boolean }> {
|
|
2895
|
+
return this.client.request({
|
|
2896
|
+
method: "POST",
|
|
2897
|
+
path: "consent",
|
|
2898
|
+
body: {
|
|
2899
|
+
granted: input.granted,
|
|
2900
|
+
noticeVersion: input.noticeVersion,
|
|
2901
|
+
source: input.source ?? "banner",
|
|
2902
|
+
},
|
|
2903
|
+
headers: { "x-biab-visitor": input.visitorKey },
|
|
2904
|
+
responseSchema: z.object({ ok: z.boolean() }),
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2830
2909
|
export class BiabDevChatbotClient {
|
|
2831
2910
|
constructor(private readonly client: BiabDevClient) {}
|
|
2832
2911
|
|
|
@@ -3190,6 +3269,26 @@ export class BiabDevCustomerPortalClient {
|
|
|
3190
3269
|
});
|
|
3191
3270
|
}
|
|
3192
3271
|
|
|
3272
|
+
/**
|
|
3273
|
+
* The signed-in customer's quiz results at this org — the SDK side of
|
|
3274
|
+
* the account-gated reveal: an invitation completed with "create an
|
|
3275
|
+
* account to see how you did" surfaces its result here (and only to
|
|
3276
|
+
* the invited email). Interview-mode quizzes come back with
|
|
3277
|
+
* `scoreWithheld: true` and no verdict, by the org's own setting.
|
|
3278
|
+
*/
|
|
3279
|
+
async myQuizResults(options?: {
|
|
3280
|
+
token?: string;
|
|
3281
|
+
}): Promise<CustomerPortalQuizResultsResponse> {
|
|
3282
|
+
const params = new URLSearchParams();
|
|
3283
|
+
if (options?.token) params.set("token", options.token);
|
|
3284
|
+
const qs = params.toString();
|
|
3285
|
+
return this.client.request({
|
|
3286
|
+
path: `customer-portal/quiz-results${qs ? `?${qs}` : ""}`,
|
|
3287
|
+
responseSchema: customerPortalQuizResultsResponseSchema,
|
|
3288
|
+
headers: this.headers(),
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3193
3292
|
async updateProfile(
|
|
3194
3293
|
input: CustomerPortalUpdateProfileInput,
|
|
3195
3294
|
): Promise<CustomerPortalUpdateProfileResponse> {
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@businessdash/sdk/consent` — tracking consent, per org.
|
|
3
|
+
*
|
|
4
|
+
* ## The rule that shapes everything here
|
|
5
|
+
*
|
|
6
|
+
* Consent is given to a **controller**, and each org is a separate controller.
|
|
7
|
+
* It does not travel between them in either direction:
|
|
8
|
+
*
|
|
9
|
+
* - A visitor who refuses at org 2 has said nothing to org 1. Org 1 keeps
|
|
10
|
+
* collecting.
|
|
11
|
+
* - A visitor who consents at org 1 has NOT consented at org 2. Org 2 may not
|
|
12
|
+
* start collecting on the strength of it.
|
|
13
|
+
*
|
|
14
|
+
* The second half is the one that gets skipped, and it is the one with legal
|
|
15
|
+
* teeth: acting on another controller's consent is processing without a lawful
|
|
16
|
+
* basis.
|
|
17
|
+
*
|
|
18
|
+
* That is why the visitor key lives in a **first-party cookie on the org's own
|
|
19
|
+
* domain**, and why every call carries the org's own API key. Two sites cannot
|
|
20
|
+
* see each other's cookies, so the isolation is enforced by the browser rather
|
|
21
|
+
* than by us remembering to scope a query.
|
|
22
|
+
*
|
|
23
|
+
* ## Gate analytics on it
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const consent = await loadConsent({ client });
|
|
27
|
+
* if (consent.allows('analytics')) {
|
|
28
|
+
* initBiabAnalytics({ siteId, baseUrl, apiKey });
|
|
29
|
+
* }
|
|
30
|
+
* if (consent.mustAsk) showYourBanner(consent);
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* `initBiabAnalytics` is deliberately NOT made to call this itself. A gate that
|
|
34
|
+
* fires implicitly is one nobody can see in review, and the decision of what
|
|
35
|
+
* happens before consent — render nothing, render a placeholder, collect
|
|
36
|
+
* essential-only — belongs to the site, not to us.
|
|
37
|
+
*
|
|
38
|
+
* @module
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export type ConsentCategory = "analytics" | "marketing" | "personalisation";
|
|
42
|
+
|
|
43
|
+
/** Why consent resolved the way it did. Useful in an org's audit view. */
|
|
44
|
+
export type ConsentReason =
|
|
45
|
+
| "browser_signal"
|
|
46
|
+
| "explicit"
|
|
47
|
+
| "stale_notice"
|
|
48
|
+
| "default_allow"
|
|
49
|
+
| "default_deny";
|
|
50
|
+
|
|
51
|
+
export type ConsentState = {
|
|
52
|
+
/** Categories this org uses at all. Render one banner row per entry. */
|
|
53
|
+
categories: ConsentCategory[];
|
|
54
|
+
noticeText: string | null;
|
|
55
|
+
noticeVersion: number;
|
|
56
|
+
/** What may be used right now. */
|
|
57
|
+
allowed: ConsentCategory[];
|
|
58
|
+
/** True when the visitor has not answered and a banner should show. */
|
|
59
|
+
mustAsk: boolean;
|
|
60
|
+
reason: ConsentReason;
|
|
61
|
+
/** `allowed.includes(category)`, spelled as a question. */
|
|
62
|
+
allows(category: ConsentCategory): boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The subset of the SDK client this needs. */
|
|
66
|
+
export type ConsentClientLike = {
|
|
67
|
+
request(input: {
|
|
68
|
+
method?: string;
|
|
69
|
+
path: string;
|
|
70
|
+
body?: unknown;
|
|
71
|
+
headers?: Record<string, string>;
|
|
72
|
+
responseSchema: unknown;
|
|
73
|
+
}): Promise<unknown>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const VISITOR_COOKIE = "biab_visitor";
|
|
77
|
+
const VISITOR_HEADER = "x-biab-visitor";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The visitor's id for THIS site, minted on first need.
|
|
81
|
+
*
|
|
82
|
+
* A first-party cookie on the org's own domain, which is what makes the
|
|
83
|
+
* per-org isolation structural: the same person visiting two orgs gets two
|
|
84
|
+
* unrelated keys, because two domains cannot read each other's cookies. There
|
|
85
|
+
* is no cross-site identifier here and deliberately no way to add one.
|
|
86
|
+
*
|
|
87
|
+
* Returns null on the server, where there is no visitor to identify.
|
|
88
|
+
*/
|
|
89
|
+
export function visitorKey(): string | null {
|
|
90
|
+
if (typeof document === "undefined") return null;
|
|
91
|
+
|
|
92
|
+
const found = document.cookie
|
|
93
|
+
.split(";")
|
|
94
|
+
.map((part) => part.trim())
|
|
95
|
+
.find((part) => part.startsWith(`${VISITOR_COOKIE}=`));
|
|
96
|
+
if (found) return decodeURIComponent(found.slice(VISITOR_COOKIE.length + 1));
|
|
97
|
+
|
|
98
|
+
const key =
|
|
99
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
100
|
+
? crypto.randomUUID()
|
|
101
|
+
: `v${Date.now()}${Math.floor(Math.random() * 1e9)}`;
|
|
102
|
+
|
|
103
|
+
// SameSite=Lax and no Secure flag would leak on http; Secure is set unless
|
|
104
|
+
// we are plainly on localhost, where it would stop the cookie working at all.
|
|
105
|
+
const secure = location.protocol === "https:" ? "; Secure" : "";
|
|
106
|
+
// biome-ignore lint/suspicious/noDocumentCookie: the suggested CookieStore
|
|
107
|
+
// API has no Safari or Firefox support. A consent banner has to work in
|
|
108
|
+
// every browser a visitor might arrive in — that is the entire point of it —
|
|
109
|
+
// so the universally supported API is the correct one here.
|
|
110
|
+
document.cookie = `${VISITOR_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
|
|
111
|
+
return key;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** True when the browser is signalling a global opt-out (DNT or GPC). */
|
|
115
|
+
export function browserOptOut(): boolean {
|
|
116
|
+
if (typeof navigator === "undefined") return false;
|
|
117
|
+
const nav = navigator as Navigator & {
|
|
118
|
+
doNotTrack?: string;
|
|
119
|
+
globalPrivacyControl?: boolean;
|
|
120
|
+
};
|
|
121
|
+
if (nav.globalPrivacyControl === true) return true;
|
|
122
|
+
const dnt =
|
|
123
|
+
nav.doNotTrack ??
|
|
124
|
+
(typeof window === "undefined"
|
|
125
|
+
? undefined
|
|
126
|
+
: (window as Window & { doNotTrack?: string }).doNotTrack);
|
|
127
|
+
return dnt === "1" || dnt === "yes";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toState(raw: {
|
|
131
|
+
categories?: string[];
|
|
132
|
+
noticeText?: string | null;
|
|
133
|
+
noticeVersion?: number;
|
|
134
|
+
allowed?: string[];
|
|
135
|
+
mustAsk?: boolean;
|
|
136
|
+
reason?: string;
|
|
137
|
+
}): ConsentState {
|
|
138
|
+
const categories = (raw.categories ?? []) as ConsentCategory[];
|
|
139
|
+
const allowed = (raw.allowed ?? []) as ConsentCategory[];
|
|
140
|
+
return {
|
|
141
|
+
categories,
|
|
142
|
+
noticeText: raw.noticeText ?? null,
|
|
143
|
+
noticeVersion: raw.noticeVersion ?? 1,
|
|
144
|
+
allowed,
|
|
145
|
+
mustAsk: raw.mustAsk ?? true,
|
|
146
|
+
reason: (raw.reason ?? "default_deny") as ConsentReason,
|
|
147
|
+
allows: (category) => allowed.includes(category),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What this visitor currently allows, at this org.
|
|
153
|
+
*
|
|
154
|
+
* On any failure this returns a **deny-everything** state rather than throwing.
|
|
155
|
+
* A consent gate that fails open collects data nobody agreed to; one that fails
|
|
156
|
+
* closed loses some analytics. Only one of those is a compliance incident.
|
|
157
|
+
*/
|
|
158
|
+
export async function loadConsent(options: {
|
|
159
|
+
client: ConsentClientLike;
|
|
160
|
+
}): Promise<ConsentState> {
|
|
161
|
+
const key = visitorKey();
|
|
162
|
+
try {
|
|
163
|
+
const raw = (await options.client.request({
|
|
164
|
+
path: "consent",
|
|
165
|
+
...(key ? { headers: { [VISITOR_HEADER]: key } } : {}),
|
|
166
|
+
responseSchema: { parse: (v: unknown) => v },
|
|
167
|
+
})) as Parameters<typeof toState>[0];
|
|
168
|
+
return toState(raw);
|
|
169
|
+
} catch {
|
|
170
|
+
return {
|
|
171
|
+
categories: [],
|
|
172
|
+
noticeText: null,
|
|
173
|
+
noticeVersion: 1,
|
|
174
|
+
allowed: [],
|
|
175
|
+
mustAsk: false,
|
|
176
|
+
reason: "default_deny",
|
|
177
|
+
allows: () => false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record the visitor's answer.
|
|
184
|
+
*
|
|
185
|
+
* `granted: []` is a real answer — "asked, refused everything" — and is stored
|
|
186
|
+
* as one, so the banner does not reappear on the next page.
|
|
187
|
+
*/
|
|
188
|
+
export async function recordConsent(options: {
|
|
189
|
+
client: ConsentClientLike;
|
|
190
|
+
granted: ConsentCategory[];
|
|
191
|
+
noticeVersion: number;
|
|
192
|
+
source?: "banner" | "settings";
|
|
193
|
+
}): Promise<{ ok: boolean }> {
|
|
194
|
+
const key = visitorKey();
|
|
195
|
+
if (!key) return { ok: false };
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
await options.client.request({
|
|
199
|
+
method: "POST",
|
|
200
|
+
path: "consent",
|
|
201
|
+
body: {
|
|
202
|
+
granted: options.granted,
|
|
203
|
+
noticeVersion: options.noticeVersion,
|
|
204
|
+
source: options.source ?? "banner",
|
|
205
|
+
},
|
|
206
|
+
headers: { [VISITOR_HEADER]: key },
|
|
207
|
+
responseSchema: { parse: (v: unknown) => v },
|
|
208
|
+
});
|
|
209
|
+
return { ok: true };
|
|
210
|
+
} catch {
|
|
211
|
+
return { ok: false };
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/contracts.ts
CHANGED
|
@@ -995,6 +995,44 @@ export const customerPortalProfileSchema = z
|
|
|
995
995
|
})
|
|
996
996
|
.nullable();
|
|
997
997
|
|
|
998
|
+
/**
|
|
999
|
+
* One quiz outcome for the signed-in customer. `scoreWithheld` marks an
|
|
1000
|
+
* interview-mode quiz: the attempt exists, the verdict is staff-only —
|
|
1001
|
+
* render "completed" and nothing else.
|
|
1002
|
+
*/
|
|
1003
|
+
export const customerPortalQuizResultSchema = z.object({
|
|
1004
|
+
invitationId: z.string(),
|
|
1005
|
+
quizName: z.string(),
|
|
1006
|
+
competency: z.string().nullable(),
|
|
1007
|
+
status: z.string(),
|
|
1008
|
+
completedAt: z.string().nullable(),
|
|
1009
|
+
scoreWithheld: z.boolean(),
|
|
1010
|
+
result: z
|
|
1011
|
+
.object({
|
|
1012
|
+
status: z.string(),
|
|
1013
|
+
scorePercent: z.number().nullable(),
|
|
1014
|
+
provisionalScorePercent: z.number().nullable(),
|
|
1015
|
+
passed: z.boolean().nullable(),
|
|
1016
|
+
pointsEarned: z.number().nullable(),
|
|
1017
|
+
pointsPossible: z.number().nullable(),
|
|
1018
|
+
passingScore: z.number(),
|
|
1019
|
+
results: z.array(z.record(z.unknown())),
|
|
1020
|
+
submittedAt: z.string().nullable(),
|
|
1021
|
+
durationSeconds: z.number().nullable(),
|
|
1022
|
+
})
|
|
1023
|
+
.nullable(),
|
|
1024
|
+
});
|
|
1025
|
+
export type CustomerPortalQuizResult = z.infer<
|
|
1026
|
+
typeof customerPortalQuizResultSchema
|
|
1027
|
+
>;
|
|
1028
|
+
|
|
1029
|
+
export const customerPortalQuizResultsResponseSchema = z.object({
|
|
1030
|
+
items: z.array(customerPortalQuizResultSchema),
|
|
1031
|
+
});
|
|
1032
|
+
export type CustomerPortalQuizResultsResponse = z.infer<
|
|
1033
|
+
typeof customerPortalQuizResultsResponseSchema
|
|
1034
|
+
>;
|
|
1035
|
+
|
|
998
1036
|
export const customerPortalUpdateProfileInputSchema = z.object({
|
|
999
1037
|
displayName: z.string().min(1).max(120).optional(),
|
|
1000
1038
|
profile: z.record(z.unknown()).optional(),
|