studio-engine 0.60.0 → 0.60.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/app/views/studio/{_age_verify_assets.html.erb → _birthday_assets.html.erb} +79 -13
- data/app/views/studio/_board_assets.html.erb +1 -1
- data/app/views/studio/_leveling_activity_assets.html.erb +2 -2
- data/app/views/studio/fields/_date_of_birth.html.erb +4 -3
- data/app/views/studio/modals/blocks/_age_gate.html.erb +225 -0
- data/app/views/studio/modals/blocks/_birthday.html.erb +134 -0
- data/app/views/studio/profiles/_birthday_fields.html.erb +1 -1
- data/app/views/style/_modals.html.erb +521 -114
- data/app/views/style/modals/_age_gate.html.erb +21 -0
- data/app/views/style/modals/_birthday.html.erb +59 -0
- data/app/views/style/modals/_cosign_rejected.html.erb +36 -0
- data/app/views/style/modals/_email_change_pending.html.erb +39 -0
- data/app/views/style/modals/_it_begins.html.erb +46 -0
- data/app/views/style/modals/_network_guard.html.erb +55 -0
- data/app/views/style/modals/_newsletter_email.html.erb +57 -0
- data/app/views/style/modals/_quest_success.html.erb +55 -0
- data/app/views/style/modals/_rate_limit_general.html.erb +61 -0
- data/app/views/style/modals/_unsubscribe_confirm.html.erb +52 -0
- data/app/views/style/modals/_unsubscribe_goodbye.html.erb +30 -0
- data/app/views/style/modals/_wallet_changed.html.erb +70 -0
- data/app/views/style/modals/_wallet_setup.html.erb +112 -0
- data/lib/studio/version.rb +1 -1
- metadata +18 -5
- data/app/views/studio/modals/blocks/_age_verify.html.erb +0 -82
- data/app/views/style/modals/_age_verify.html.erb +0 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e147f36e9a7113442530805c34a0f5fe0b9fb1b37391b8e4271e89368b01236e
|
|
4
|
+
data.tar.gz: 17203c174004a90979e88259c32b5672907fe8b5b6deadb9cf240f1d4c9aab85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1cacfd158c940e8d78c63d97d33c6bd248112a738032230a79ac480facc90077dffa185fa4fe352567b07204e91044f88dd7ea498906fb8ea8f5bba570944be
|
|
7
|
+
data.tar.gz: e1c274a2ff0726e5ebced0ba9185157798e6c194e149ad9c846b10d215f1e282731a8838cd1ec9fa85f6d93ee94ddada47fb16bad2cf37569c181c4791310840
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Breaking
|
|
8
|
+
|
|
9
|
+
- **The age-gate DOB modal is renamed, and the refusal moved to its own card.**
|
|
10
|
+
A host that renders these paths from the released gem must update them:
|
|
11
|
+
|
|
12
|
+
| Was | Is now |
|
|
13
|
+
|-----|--------|
|
|
14
|
+
| `studio/modals/blocks/_age_verify` | `studio/modals/blocks/_birthday` |
|
|
15
|
+
| `studio/_age_verify_assets` | `studio/_birthday_assets` |
|
|
16
|
+
| `window.ageVerifyModal` | `window.birthdayModal` |
|
|
17
|
+
|
|
18
|
+
Nothing in `mcritchie-studio`, `turf-monster`, `mcritchie-industries` or `rolio`
|
|
19
|
+
rendered the old paths at the time of this change (each app that shows an age
|
|
20
|
+
gate ships its own card and shares only `studio/fields/_date_of_birth`), so the
|
|
21
|
+
rename lands with no consumer edit — but the old paths are GONE, not aliased, so
|
|
22
|
+
a future adopter reaching for `blocks/age_verify` needs this table.
|
|
23
|
+
|
|
24
|
+
**`age-verified` is deliberately unchanged.** The success event and
|
|
25
|
+
`session.ageVerified` are what consuming apps already listen on, so the rename
|
|
26
|
+
stopped short of them and no host wiring moves.
|
|
27
|
+
|
|
28
|
+
- **`min_age` is now OPTIONAL on the birthday card** (was REQUIRED). Absent — or
|
|
29
|
+
`0` — means the app asks for a birthday and does not gate on it: the card drops
|
|
30
|
+
the age line, the eligibility wording and the refusal handoff. Existing callers
|
|
31
|
+
that pass a number are unaffected. There is still NO engine default, because 18
|
|
32
|
+
is itself a policy value.
|
|
33
|
+
|
|
34
|
+
- **An under-age date now SUBMITS.** It used to disable the card's own submit
|
|
35
|
+
button and turn the card red — the one screen state with nothing to press. The
|
|
36
|
+
factory routes on the RESPONSE instead: `body.underage === true`, or HTTP 403,
|
|
37
|
+
opens the age-gate card; anything else non-verified stays on the error line. An
|
|
38
|
+
app adopting this must make its verify endpoint answer "too young" distinctly
|
|
39
|
+
from "bad request", because a client that blocks submission never had to.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`studio/modals/blocks/_age_gate` — the refusal card.** Where the birthday card
|
|
44
|
+
hands off when the date is under the app's bar. Headed "Easy, Young'un" under a
|
|
45
|
+
teddy bear rather than a red X, because a person who will be welcome later is
|
|
46
|
+
not an error state. Carries a LIVE COUNTDOWN to the day they qualify, built from
|
|
47
|
+
the date the birthday card passes across the store, plus the two ways out: a
|
|
48
|
+
primary CTA to watch instead (app-supplied `watch_url`, dropped entirely when
|
|
49
|
+
absent rather than rendered dead) and a back link that returns to the birthday
|
|
50
|
+
card. Displays `min_age` / `state`; computes no eligibility of its own.
|
|
51
|
+
|
|
7
52
|
### Added
|
|
8
53
|
|
|
9
54
|
- **A Geo row in the shared admin dropdown, with signage when it is off.** Every
|
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
|
|
3
|
-
(studio/modals/blocks/
|
|
2
|
+
birthdayModal factory — the Alpine data for the engine birthday DOB modal
|
|
3
|
+
(studio/modals/blocks/_birthday). Ships here, at PAGE level, on purpose: the
|
|
4
4
|
modal partial mounts inside a modal host's single-root template, and a script
|
|
5
5
|
cloned out of a template never runs. So a consumer renders THIS once in its
|
|
6
6
|
layout (like the shared alpine factories), and the modal partial anywhere below
|
|
7
|
-
just references window.
|
|
7
|
+
just references window.birthdayModal. Mirrors how studio/_cropper_assets homes
|
|
8
8
|
the cropPhotoModal factory for the crop modal.
|
|
9
9
|
|
|
10
10
|
The factory holds ZERO legal policy — no age default, no state table. minAge is
|
|
11
11
|
whatever the app hands it (a required prop upstream); the client "too young"
|
|
12
12
|
hint is UX only, and the server recompute at submit_url stays authoritative.
|
|
13
13
|
|
|
14
|
-
opts: { minAge, state, url, store, demo }.
|
|
14
|
+
opts: { minAge, state, url, store, demo, demoUnderage, gateId }.
|
|
15
15
|
minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
|
|
16
16
|
state passive jurisdiction label (server-detected upstream; never editable).
|
|
17
17
|
url app endpoint the DOB POSTs to (the app owns the authoritative check).
|
|
18
18
|
store Alpine modal store name backing close() (default "modals").
|
|
19
19
|
demo style-guide preview: resolve locally, skip the network POST.
|
|
20
|
+
demoUnderage demo only — take the REFUSED branch, so the style guide can
|
|
21
|
+
walk the handoff with no backend.
|
|
22
|
+
gateId modal id opened on a refusal (default "age-gate"). Blank falls back
|
|
23
|
+
to the inline error line, for a host mid-adoption that has not
|
|
24
|
+
registered the second card yet.
|
|
25
|
+
|
|
26
|
+
THE REFUSAL IS A HANDOFF, NOT AN ERROR (2026-08-24). This factory used to
|
|
27
|
+
early-return on an under-age date, leaving the modal red with its own button
|
|
28
|
+
disabled — the one state with nothing to press. `_reject` now opens the
|
|
29
|
+
age-gate card instead. `_finish` is unchanged: the success path still flips
|
|
30
|
+
session.ageVerified and dispatches `age-verified`, which is the hook consuming
|
|
31
|
+
apps already listen on, so nothing downstream had to change.
|
|
20
32
|
%>
|
|
21
33
|
<script>
|
|
22
34
|
(function () {
|
|
23
|
-
if (window.
|
|
24
|
-
window.
|
|
35
|
+
if (window.birthdayModal) return;
|
|
36
|
+
window.birthdayModal = function (opts) {
|
|
25
37
|
opts = opts || {};
|
|
26
38
|
var now = new Date();
|
|
27
39
|
return {
|
|
@@ -34,6 +46,10 @@
|
|
|
34
46
|
url: opts.url || "",
|
|
35
47
|
store: opts.store || "modals",
|
|
36
48
|
demo: !!opts.demo,
|
|
49
|
+
demoUnderage: !!opts.demoUnderage,
|
|
50
|
+
// Blank gateId => no second card registered yet; the refusal falls back
|
|
51
|
+
// to the error line so a host mid-adoption still SAYS something.
|
|
52
|
+
gateId: opts.gateId || "",
|
|
37
53
|
months: [
|
|
38
54
|
{ n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
|
|
39
55
|
{ n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
|
|
@@ -83,17 +99,58 @@
|
|
|
83
99
|
try { Alpine.store(this.store).close(); } catch (e) {}
|
|
84
100
|
window.dispatchEvent(new CustomEvent("age-verified"));
|
|
85
101
|
},
|
|
102
|
+
// Refusal path: hand off to the age-gate card, which owns the CTAs
|
|
103
|
+
// ("watch instead", "fix your birthday"). Deliberately NOT an error —
|
|
104
|
+
// the card that refuses is also the card that offers somewhere to go.
|
|
105
|
+
// swap(), not open(), so the back-link on that card returns HERE.
|
|
106
|
+
_reject(message) {
|
|
107
|
+
if (!this.gateId) {
|
|
108
|
+
// No gate registered (host mid-adoption): say it inline rather than
|
|
109
|
+
// closing silently, which would look like the date was accepted.
|
|
110
|
+
this.error = message || this._refusalLine();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
Alpine.store(this.store).swap(this.gateId, {
|
|
115
|
+
minAge: this.minAge,
|
|
116
|
+
state: this.stateCode,
|
|
117
|
+
message: message || "",
|
|
118
|
+
// The DATE, so the gate card can say WHEN rather than only "no".
|
|
119
|
+
// Passed as parts rather than a Date: this crosses a store, and a
|
|
120
|
+
// Date through Alpine's reactive proxy is a class waiting to be
|
|
121
|
+
// stringified by something downstream.
|
|
122
|
+
dobYear: parseInt(this.year, 10) || null,
|
|
123
|
+
dobMonth: parseInt(this.month, 10) || null,
|
|
124
|
+
dobDay: parseInt(this.day, 10) || null
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
this.error = message || this._refusalLine();
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
_refusalLine() {
|
|
131
|
+
return "You must be " + this.minAge + "+" +
|
|
132
|
+
(this.stateCode ? " in " + this.stateCode : "") + ".";
|
|
133
|
+
},
|
|
134
|
+
get isUnderage() {
|
|
135
|
+
return this.computedAge !== null && !!this.minAge && this.computedAge < this.minAge;
|
|
136
|
+
},
|
|
86
137
|
submit() {
|
|
87
138
|
if (!this.complete || this.submitting) return;
|
|
88
139
|
this.error = "";
|
|
89
|
-
//
|
|
90
|
-
|
|
140
|
+
// NO client-side refusal. This used to early-return on an under-age
|
|
141
|
+
// date, which is what left the card red with a disabled button. The
|
|
142
|
+
// date goes to the server like any other; the server recompute is and
|
|
143
|
+
// always was the authoritative check, and its verdict routes here.
|
|
91
144
|
this.submitting = true;
|
|
92
145
|
var self = this;
|
|
93
146
|
if (this.demo) {
|
|
94
|
-
// Style-guide preview: no backend
|
|
95
|
-
//
|
|
96
|
-
setTimeout(function () {
|
|
147
|
+
// Style-guide preview: no backend. demoUnderage picks which branch
|
|
148
|
+
// the local resolve takes, so the guide can walk BOTH outcomes.
|
|
149
|
+
setTimeout(function () {
|
|
150
|
+
self.submitting = false;
|
|
151
|
+
if (self.demoUnderage || self.isUnderage) self._reject();
|
|
152
|
+
else self._finish();
|
|
153
|
+
}, 450);
|
|
97
154
|
return;
|
|
98
155
|
}
|
|
99
156
|
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
|
@@ -102,11 +159,20 @@
|
|
|
102
159
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
103
160
|
body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
|
|
104
161
|
})
|
|
105
|
-
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
162
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
|
|
106
163
|
.then(function (res) {
|
|
107
164
|
self.submitting = false;
|
|
108
165
|
if (res.ok && res.body && res.body.verified) { self._finish(); return; }
|
|
109
|
-
|
|
166
|
+
// The app distinguishes "too young" from "bad request". An explicit
|
|
167
|
+
// underage verdict — body.underage, or a 403 — is a REFUSAL and
|
|
168
|
+
// routes to the gate card; anything else is a real error and stays
|
|
169
|
+
// on the red line here.
|
|
170
|
+
var body = res.body || {};
|
|
171
|
+
if (body.underage === true || res.status === 403) {
|
|
172
|
+
self._reject(body.error || body.message || "");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
self.error = body.error ||
|
|
110
176
|
"We couldn't verify your age. Please check the date and try again.";
|
|
111
177
|
})
|
|
112
178
|
.catch(function () {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
(studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
|
|
4
4
|
board component template would be cloned/re-parsed and never run, so a consumer
|
|
5
5
|
renders THIS once in its layout (like studio/_leveling_activity_assets homes
|
|
6
|
-
levelingActionModal and studio/
|
|
6
|
+
levelingActionModal and studio/_birthday_assets homes birthdayModal), and the
|
|
7
7
|
board anywhere below just references window.studioBoard. SortableJS is loaded
|
|
8
8
|
globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
|
|
9
9
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
inside a modal host's single-root template, and a script cloned out of a template
|
|
6
6
|
never runs. So a consumer renders THIS once in its layout (like the other shared
|
|
7
7
|
Alpine factories), and the modal partial anywhere below just references
|
|
8
|
-
window.levelingActionModal. Mirrors how studio/
|
|
9
|
-
|
|
8
|
+
window.levelingActionModal. Mirrors how studio/_birthday_assets homes the
|
|
9
|
+
birthdayModal factory.
|
|
10
10
|
|
|
11
11
|
CRITICAL BOUNDARY — this factory is UI ONLY. It POSTs to an app-supplied
|
|
12
12
|
submitUrl and reacts to a NEUTRAL JSON contract; it holds ZERO on-chain logic,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
site can recompute whatever it derives. Defaults to the
|
|
7
7
|
age-gate's onMonthChange(), which every current caller has.
|
|
8
8
|
|
|
9
|
-
ONE HOOK FOR ALL THREE, where
|
|
9
|
+
ONE HOOK FOR ALL THREE, where the birthday modal used onMonthChange()
|
|
10
10
|
for month/year and `error = ''` for day. That is not a
|
|
11
11
|
behaviour change: onMonthChange() clears an out-of-range day
|
|
12
12
|
and then clears the error, and a day just chosen FROM
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
years — the year range this caller offers
|
|
25
25
|
|
|
26
26
|
EXTRACTED BECAUSE IT WAS ALREADY DUPLICATED: these selects lived in
|
|
27
|
-
studio/modals/blocks/_age_verify
|
|
28
|
-
its own modals/_age_verify. Two copies
|
|
27
|
+
studio/modals/blocks/_birthday (named _age_verify until 2026-08-24), and
|
|
28
|
+
turf-monster forked a second copy into its own modals/_age_verify. Two copies
|
|
29
|
+
of one field is how they drift.
|
|
29
30
|
|
|
30
31
|
AND BECAUSE IT REPLACED SOMETHING WORSE. /profile/edit used a custom calendar
|
|
31
32
|
popover, which shipped four defects in three days — its grid collapsed in
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Age gate — the REFUSAL card. New 2026-08-24, and it exists because a refusal
|
|
3
|
+
needs somewhere to go.
|
|
4
|
+
|
|
5
|
+
WHAT IT REPLACED. blocks/_birthday (then blocks/_age_verify) used to carry the
|
|
6
|
+
refusal itself: enter an under-age date and the card turned red and disabled
|
|
7
|
+
its own submit button. That is the one screen state with nothing to press —
|
|
8
|
+
the person is told no and handed no next move, on a card whose only other
|
|
9
|
+
control closes it. This card is that next move.
|
|
10
|
+
|
|
11
|
+
THE TWO WAYS OUT, and both are real:
|
|
12
|
+
watch_url — the thing they CAN do. Being too young to enter is not being too
|
|
13
|
+
young to watch, so the primary CTA is a way to stay rather than
|
|
14
|
+
a way to leave. App-supplied: the engine has no idea what a
|
|
15
|
+
contest is.
|
|
16
|
+
the back link — the thing they might NEED. A wrong birthday is an ordinary
|
|
17
|
+
typo (a mis-picked year is one scroll away), and a gate with no
|
|
18
|
+
way back turns a typo into a locked door. swap()s back to the
|
|
19
|
+
birthday card, so this is a correction, not a restart.
|
|
20
|
+
|
|
21
|
+
DELIBERATELY NOT DISMISSIBLE-BY-DEFAULT-ONLY: the host still decides via
|
|
22
|
+
props.dismissible at open() time, exactly like every other card. What this
|
|
23
|
+
partial guarantees is that there is always a visible way forward even when the
|
|
24
|
+
host locks the backdrop.
|
|
25
|
+
|
|
26
|
+
NO LEGAL POLICY LIVES HERE, same rule as the birthday card. min_age and state
|
|
27
|
+
are passed in and only ever displayed; nothing here computes eligibility.
|
|
28
|
+
|
|
29
|
+
Locals:
|
|
30
|
+
min_age (Integer, optional) — for the headline. Falls back to props.minAge,
|
|
31
|
+
which _birthday's _reject passes, so a host that opens this card
|
|
32
|
+
through the handoff needs to supply nothing at all.
|
|
33
|
+
state (String, optional) — passive jurisdiction label. Same fallback.
|
|
34
|
+
watch_url (String, optional) — the primary CTA target. Omit and the CTA is
|
|
35
|
+
dropped rather than rendered dead.
|
|
36
|
+
watch_label (String, optional) — default "Watch the Contest".
|
|
37
|
+
birthday_modal_id (String, optional) — the card the back link returns to.
|
|
38
|
+
Default "birthday". Pass nil to drop the link.
|
|
39
|
+
back_label (String, optional) — default "Update your Birthday".
|
|
40
|
+
title (String, optional) — default is built from min_age.
|
|
41
|
+
countdown (Boolean, optional) — default true. FALSE drops the countdown line
|
|
42
|
+
entirely and falls back to the plain rule sentence. It does not
|
|
43
|
+
merely stop the clock: a frozen countdown reads as a rendering
|
|
44
|
+
artefact, which is the very thing this card exists not to be.
|
|
45
|
+
modal_store (String, optional) — Alpine store name. Default "modals".
|
|
46
|
+
|
|
47
|
+
Single root element (modal-host template mount rule).
|
|
48
|
+
%>
|
|
49
|
+
<%
|
|
50
|
+
min_age = local_assigns[:min_age]
|
|
51
|
+
state = local_assigns[:state].to_s.strip
|
|
52
|
+
countdown = local_assigns.fetch(:countdown, true)
|
|
53
|
+
watch_url = local_assigns[:watch_url].to_s
|
|
54
|
+
watch_label = local_assigns.fetch(:watch_label, "Watch the Contest")
|
|
55
|
+
birthday_modal_id = local_assigns.fetch(:birthday_modal_id, "birthday")
|
|
56
|
+
back_label = local_assigns.fetch(:back_label, "Update your Birthday")
|
|
57
|
+
title = local_assigns[:title]
|
|
58
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
59
|
+
%>
|
|
60
|
+
<div x-data="{
|
|
61
|
+
now: Date.now(),
|
|
62
|
+
_tick: null,
|
|
63
|
+
get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
|
|
64
|
+
get minAge() { return <%= min_age.present? ? min_age.to_i : 0 %> || this.props.minAge || null; },
|
|
65
|
+
get stateCode() { return '<%= j state %>' || this.props.state || ''; },
|
|
66
|
+
get headline() {
|
|
67
|
+
// Single-quoted + j-escaped, NOT to_json: to_json emits DOUBLE quotes,
|
|
68
|
+
// which close this double-quoted x-data attribute and mount the
|
|
69
|
+
// component as a silent no-op. The markup still renders, so every
|
|
70
|
+
// assertion passes while the card is dead in a browser.
|
|
71
|
+
var t = '<%= j title.to_s %>';
|
|
72
|
+
return t ? t : 'Easy, Young’un';
|
|
73
|
+
},
|
|
74
|
+
// The date they become eligible: their birthday, plus the app's minimum
|
|
75
|
+
// age in years. Built from PARTS the birthday card handed across the
|
|
76
|
+
// store, because a Date through a reactive proxy is a class waiting to be
|
|
77
|
+
// stringified by something downstream.
|
|
78
|
+
get eligibleAt() {
|
|
79
|
+
var p = this.props, y = p.dobYear, m = p.dobMonth, d = p.dobDay;
|
|
80
|
+
if (!y || !m || !d || !this.minAge) return null;
|
|
81
|
+
return new Date(y + this.minAge, m - 1, d);
|
|
82
|
+
},
|
|
83
|
+
// Add n months and CLAMP to the end of the target month. Bare setMonth
|
|
84
|
+
// overflows: Jan 31 + 1 month is Feb 31, which JS rolls forward to Mar 3,
|
|
85
|
+
// and the countdown then reports one month less than it should for every
|
|
86
|
+
// person born on a 29th, 30th or 31st.
|
|
87
|
+
addMonths(d, n) {
|
|
88
|
+
var day = d.getDate();
|
|
89
|
+
var r = new Date(d.getTime());
|
|
90
|
+
r.setDate(1);
|
|
91
|
+
r.setMonth(r.getMonth() + n);
|
|
92
|
+
var lastDay = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
|
|
93
|
+
r.setDate(day < lastDay ? day : lastDay);
|
|
94
|
+
return r;
|
|
95
|
+
},
|
|
96
|
+
get remaining() {
|
|
97
|
+
var target = this.eligibleAt;
|
|
98
|
+
if (!target) return null;
|
|
99
|
+
if (target.getTime() - this.now <= 0) return null;
|
|
100
|
+
|
|
101
|
+
// Calendar months and days, not 30-day approximations — this number is
|
|
102
|
+
// read as a real date difference and a rounded one is wrong by days.
|
|
103
|
+
var from = new Date(this.now), months = 0;
|
|
104
|
+
while (this.addMonths(from, months + 1).getTime() <= target.getTime()) months++;
|
|
105
|
+
var probe = this.addMonths(from, months);
|
|
106
|
+
|
|
107
|
+
// Days walked with setDate, NOT by dividing milliseconds: a day across a
|
|
108
|
+
// DST boundary is 23 or 25 hours, and ms-division reports 27d 23h where
|
|
109
|
+
// the calendar says 28d.
|
|
110
|
+
var days = 0;
|
|
111
|
+
while (true) {
|
|
112
|
+
var next = new Date(probe.getTime());
|
|
113
|
+
next.setDate(next.getDate() + 1);
|
|
114
|
+
if (next.getTime() > target.getTime()) break;
|
|
115
|
+
probe = next; days++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
var rest = target.getTime() - probe.getTime();
|
|
119
|
+
var hours = Math.floor(rest / 3600000); rest -= hours * 3600000;
|
|
120
|
+
var mins = Math.floor(rest / 60000); rest -= mins * 60000;
|
|
121
|
+
var secs = Math.floor(rest / 1000);
|
|
122
|
+
return { months: months, days: days, hours: hours, minutes: mins, seconds: secs };
|
|
123
|
+
},
|
|
124
|
+
get countdownText() {
|
|
125
|
+
var r = this.remaining;
|
|
126
|
+
if (!r) return '';
|
|
127
|
+
var unit = function (n, one) { return n + ' ' + one + (n === 1 ? '' : 's'); };
|
|
128
|
+
// YEARS, because the whole point of this line is that nobody should have
|
|
129
|
+
// to divide. Ninety months — the reachable worst case for a 13-year-old
|
|
130
|
+
// at a 21 bar — is exactly the is-that-a-week-or-a-decade arithmetic the
|
|
131
|
+
// comment below claims to remove. Months carry the remainder.
|
|
132
|
+
// (No double quotes in here: this whole object is one HTML attribute.)
|
|
133
|
+
var years = Math.floor(r.months / 12), months = r.months % 12;
|
|
134
|
+
var parts = [];
|
|
135
|
+
if (years) parts.push(unit(years, 'year'));
|
|
136
|
+
if (months || years) parts.push(unit(months, 'month'));
|
|
137
|
+
if (r.days || months || years) parts.push(unit(r.days, 'day'));
|
|
138
|
+
return parts.join(' ') + (parts.length ? ', ' : '') +
|
|
139
|
+
unit(r.hours, 'hour') + ', ' + unit(r.minutes, 'minute') + ', ' + unit(r.seconds, 'second');
|
|
140
|
+
},
|
|
141
|
+
start() { var s = this; this._tick = setInterval(function () { s.now = Date.now(); }, 1000); },
|
|
142
|
+
stop() { if (this._tick) { clearInterval(this._tick); this._tick = null; } },
|
|
143
|
+
// Alpine 3 calls destroy() ON THE DATA OBJECT at teardown and dispatches
|
|
144
|
+
// no `destroy` DOM event, so the `@destroy` listener this used to carry
|
|
145
|
+
// never fired and the interval outlived every close. Same idiom as the
|
|
146
|
+
// sibling cards that poll: modals/_web3_step_up, blocks/_cta_redirect,
|
|
147
|
+
// blocks/_seeds_bar.
|
|
148
|
+
destroy() { this.stop(); },
|
|
149
|
+
back() { $store.<%= modal_store %>.swap('<%= j birthday_modal_id.to_s %>', {}); }
|
|
150
|
+
}"
|
|
151
|
+
<% if countdown %>x-init="start()"<% end %>
|
|
152
|
+
class="relative">
|
|
153
|
+
<%# A TEDDY BEAR, not a red X. The X pill is the engine's error icon and this
|
|
154
|
+
is not an error — it is a person who will be welcome later. The card that
|
|
155
|
+
turns someone away is the last place to reach for alarm styling; the copy
|
|
156
|
+
does the same work ("Easy, Young’un" rather than "You must be 21+"),
|
|
157
|
+
and the two have to agree or the card reads as scolding.
|
|
158
|
+
|
|
159
|
+
title_key, not title: the headline may arrive as a LOCAL or fall back to
|
|
160
|
+
the default, and the block below replaces the subtitle area. %>
|
|
161
|
+
<%= render "studio/modals/blocks/card_header",
|
|
162
|
+
size: :lg,
|
|
163
|
+
icon_emoji: "🧸",
|
|
164
|
+
title_key: "headline" do %>
|
|
165
|
+
<%# WHEN, not just no. A refusal that only says "not yet" leaves the person
|
|
166
|
+
to work out whether that means a week or a decade; the countdown answers
|
|
167
|
+
it in the units they actually feel. It ticks live because a frozen
|
|
168
|
+
number reads as a rendering artefact rather than a clock. %>
|
|
169
|
+
<template x-if="<%= countdown ? 'countdownText' : 'false' %>">
|
|
170
|
+
<p class="text-sm text-body">
|
|
171
|
+
You have <strong class="text-heading" x-text="countdownText"></strong>
|
|
172
|
+
before you can join your first contest.
|
|
173
|
+
<span x-show="stateCode" x-cloak>
|
|
174
|
+
The minimum age <span x-text="'in ' + stateCode"></span> is
|
|
175
|
+
<strong class="text-heading" x-text="minAge"></strong>.
|
|
176
|
+
</span>
|
|
177
|
+
</p>
|
|
178
|
+
</template>
|
|
179
|
+
|
|
180
|
+
<%# No date to count from (the card was opened directly, or the handoff
|
|
181
|
+
carried no DOB). Say the rule plainly rather than an empty sentence. %>
|
|
182
|
+
<template x-if="<%= countdown ? '!countdownText' : 'true' %>">
|
|
183
|
+
<p class="text-sm text-body">
|
|
184
|
+
<span x-show="stateCode" x-cloak>
|
|
185
|
+
The minimum age <span x-text="'in ' + stateCode"></span> is
|
|
186
|
+
<strong class="text-heading" x-text="minAge"></strong>.
|
|
187
|
+
</span>
|
|
188
|
+
<span x-show="!stateCode">You are not old enough to enter this contest yet.</span>
|
|
189
|
+
</p>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<%# A server-authored reason, when there is one. Never invented here. %>
|
|
193
|
+
<template x-if="props.message">
|
|
194
|
+
<p class="text-sm text-muted mt-2" x-text="props.message"></p>
|
|
195
|
+
</template>
|
|
196
|
+
<% end %>
|
|
197
|
+
|
|
198
|
+
<%# Breathing room before the CTA. card_header's block sits flush against
|
|
199
|
+
whatever follows it, and a full-width primary button landing directly on
|
|
200
|
+
the last line of body copy reads as one block rather than as a sentence
|
|
201
|
+
and then a choice. %>
|
|
202
|
+
<div class="mt-5"></div>
|
|
203
|
+
|
|
204
|
+
<% if watch_url.present? %>
|
|
205
|
+
<%# The primary action is the thing they CAN do. Too young to enter is not
|
|
206
|
+
too young to watch, so this keeps them in rather than showing them out. %>
|
|
207
|
+
<%= link_to watch_label, watch_url,
|
|
208
|
+
class: "btn btn-primary btn-lg w-full",
|
|
209
|
+
"@click": "$store.#{modal_store}.close()" %>
|
|
210
|
+
<% end %>
|
|
211
|
+
|
|
212
|
+
<% if birthday_modal_id.present? %>
|
|
213
|
+
<%# The correction path. A mis-picked year is one scroll away from a right
|
|
214
|
+
one, and without this the typo is indistinguishable from the verdict. %>
|
|
215
|
+
<button type="button" @click="back()"
|
|
216
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition underline-offset-2 hover:underline">
|
|
217
|
+
<%= back_label %>
|
|
218
|
+
</button>
|
|
219
|
+
<% end %>
|
|
220
|
+
|
|
221
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
222
|
+
class="block mx-auto mt-3 text-sm text-muted hover:text-secondary transition">
|
|
223
|
+
Close
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Birthday modal — engine primitive. Collects a real date of birth
|
|
3
|
+
(Month / Day / Year) and submits it. Homed in the engine as the heavier sibling
|
|
4
|
+
of studio/modals/shared/_age_attestation (the lighter one-checkbox legal
|
|
5
|
+
attestation). RUN ONE OR THE OTHER, not both: the attestation is a signup-time
|
|
6
|
+
"I confirm I'm of legal age" checkbox; this is an entry-time DOB gate that a
|
|
7
|
+
server can recompute and stamp.
|
|
8
|
+
|
|
9
|
+
RENAMED 2026-08-24, and the rename carries a behaviour change. This partial was
|
|
10
|
+
`blocks/_age_verify` and it owned BOTH halves of the question: it collected the
|
|
11
|
+
date AND, when the date failed, it turned red and disabled its own button. That
|
|
12
|
+
is a dead end — the one screen state with nothing to press and nowhere to go.
|
|
13
|
+
The two halves are now two cards:
|
|
14
|
+
|
|
15
|
+
THIS ONE (birthday) — asks. Always submittable.
|
|
16
|
+
blocks/_age_gate — answers, when the answer is no. Carries the CTAs
|
|
17
|
+
(watch instead / fix the date), so the refusal is a
|
|
18
|
+
card with somewhere to go rather than red text.
|
|
19
|
+
|
|
20
|
+
So an under-age date no longer disables the button; it SUBMITS, and the app's
|
|
21
|
+
verdict opens the age-gate card. The inline red text that used to carry the
|
|
22
|
+
refusal is gone. What remains red here is a genuine ERROR — an invalid date, a
|
|
23
|
+
network failure — which is a different thing from "you are too young", and
|
|
24
|
+
conflating the two is what made the old state a dead end.
|
|
25
|
+
|
|
26
|
+
CRITICAL — the engine hardcodes NO legal policy. It renders the modal UI, the
|
|
27
|
+
DOB fields, and the submit; everything legal is app-supplied:
|
|
28
|
+
|
|
29
|
+
min_age (Integer, OPTIONAL) — the app's minimum age. There is NO engine
|
|
30
|
+
default (18 is itself a policy value); the app resolves it from
|
|
31
|
+
its own policy against its own server-detected jurisdiction.
|
|
32
|
+
ABSENT (or 0) = this app asks for a birthday and does not gate on
|
|
33
|
+
it. That is a first-class mode, not a degraded one: a hub app may
|
|
34
|
+
want the date for a greeting and have no bar at all, and the card
|
|
35
|
+
then drops the age line, the eligibility wording and the refusal
|
|
36
|
+
handoff rather than rendering "You must be 0+".
|
|
37
|
+
submit_url (String, REQUIRED) — the app endpoint the DOB POSTs to. The
|
|
38
|
+
app owns the authoritative recompute + DOB persistence; the modal
|
|
39
|
+
only shows the response.
|
|
40
|
+
state (String, optional) — a PASSIVE jurisdiction label for the copy
|
|
41
|
+
(e.g. "CA"). Server-detected upstream; the modal never detects
|
|
42
|
+
geography and never offers an editable state field (a spoofable
|
|
43
|
+
client state must not be able to lower the bar). Blank => the
|
|
44
|
+
jurisdiction clause is dropped.
|
|
45
|
+
title (String, optional) — modal title. Default "Your birthday"
|
|
46
|
+
(it was "Verify your age" under the old _age_verify name).
|
|
47
|
+
intro (String, optional) — the lead sentence after the age line.
|
|
48
|
+
fine_print (String, optional) — the app's legal / jurisdiction copy (e.g.
|
|
49
|
+
its per-state age table). The engine ships only a neutral,
|
|
50
|
+
policy-free default; the app passes its real copy here.
|
|
51
|
+
modal_store (String, optional) — Alpine store backing close(). Default "modals".
|
|
52
|
+
age_gate_modal_id (String, optional) — the id opened when the app’s verdict is
|
|
53
|
+
UNDER age. Default "age-gate". Pass nil to keep the old
|
|
54
|
+
inline-refusal behaviour (the refusal then falls back to the
|
|
55
|
+
error line), which exists only so a host mid-adoption is not
|
|
56
|
+
forced to register a second modal on the same deploy.
|
|
57
|
+
demo (Boolean, optional) — style-guide preview: resolve locally, no POST.
|
|
58
|
+
demo_underage (Boolean, optional) — style-guide preview ONLY: make the local
|
|
59
|
+
resolve take the REFUSED branch, so the guide can show the
|
|
60
|
+
handoff without a backend. Ignored unless demo.
|
|
61
|
+
|
|
62
|
+
Requires window.birthdayModal — render studio/_birthday_assets once at
|
|
63
|
+
layout level (the factory can’t ship inside this template; a cloned script
|
|
64
|
+
never runs). Single root element (modal-host template mount rule).
|
|
65
|
+
%>
|
|
66
|
+
<%
|
|
67
|
+
# min_age is OPTIONAL now. Absent (or 0) means this app asks for a birthday and
|
|
68
|
+
# does not gate on it — McRitchie Studio wants the date for a greeting, not for
|
|
69
|
+
# eligibility. Present means the app validates, and the shape of that validation
|
|
70
|
+
# is the APP's: turf-monster's varies by jurisdiction, which is exactly why the
|
|
71
|
+
# engine takes a resolved number and a passive label rather than a rule.
|
|
72
|
+
min_age = local_assigns[:min_age]
|
|
73
|
+
validates = min_age.to_i.positive?
|
|
74
|
+
submit_url = local_assigns.fetch(:submit_url)
|
|
75
|
+
state = local_assigns[:state].to_s.strip
|
|
76
|
+
title = local_assigns.fetch(:title, "Your birthday")
|
|
77
|
+
intro = local_assigns.fetch(:intro,
|
|
78
|
+
validates ? "Enter your date of birth to confirm you're eligible — we only ask once."
|
|
79
|
+
: "Enter your date of birth — we only ask once.")
|
|
80
|
+
fine_print = local_assigns.fetch(:fine_print,
|
|
81
|
+
validates ? "We use your date of birth only to confirm eligibility."
|
|
82
|
+
: "We use your date of birth only to know when to wish you a happy birthday.")
|
|
83
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
84
|
+
demo = local_assigns.fetch(:demo, false)
|
|
85
|
+
demo_underage = local_assigns.fetch(:demo_underage, false)
|
|
86
|
+
age_gate_modal_id = local_assigns.fetch(:age_gate_modal_id, "age-gate")
|
|
87
|
+
%>
|
|
88
|
+
<div x-data="birthdayModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %>, demoUnderage: <%= demo_underage ? 'true' : 'false' %>, gateId: '<%= j age_gate_modal_id.to_s %>' })">
|
|
89
|
+
<%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
|
|
90
|
+
<%# The age line only exists when the app HAS a bar. A card that says
|
|
91
|
+
"You must be 0+" is what an unconditional line produces for a host that
|
|
92
|
+
just wants the date. %>
|
|
93
|
+
<p class="text-sm text-muted mb-4">
|
|
94
|
+
<% if validates %>
|
|
95
|
+
You must be <span class="text-heading font-semibold"><%= min_age %>+</span><%= state.present? ? " in #{state}" : "" %>.
|
|
96
|
+
<% end %>
|
|
97
|
+
<%= intro %>
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
<%# Month / Day / Year — the engine's one DOB field, shared with the profile
|
|
101
|
+
page's birthday row. It lived here first and turf-monster forked a second
|
|
102
|
+
copy; extracting it is what stops the two drifting. %>
|
|
103
|
+
<div class="mb-4">
|
|
104
|
+
<%= render "studio/fields/date_of_birth" %>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<%# NO inline too-young hint. It used to live here, and turning the card red
|
|
108
|
+
while disabling its own button is the dead end this rename removed — the
|
|
109
|
+
refusal is now its own card (blocks/_age_gate) with somewhere to go.
|
|
110
|
+
|
|
111
|
+
This line stays for genuine ERRORS: an invalid date, a network failure.
|
|
112
|
+
A refusal is not an error, and showing them in the same red paragraph is
|
|
113
|
+
what let the dead end hide. %>
|
|
114
|
+
<template x-if="error">
|
|
115
|
+
<p class="text-red-400 text-sm mb-3" x-text="error"></p>
|
|
116
|
+
</template>
|
|
117
|
+
|
|
118
|
+
<%# Disabled ONLY for an incomplete date or an in-flight submit. It used to be
|
|
119
|
+
disabled for an under-age date too, which is precisely the dead end: the
|
|
120
|
+
one state where the card had nothing to press. An under-age date now
|
|
121
|
+
submits like any other and the app’s verdict opens the age-gate card. %>
|
|
122
|
+
<button type="button" @click="submit()"
|
|
123
|
+
class="btn btn-primary btn-lg w-full"
|
|
124
|
+
:disabled="!complete || submitting">
|
|
125
|
+
<span x-show="!submitting">Confirm & Continue</span>
|
|
126
|
+
<span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
|
|
127
|
+
<span class="cta-spinner" aria-hidden="true"></span>
|
|
128
|
+
Verifying…
|
|
129
|
+
</span>
|
|
130
|
+
</button>
|
|
131
|
+
|
|
132
|
+
<p class="text-[11px] text-muted text-center mt-3 leading-snug"><%= fine_print %></p>
|
|
133
|
+
<% end %>
|
|
134
|
+
</div>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
second one badly.
|
|
11
11
|
|
|
12
12
|
THE SAME FIELD THE AGE GATE USES (operator's call, 2026-08-15). It renders
|
|
13
|
-
studio/fields/_date_of_birth, which studio/modals/blocks/
|
|
13
|
+
studio/fields/_date_of_birth, which studio/modals/blocks/_birthday also
|
|
14
14
|
renders — one field, two surfaces. It replaced a custom calendar popover that
|
|
15
15
|
shipped four defects in three days; see the component script for the list.
|
|
16
16
|
|