studio-engine 0.59.0 → 0.60.1
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/components/_geo_signpost.html.erb +49 -8
- 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/_web3_step_up.html.erb +292 -0
- 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 +403 -89
- 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/_quest_success.html.erb +55 -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_setup.html.erb +112 -0
- data/lib/studio/version.rb +1 -1
- metadata +13 -5
- data/app/views/studio/modals/blocks/_age_verify.html.erb +0 -82
- data/app/views/style/modals/_age_verify.html.erb +0 -17
|
@@ -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
|
|