@tribe-nest/forge 3.2.0 → 3.9.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/package.json +1 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +122 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +89 -0
- package/src/data/queries/_tests/walletPass.spec.ts +159 -0
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEventWaitlist.ts +429 -0
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/useMyBookings.ts +211 -0
- package/src/data/queries/useMyTickets.ts +154 -0
- package/src/data/queries/usePassTransfers.ts +318 -0
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWalletPass.ts +236 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +14 -0
- package/src/server/_tests/siteBootstrap.spec.ts +131 -0
- package/src/server/index.ts +122 -6
- package/src/types/diagnostics.ts +49 -0
- package/src/types/models.ts +66 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/index.ts +26 -0
- package/src/ui/shell/PoweredBy.tsx +62 -0
- package/src/ui/shell/PreviewDiagnostics.tsx +80 -0
- package/src/ui/shell/TribeNestApp.tsx +39 -1
- package/src/ui/shell/diagnosticsGating.spec.ts +102 -0
- package/src/ui/shell/diagnosticsGating.ts +90 -0
- package/src/ui/shell/shellGating.spec.ts +60 -1
- package/src/ui/shell/shellGating.ts +47 -0
- package/src/ui/styled/AccountDashboard.tsx +598 -1
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +24 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/EventWaitlist.tsx +448 -0
- package/src/ui/styled/TicketTransfer.tsx +393 -0
- package/src/ui/styled/WalletPassButtons.tsx +208 -0
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/_tests/WalletPassButtons.spec.tsx +223 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
previewDiagnosticsEnabled,
|
|
4
|
+
resolvePreviewDiagnostics,
|
|
5
|
+
previewDiagnosticsMessage,
|
|
6
|
+
} from "./diagnosticsGating";
|
|
7
|
+
import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
|
|
8
|
+
|
|
9
|
+
describe("previewDiagnosticsEnabled", () => {
|
|
10
|
+
it("shows in the in-editor sandbox", () => {
|
|
11
|
+
expect(previewDiagnosticsEnabled({ editable: true, state: "draft" })).toBe(true);
|
|
12
|
+
// editable wins even if a shell mislabels the state.
|
|
13
|
+
expect(previewDiagnosticsEnabled({ editable: true, state: "published" })).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows on a draft review Worker, which is not editable", () => {
|
|
17
|
+
expect(previewDiagnosticsEnabled({ editable: false, state: "draft" })).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("NEVER shows on the live published site", () => {
|
|
21
|
+
expect(previewDiagnosticsEnabled({ editable: false, state: "published" })).toBe(false);
|
|
22
|
+
expect(previewDiagnosticsEnabled({ state: "published" })).toBe(false);
|
|
23
|
+
// A shell that passes neither must not leak the banner to visitors.
|
|
24
|
+
expect(previewDiagnosticsEnabled({})).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolvePreviewDiagnostics", () => {
|
|
29
|
+
const failed: ForgeSsrDiagnostics = {
|
|
30
|
+
apiUrl: "https://api.tribenest.co",
|
|
31
|
+
failures: [{ what: "content", error: "fetch failed" }],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it("passes through real diagnostics from fetchSiteBootstrap", () => {
|
|
35
|
+
expect(resolvePreviewDiagnostics({ diagnostics: failed, profileId: "p1", siteConfig: {} })).toBe(failed);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null on a healthy render", () => {
|
|
39
|
+
expect(resolvePreviewDiagnostics({ diagnostics: null, profileId: "p1", siteConfig: { currency: "USD" } })).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("ignores an empty failure list", () => {
|
|
43
|
+
const clean: ForgeSsrDiagnostics = { apiUrl: "https://api.tribenest.co", failures: [] };
|
|
44
|
+
expect(resolvePreviewDiagnostics({ diagnostics: clean, profileId: "p1", siteConfig: {} })).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The fallback that covers every already-deployed shell still calling
|
|
48
|
+
// fetchContentDocument/fetchSiteConfig separately — no file edit needed.
|
|
49
|
+
it("infers a failure from a null siteConfig when a profileId IS baked in", () => {
|
|
50
|
+
const d = resolvePreviewDiagnostics({ profileId: "p1", siteConfig: null, apiUrl: "https://api.tribenest.co" });
|
|
51
|
+
expect(d).toEqual({
|
|
52
|
+
apiUrl: "https://api.tribenest.co",
|
|
53
|
+
failures: [{ what: "siteConfig", error: "no response" }],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does NOT infer a failure when there was no profile to fetch for", () => {
|
|
58
|
+
// No profileId baked in → nothing was requested → a null config is correct,
|
|
59
|
+
// not a failure. Crying wolf here would train people to ignore the banner.
|
|
60
|
+
expect(resolvePreviewDiagnostics({ profileId: undefined, siteConfig: null })).toBeNull();
|
|
61
|
+
expect(resolvePreviewDiagnostics({ profileId: "", siteConfig: null })).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("previewDiagnosticsMessage", () => {
|
|
66
|
+
it("names the network as the cause when the probe also failed", () => {
|
|
67
|
+
const msg = previewDiagnosticsMessage({
|
|
68
|
+
apiUrl: "https://api.tribenest.co",
|
|
69
|
+
failures: [
|
|
70
|
+
{ what: "content", error: "fetch failed" },
|
|
71
|
+
{ what: "siteConfig", error: "fetch failed" },
|
|
72
|
+
],
|
|
73
|
+
probe: { ok: false, url: "https://api.tribenest.co/healthcheck", error: "no response within 5000ms", ms: 5001 },
|
|
74
|
+
});
|
|
75
|
+
expect(msg.title).toBe("This preview can't reach the TribeNest API");
|
|
76
|
+
expect(msg.details.some((l) => l.includes("no route to the API"))).toBe(true);
|
|
77
|
+
expect(msg.details).toContain("API: https://api.tribenest.co");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("distinguishes a reachable API from a per-route failure", () => {
|
|
81
|
+
const msg = previewDiagnosticsMessage({
|
|
82
|
+
apiUrl: "https://api.tribenest.co",
|
|
83
|
+
failures: [{ what: "content", error: "HTTP 500", status: 500 }],
|
|
84
|
+
probe: { ok: true, url: "https://api.tribenest.co/healthcheck", status: 200, ms: 42 },
|
|
85
|
+
});
|
|
86
|
+
expect(msg.title).toBe("This preview couldn't load your site data");
|
|
87
|
+
expect(msg.details.some((l) => l.includes("per-route failure"))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("always states that the defaults on screen are the symptom, not the setting", () => {
|
|
91
|
+
// This sentence is the entire point of the banner — the failure mode it was
|
|
92
|
+
// built for is a preview that looks configured-but-wrong rather than broken.
|
|
93
|
+
const msg = previewDiagnosticsMessage({ apiUrl: "", failures: [{ what: "siteConfig", error: "no response" }] });
|
|
94
|
+
expect(msg.consequence).toContain("default theme");
|
|
95
|
+
expect(msg.consequence).toContain("published site is unaffected");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("omits the API line when the shell baked in no url", () => {
|
|
99
|
+
const msg = previewDiagnosticsMessage({ apiUrl: "", failures: [{ what: "content", error: "boom" }] });
|
|
100
|
+
expect(msg.details.some((l) => l.startsWith("API:"))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Pure logic for the preview's SSR-failure banner, kept React-free so it's
|
|
2
|
+
// unit-testable in the node test env (no jsdom) — same split as `shellGating.ts`,
|
|
3
|
+
// which is also why this is not named after the component it feeds: a
|
|
4
|
+
// `previewDiagnostics.ts` next to `PreviewDiagnostics.tsx` differs only in
|
|
5
|
+
// casing, which tsc rejects outright on a case-insensitive filesystem.
|
|
6
|
+
import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WHERE the banner is allowed to appear: any build that is NOT the released live
|
|
10
|
+
* site — the in-editor sandbox (`editable`) or a preview-<versionId> review
|
|
11
|
+
* Worker (`state === "draft"`).
|
|
12
|
+
*
|
|
13
|
+
* The inverse of `shellPwaEnabled` / `shellPoweredByEnabled`, and deliberately so:
|
|
14
|
+
* a visitor to a live site must never be shown our plumbing. On the live site the
|
|
15
|
+
* degraded render is still the right behaviour — the point of this banner is that
|
|
16
|
+
* the person who can FIX it (the creator, or us) is the one looking at it.
|
|
17
|
+
*/
|
|
18
|
+
export function previewDiagnosticsEnabled(opts: { editable?: boolean; state?: "draft" | "published" }): boolean {
|
|
19
|
+
return !!opts.editable || opts.state === "draft";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* WHETHER there is anything to report, and what.
|
|
24
|
+
*
|
|
25
|
+
* Two sources, because the shells are versioned independently of Forge:
|
|
26
|
+
*
|
|
27
|
+
* 1. `diagnostics` — the precise answer, from `fetchSiteBootstrap`. Only shells
|
|
28
|
+
* that have been updated to call it supply this.
|
|
29
|
+
* 2. The fallback — a deploy that HAS a `profileId` baked in but rendered with a
|
|
30
|
+
* null `siteConfig` cannot be healthy: the fetch went out and came back with
|
|
31
|
+
* nothing. Every already-deployed site whose `__root.tsx` still calls the old
|
|
32
|
+
* `fetchSiteConfig` gets a (less detailed) banner from this on a Forge bump
|
|
33
|
+
* alone, with no file edit.
|
|
34
|
+
*
|
|
35
|
+
* Returns null when there is nothing wrong.
|
|
36
|
+
*/
|
|
37
|
+
export function resolvePreviewDiagnostics(opts: {
|
|
38
|
+
diagnostics?: ForgeSsrDiagnostics | null;
|
|
39
|
+
profileId?: string;
|
|
40
|
+
siteConfig?: unknown;
|
|
41
|
+
apiUrl?: string;
|
|
42
|
+
}): ForgeSsrDiagnostics | null {
|
|
43
|
+
if (opts.diagnostics && opts.diagnostics.failures.length > 0) return opts.diagnostics;
|
|
44
|
+
if (opts.profileId && opts.siteConfig == null) {
|
|
45
|
+
return {
|
|
46
|
+
apiUrl: opts.apiUrl ?? "",
|
|
47
|
+
failures: [{ what: "siteConfig", error: "no response" }],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type PreviewDiagnosticsMessage = {
|
|
54
|
+
title: string;
|
|
55
|
+
/** The one sentence that resolves the confusion this banner exists for. */
|
|
56
|
+
consequence: string;
|
|
57
|
+
/** Specifics — one line per failure, plus the probe verdict. */
|
|
58
|
+
details: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const WHAT_LABEL: Record<string, string> = {
|
|
62
|
+
content: "content document (your text, images and theme)",
|
|
63
|
+
siteConfig: "site config (currency, payment provider, tracking)",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Human copy for the banner. Pure — no React, no formatting concerns. */
|
|
67
|
+
export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
|
|
68
|
+
const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what} — ${f.error}.`);
|
|
69
|
+
|
|
70
|
+
if (d.probe) {
|
|
71
|
+
details.push(
|
|
72
|
+
d.probe.ok
|
|
73
|
+
? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
|
|
74
|
+
: `Couldn't reach ${d.probe.url} either — ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
title:
|
|
81
|
+
d.probe && !d.probe.ok
|
|
82
|
+
? "This preview can't reach the TribeNest API"
|
|
83
|
+
: "This preview couldn't load your site data",
|
|
84
|
+
// The sentence that would have saved the debugging session this was built
|
|
85
|
+
// after: the defaults on screen are a symptom, not the setting.
|
|
86
|
+
consequence:
|
|
87
|
+
"You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived — not because of how your site is configured. Your published site is unaffected.",
|
|
88
|
+
details,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { shellPwaEnabled } from "./shellGating";
|
|
2
|
+
import { poweredByHref, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
|
|
3
3
|
|
|
4
4
|
// The PWA (SW register + install prompt) must be ON only for the live published
|
|
5
5
|
// site, and OFF everywhere else (editor, preview/draft), so review Workers never
|
|
@@ -26,3 +26,62 @@ describe("shellPwaEnabled", () => {
|
|
|
26
26
|
expect(shellPwaEnabled({ editable: false, state: "published" })).toBe(true);
|
|
27
27
|
});
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// "Powered by TribeNest" ships on released builds only, and the ONLY way to
|
|
31
|
+
// remove it is the server-supplied flag — there is no client-side opt-out,
|
|
32
|
+
// because the tenant owns __root.tsx.
|
|
33
|
+
describe("shellPoweredByEnabled", () => {
|
|
34
|
+
it("is true on a live published site", () => {
|
|
35
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published" })).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is false in the editor and on draft/preview deploys", () => {
|
|
39
|
+
expect(shellPoweredByEnabled({ editable: true, state: "published" })).toBe(false);
|
|
40
|
+
expect(shellPoweredByEnabled({ editable: false, state: "draft" })).toBe(false);
|
|
41
|
+
expect(shellPoweredByEnabled({ editable: false })).toBe(false); // state undefined
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is false only when the SERVER says to hide it", () => {
|
|
45
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: true })).toBe(false);
|
|
46
|
+
expect(shellPoweredByEnabled({ editable: false, state: "published", hideBadge: false })).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The badge is only worth carrying if we can tell WHICH site sent the visitor,
|
|
51
|
+
// so the identifying params are the point of the link, not decoration.
|
|
52
|
+
describe("poweredByHref", () => {
|
|
53
|
+
const parse = (href: string) => new URL(href).searchParams;
|
|
54
|
+
|
|
55
|
+
it("identifies the referring site by subdomain", () => {
|
|
56
|
+
const p = parse(poweredByHref({ subdomain: "ladygaga", profileId: "prof-1" }));
|
|
57
|
+
expect(p.get("utm_source")).toBe("powered_by");
|
|
58
|
+
expect(p.get("utm_medium")).toBe("badge");
|
|
59
|
+
expect(p.get("utm_campaign")).toBe("creator_site");
|
|
60
|
+
expect(p.get("utm_content")).toBe("ladygaga");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the profileId when no subdomain is baked in", () => {
|
|
64
|
+
expect(parse(poweredByHref({ profileId: "prof-1" })).get("utm_content")).toBe("prof-1");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("distinguishes a mini-app from a website, and one app from another", () => {
|
|
68
|
+
const p = parse(poweredByHref({ subdomain: "ladygaga", appId: "app-7" }));
|
|
69
|
+
expect(p.get("utm_campaign")).toBe("creator_app");
|
|
70
|
+
expect(p.get("utm_content")).toBe("ladygaga"); // the tenant
|
|
71
|
+
expect(p.get("utm_term")).toBe("app-7"); // the deploy
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("omits utm_content rather than emitting an empty one when identity is missing", () => {
|
|
75
|
+
expect(parse(poweredByHref({})).has("utm_content")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("url-encodes an identity that needs it", () => {
|
|
79
|
+
// A subdomain can't contain these, but utm_content also carries profile ids
|
|
80
|
+
// and one day whatever replaces them — encode rather than trust the shape.
|
|
81
|
+
expect(parse(poweredByHref({ subdomain: "a b&c" })).get("utm_content")).toBe("a b&c");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("always points at the marketing site", () => {
|
|
85
|
+
expect(poweredByHref({ subdomain: "x" }).startsWith("https://tribenest.co/?")).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -9,3 +9,50 @@ export function shellPwaEnabled(opts: {
|
|
|
9
9
|
}): boolean {
|
|
10
10
|
return (opts.pwa ?? true) && !opts.editable && opts.state === "published";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// "Powered by TribeNest" shows on RELEASED builds only — the live published
|
|
14
|
+
// site, never the HMR editor or a preview-<versionId>.* review Worker. Same
|
|
15
|
+
// released-build test as the PWA, with one extra lever: `hideBadge` comes from
|
|
16
|
+
// the server (`siteConfig.hideTribeNestBadge`), which is where a white-label
|
|
17
|
+
// plan entitlement turns it off. It is deliberately not a `TribeNestApp` prop —
|
|
18
|
+
// the tenant owns __root.tsx, so a prop would make the badge opt-out.
|
|
19
|
+
export function shellPoweredByEnabled(opts: {
|
|
20
|
+
editable?: boolean;
|
|
21
|
+
state?: "draft" | "published";
|
|
22
|
+
hideBadge?: boolean;
|
|
23
|
+
}): boolean {
|
|
24
|
+
return !opts.hideBadge && !opts.editable && opts.state === "published";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Marketing site the badge points at. */
|
|
28
|
+
const TRIBENEST_URL = "https://tribenest.co/";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The badge href, tagged so a signup can be traced back to the exact site that
|
|
32
|
+
* referred it.
|
|
33
|
+
*
|
|
34
|
+
* utm_source=powered_by the badge, as against any other tribenest.co inbound
|
|
35
|
+
* utm_medium=badge
|
|
36
|
+
* utm_campaign creator_site | creator_app — which surface it was
|
|
37
|
+
* utm_content WHICH site: the tenant subdomain, falling back to the
|
|
38
|
+
* profileId when a deploy has no subdomain baked in
|
|
39
|
+
* utm_term the appId on a mini-app, so two apps on one profile
|
|
40
|
+
* stay distinguishable (utm_content is the tenant, not
|
|
41
|
+
* the deploy)
|
|
42
|
+
*
|
|
43
|
+
* Built from the identity `ForgeProvider` was constructed with — baked at build
|
|
44
|
+
* time — never from `window.location`. That keeps it identical on the server and
|
|
45
|
+
* the client (no hydration mismatch), and correct on custom domains, where the
|
|
46
|
+
* hostname says nothing about which tenant it is.
|
|
47
|
+
*/
|
|
48
|
+
export function poweredByHref(opts: { subdomain?: string; profileId?: string; appId?: string }): string {
|
|
49
|
+
const params = new URLSearchParams({
|
|
50
|
+
utm_source: "powered_by",
|
|
51
|
+
utm_medium: "badge",
|
|
52
|
+
utm_campaign: opts.appId ? "creator_app" : "creator_site",
|
|
53
|
+
});
|
|
54
|
+
const site = opts.subdomain || opts.profileId;
|
|
55
|
+
if (site) params.set("utm_content", site);
|
|
56
|
+
if (opts.appId) params.set("utm_term", opts.appId);
|
|
57
|
+
return `${TRIBENEST_URL}?${params.toString()}`;
|
|
58
|
+
}
|