@escape-game-over/atlas 0.1.39 → 0.1.41
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/docs/client-scripts.md +3 -1
- package/package.json +1 -1
- package/src/astro/attribution.ts +114 -0
- package/src/astro/client.ts +4 -0
- package/src/astro/consent-element.ts +27 -1
package/docs/client-scripts.md
CHANGED
|
@@ -51,7 +51,9 @@ answer is two, one per category: `analytics` (Google's `analytics_storage`) and
|
|
|
51
51
|
markup marks its buttons `data-consent="grant"` and `data-consent="deny"` for
|
|
52
52
|
everything at once, and `data-consent="save"` for the answer its checkboxes
|
|
53
53
|
give — `data-consent-category="analytics"` and `"marketing"`, never ticked by
|
|
54
|
-
default.
|
|
54
|
+
default. Those can wait in a `data-consent-choices hidden` panel that a
|
|
55
|
+
`data-consent-choose` button shows and hides, keeping its `aria-expanded` in
|
|
56
|
+
step. The control that brings it back is `data-consent-reopen hidden`.
|
|
55
57
|
|
|
56
58
|
`Document` ships one more: scroll restoration, on unless `restoreScroll={false}`.
|
|
57
59
|
WebKit restores a page's position only at `load`, after every image, so a page
|
package/package.json
CHANGED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a visit came from, for a lead form to report: the campaign tags it
|
|
3
|
+
* landed with, and the Google Ads click behind it.
|
|
4
|
+
*
|
|
5
|
+
* A lead is the one record that outlives the visit — an email, a row in a
|
|
6
|
+
* sheet — so it is where a conversion import has to find the click, and where
|
|
7
|
+
* anyone asking "which campaign brought this?" will look.
|
|
8
|
+
*
|
|
9
|
+
* The two are kept differently, on purpose:
|
|
10
|
+
*
|
|
11
|
+
* - **Campaign tags** (`utm_*`) are labels a campaign shares with everyone who
|
|
12
|
+
* clicked it, not an identifier. They are kept for the tab in
|
|
13
|
+
* `sessionStorage`, so a visitor who lands on one page and fills a form on
|
|
14
|
+
* another still reports them. `rememberCampaign()` on every page does that.
|
|
15
|
+
* - **The ad click id** (`gclid`, `gbraid`, `wbraid`) identifies one click, and
|
|
16
|
+
* is never stored here. Google keeps it under the visitor's consent: on the
|
|
17
|
+
* URL while marketing is denied — `urlPassthrough` in `GoogleSettings` — and
|
|
18
|
+
* in its own `_gcl_aw` cookie once granted. `adClick()` reads it from there.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The campaign tags a link can carry. */
|
|
22
|
+
export type CampaignTag =
|
|
23
|
+
| "utm_source"
|
|
24
|
+
| "utm_medium"
|
|
25
|
+
| "utm_campaign"
|
|
26
|
+
| "utm_term"
|
|
27
|
+
| "utm_content";
|
|
28
|
+
|
|
29
|
+
const CAMPAIGN_TAGS: readonly CampaignTag[] = [
|
|
30
|
+
"utm_source",
|
|
31
|
+
"utm_medium",
|
|
32
|
+
"utm_campaign",
|
|
33
|
+
"utm_term",
|
|
34
|
+
"utm_content",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** The ids Google Ads puts on a landing URL. */
|
|
38
|
+
export type AdClickId = "gclid" | "gbraid" | "wbraid";
|
|
39
|
+
|
|
40
|
+
const AD_CLICK_IDS: readonly AdClickId[] = ["gclid", "gbraid", "wbraid"];
|
|
41
|
+
|
|
42
|
+
export type Campaign = Partial<Record<CampaignTag, string>>;
|
|
43
|
+
export type AdClick = Partial<Record<AdClickId, string>>;
|
|
44
|
+
|
|
45
|
+
/** The `sessionStorage` key the tags are kept under. */
|
|
46
|
+
const KEY = "atlas-campaign";
|
|
47
|
+
|
|
48
|
+
/** The given parameters present on this page's URL. */
|
|
49
|
+
function fromUrl<K extends string>(
|
|
50
|
+
names: readonly K[]
|
|
51
|
+
): Partial<Record<K, string>> {
|
|
52
|
+
const params = new URLSearchParams(location.search);
|
|
53
|
+
const found: Partial<Record<K, string>> = {};
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const value = params.get(name);
|
|
56
|
+
if (value) found[name] = value;
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Keeps this page's campaign tags for the tab, if it has any. */
|
|
62
|
+
export function rememberCampaign(): void {
|
|
63
|
+
const landed = fromUrl(CAMPAIGN_TAGS);
|
|
64
|
+
if (Object.keys(landed).length === 0) return;
|
|
65
|
+
try {
|
|
66
|
+
sessionStorage.setItem(KEY, JSON.stringify(landed));
|
|
67
|
+
} catch {
|
|
68
|
+
// Storage refused: the tags still count on the page they arrived on.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The tags on this page's URL, else the ones the visit landed with. */
|
|
73
|
+
export function campaign(): Campaign {
|
|
74
|
+
const here = fromUrl(CAMPAIGN_TAGS);
|
|
75
|
+
if (Object.keys(here).length > 0) return here;
|
|
76
|
+
try {
|
|
77
|
+
const kept: unknown = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
|
|
78
|
+
if (typeof kept !== "object" || kept === null) return {};
|
|
79
|
+
const found: Campaign = {};
|
|
80
|
+
for (const tag of CAMPAIGN_TAGS) {
|
|
81
|
+
const value = (kept as Record<string, unknown>)[tag];
|
|
82
|
+
if (typeof value === "string" && value !== "") found[tag] = value;
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
} catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A cookie's value. `document.cookie` is always `name=value; name=value`, so a
|
|
92
|
+
* split reads it — `cookieStore.get` would too, but is async and newer than
|
|
93
|
+
* the Safari still in use.
|
|
94
|
+
*/
|
|
95
|
+
function cookie(name: string): string | undefined {
|
|
96
|
+
const prefix = `${name}=`;
|
|
97
|
+
return document.cookie
|
|
98
|
+
.split("; ")
|
|
99
|
+
.find((pair) => pair.startsWith(prefix))
|
|
100
|
+
?.slice(prefix.length);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The Google Ads click behind this visit: from the URL, else `gclid` from
|
|
105
|
+
* Google's `_gcl_aw` cookie, whose value is `GCL.<time>.<gclid>`.
|
|
106
|
+
*/
|
|
107
|
+
export function adClick(): AdClick {
|
|
108
|
+
const click = fromUrl(AD_CLICK_IDS);
|
|
109
|
+
if (click.gclid === undefined) {
|
|
110
|
+
const gclid = cookie("_gcl_aw")?.split(".").slice(2).join(".");
|
|
111
|
+
if (gclid) click.gclid = gclid;
|
|
112
|
+
}
|
|
113
|
+
return click;
|
|
114
|
+
}
|
package/src/astro/client.ts
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* import, so a component's contract can be imported from frontmatter too.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
export * from "./attribution.ts";
|
|
12
13
|
export * from "./background-video.ts";
|
|
13
14
|
export * from "./carousel.ts";
|
|
15
|
+
// Reading only: a form that reports what the visitor agreed to. Recording an
|
|
16
|
+
// answer stays with the banner.
|
|
17
|
+
export { type ConsentRecord, readConsent } from "./consent.ts";
|
|
14
18
|
export {
|
|
15
19
|
type Connect,
|
|
16
20
|
type ElementContext,
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
recordConsent,
|
|
8
8
|
} from "./consent.ts";
|
|
9
9
|
import { element } from "./element.ts";
|
|
10
|
-
import { data, refs } from "./ref.ts";
|
|
10
|
+
import { data, ref, refs } from "./ref.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The banner's behaviour. Its own module, and not re-exported from `client.ts`,
|
|
@@ -25,6 +25,13 @@ import { data, refs } from "./ref.ts";
|
|
|
25
25
|
* The checkboxes are never ticked by default; they show an answer already
|
|
26
26
|
* given, and nothing else.
|
|
27
27
|
*
|
|
28
|
+
* To keep them out of sight until asked for, put them in a
|
|
29
|
+
* `data-consent-choices hidden` panel and add a `data-consent-choose` button:
|
|
30
|
+
* it shows and hides the panel, and keeps its own `aria-expanded` in step. The
|
|
31
|
+
* panel can sit anywhere in the banner — below the buttons, not only inside
|
|
32
|
+
* the control, which is what a `<details>` would force. It starts closed each
|
|
33
|
+
* time the banner is shown.
|
|
34
|
+
*
|
|
28
35
|
* The control that brings it back — usually in the footer — is
|
|
29
36
|
* `data-consent-reopen hidden`: it stays hidden until there is an answer to
|
|
30
37
|
* withdraw.
|
|
@@ -53,12 +60,31 @@ export const consentBanner = element("atlas-consent", ({ root, signal }) => {
|
|
|
53
60
|
};
|
|
54
61
|
};
|
|
55
62
|
|
|
63
|
+
const toggles = refs<HTMLButtonElement>(root, "[data-consent-choose]");
|
|
64
|
+
const panel =
|
|
65
|
+
toggles.length > 0 ? ref(root, "[data-consent-choices]") : undefined;
|
|
66
|
+
|
|
67
|
+
const expand = (open: boolean): void => {
|
|
68
|
+
if (panel === undefined) return;
|
|
69
|
+
panel.hidden = !open;
|
|
70
|
+
for (const toggle of toggles) {
|
|
71
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const toggle of toggles) {
|
|
76
|
+
toggle.addEventListener("click", () => expand(panel?.hidden === true), {
|
|
77
|
+
signal,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
56
81
|
const show = (): void => {
|
|
57
82
|
// Only an answer already given ticks a box.
|
|
58
83
|
const stored = readConsent();
|
|
59
84
|
for (const { box, category } of boxes) {
|
|
60
85
|
box.checked = stored?.[category] === "granted";
|
|
61
86
|
}
|
|
87
|
+
expand(false);
|
|
62
88
|
root.hidden = false;
|
|
63
89
|
};
|
|
64
90
|
|