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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d48986ae997517eba2f2ae27edf0ef145069a69d09382ee2bdced6ff7839a630
|
|
4
|
+
data.tar.gz: e2e6b4b71af934b23536a94a03352e0340f47b8add6398f485f1f9c1262c240b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96fce3596b86f2ea40f0cce0e8801cb57dc0d848e00d68f4b151b72885944fe99b346e49241d0ece5d79ae8a7293e4829ad5b055e38bddb12e3a8ee3730f8169
|
|
7
|
+
data.tar.gz: c5003ca60f6d27a5ea6c81b40ca50902f0ce6df4f11fc2a127392e1993af1147209f13a580c18006b69585b7ce1e8882b7da50dc30cfbd8c05ad4c7daf4edb50
|
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
|
|
@@ -47,11 +47,40 @@
|
|
|
47
47
|
which one is missing rather than a vague "unavailable". %>
|
|
48
48
|
<% geo_routes = respond_to?(:admin_geo_path) %>
|
|
49
49
|
<% geo_live = Studio.geo_blocking_enabled? && geo_routes %>
|
|
50
|
-
<% geo_hint = geo_routes ? "Set ENABLE_GEO_BLOCKING=true" : "Draw the geo routes first" %>
|
|
51
|
-
<% geo_title = geo_routes ? "Set ENABLE_GEO_BLOCKING=true to manage geo blocking" : "Set config.draw_geo_routes = true in config/initializers/studio.rb, then ENABLE_GEO_BLOCKING=true" %>
|
|
52
|
-
<% geo_close = local_assigns[:close_action].presence %>
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
<%# THE HINT IS TWO PIECES, prose and machine string, and they are separate
|
|
52
|
+
elements on purpose. The subtitle exists to hand the operator a string to
|
|
53
|
+
copy, and that string is one unbreakable token — no spaces, and neither an
|
|
54
|
+
underscore nor an "=" is a break opportunity. Left as one run of text it
|
|
55
|
+
measured 187px inside the dropdown's 142px content box and painted past the
|
|
56
|
+
panel's right border, in every theme and at every width.
|
|
57
|
+
|
|
58
|
+
Splitting it lets the break class land on the token ALONE: prose still
|
|
59
|
+
wraps at word boundaries, and only the machine string breaks mid-character.
|
|
60
|
+
The class is `break-all`, not `break-words`, deliberately — the engine
|
|
61
|
+
ships no compiled CSS of its own, so a utility reaches a consumer only when
|
|
62
|
+
that consumer's Tailwind build scans these views. `break-all` is already
|
|
63
|
+
carried by engine views that print S3 keys, emails and magic URLs, so it is
|
|
64
|
+
in every consumer's output today; `break-words` appears nowhere in the
|
|
65
|
+
engine, and a consumer on a stale build would silently render this row with
|
|
66
|
+
no break at all — the bug again, in production. Widening the panel was the
|
|
67
|
+
third option and was declined: w-44 is the shared admin menu for every app,
|
|
68
|
+
and one row's subtitle is not a reason to move it.
|
|
69
|
+
|
|
70
|
+
The code string is also the ANSWER to the title-only instruction. In the
|
|
71
|
+
no-routes branch the initializer line used to live only in `title`, which
|
|
72
|
+
is mouse-only and invisible on touch; it is visible text now, and `title`
|
|
73
|
+
keeps the long form with the file path. %>
|
|
74
|
+
<% geo_lead = geo_routes ? "Set" : "Draw the geo routes first" %>
|
|
75
|
+
<% geo_code = geo_routes ? "ENABLE_GEO_BLOCKING=true" : "config.draw_geo_routes = true" %>
|
|
76
|
+
<% geo_title = geo_routes ? "Set ENABLE_GEO_BLOCKING=true to manage geo blocking" : "Set config.draw_geo_routes = true in config/initializers/studio.rb, then ENABLE_GEO_BLOCKING=true" %>
|
|
77
|
+
<% geo_close = local_assigns[:close_action].presence %>
|
|
78
|
+
|
|
79
|
+
<%# `local_assigns[...].presence`, not `fetch(:variant, :dropdown)`. fetch only
|
|
80
|
+
defaults an ABSENT key, so a caller computing the variant inline —
|
|
81
|
+
`variant: sidebar? && :sidebar` — hands over an explicit nil, and the old
|
|
82
|
+
`nil.to_sym` raised NoMethodError inside admin chrome on every page. %>
|
|
83
|
+
<% if local_assigns[:variant].presence&.to_sym == :sidebar %>
|
|
55
84
|
<%# Sidebar variant: the wide emoji row the link sidebar's own sections use,
|
|
56
85
|
so an engine-owned row is indistinguishable from a host-declared one. %>
|
|
57
86
|
<% if geo_live %>
|
|
@@ -65,13 +94,21 @@
|
|
|
65
94
|
</span>
|
|
66
95
|
<% end %>
|
|
67
96
|
<% else %>
|
|
68
|
-
|
|
97
|
+
<%# No `select-none` here, and that is the fix rather than an omission: the
|
|
98
|
+
row's whole job in this state is to hand over the string below, and
|
|
99
|
+
select-none made it unselectable. role/aria-disabled give a screen
|
|
100
|
+
reader the "unavailable destination" this paints for a sighted eye. %>
|
|
101
|
+
<span class="flex items-start gap-3 px-4 py-3 text-sm text-muted cursor-not-allowed"
|
|
102
|
+
role="link"
|
|
103
|
+
aria-disabled="true"
|
|
69
104
|
data-test="geo-signpost-disabled"
|
|
70
105
|
title="<%= geo_title %>">
|
|
71
106
|
<span class="text-lg leading-none flex-shrink-0">🌍</span>
|
|
72
107
|
<span class="min-w-0">
|
|
73
108
|
<span class="block font-medium truncate">Geo</span>
|
|
74
|
-
<span class="block text-xs text-muted leading-snug mt-0.5"><%=
|
|
109
|
+
<span class="block text-xs text-muted leading-snug mt-0.5"><%= geo_lead %>
|
|
110
|
+
<code class="font-mono break-all"><%= geo_code %></code>
|
|
111
|
+
</span>
|
|
75
112
|
</span>
|
|
76
113
|
</span>
|
|
77
114
|
<% end %>
|
|
@@ -81,11 +118,15 @@
|
|
|
81
118
|
<% if geo_live %>
|
|
82
119
|
<a href="<%= admin_geo_path %>" class="block px-4 py-2 text-sm text-body hover:text-heading hover:bg-surface-alt transition">Geo</a>
|
|
83
120
|
<% else %>
|
|
84
|
-
<span class="block px-4 py-2 text-sm text-muted cursor-not-allowed
|
|
121
|
+
<span class="block px-4 py-2 text-sm text-muted cursor-not-allowed"
|
|
122
|
+
role="link"
|
|
123
|
+
aria-disabled="true"
|
|
85
124
|
data-test="geo-signpost-disabled"
|
|
86
125
|
title="<%= geo_title %>">
|
|
87
126
|
Geo
|
|
88
|
-
<span class="block text-xs text-muted"><%=
|
|
127
|
+
<span class="block text-xs text-muted"><%= geo_lead %>
|
|
128
|
+
<code class="font-mono break-all"><%= geo_code %></code>
|
|
129
|
+
</span>
|
|
89
130
|
</span>
|
|
90
131
|
<% end %>
|
|
91
132
|
<% end %>
|
|
@@ -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,292 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Web3 step-up — the shared "your session cannot sign on-chain" card.
|
|
3
|
+
|
|
4
|
+
THE SITUATION IT NAMES: an account that holds a self-custody wallet signs in
|
|
5
|
+
with a WEB2 credential — a magic link, email, or Google. Both facts are
|
|
6
|
+
ordinary alone; together they mean the person is signed in but cannot sign
|
|
7
|
+
anything on-chain. `SessionContext` has modelled that intersection since it was
|
|
8
|
+
lifted into this engine (`web2?` AND `phantom_linked?`) and nothing acted on
|
|
9
|
+
it, so every web3 Studio app logged a wallet owner straight in with no web3
|
|
10
|
+
beat at all. This partial is the missing step; SessionContext stays the state.
|
|
11
|
+
|
|
12
|
+
Ported from turf-monster (2026-08-21), where it shipped first as the working
|
|
13
|
+
vertical. What is NOT shared — and deliberately stays in the host — is WHEN to
|
|
14
|
+
show it and what it costs to dismiss: the host's policy decides who sees the
|
|
15
|
+
card, and the host's gates keep their own teeth. This partial owns ONE step.
|
|
16
|
+
|
|
17
|
+
ADVISORY BY CONSTRUCTION. The card is dismissible and says so. A self-custody
|
|
18
|
+
wallet is the one credential a host cannot reset for a user, so a card that
|
|
19
|
+
could not be closed would lock a legitimate owner out of their own account
|
|
20
|
+
over a wallet they merely cannot reach right now. That is why the escape hatch
|
|
21
|
+
below is not optional decoration — and why enforcement must live in the host's
|
|
22
|
+
on-chain gates, never in this card.
|
|
23
|
+
|
|
24
|
+
Locals (all optional, defaults via local_assigns.fetch):
|
|
25
|
+
heading — (default "Sign in with your wallet")
|
|
26
|
+
subtext — the one-line why
|
|
27
|
+
help_url — the escape hatch for a user who cannot reach their wallet
|
|
28
|
+
(default "/help"). Set to nil to drop the line entirely.
|
|
29
|
+
help_label — (default "Get help")
|
|
30
|
+
picker_modal_id — the host's brand picker, reached by "Use a different
|
|
31
|
+
wallet" (default "wallet-connect"). The picker is expected
|
|
32
|
+
to honour a `backTo` prop pointing back at this card.
|
|
33
|
+
modal_id — this card's own id, passed to the picker as backTo
|
|
34
|
+
(default "web3-step-up")
|
|
35
|
+
modal_store — Alpine store name (default "modals"; the living style
|
|
36
|
+
guide mounts a page-scoped host and passes "dsModals")
|
|
37
|
+
dismiss_event — window event dispatched on dismissal (default
|
|
38
|
+
"web3-step-up-dismissed"). The HOST decides what happens
|
|
39
|
+
next — typically releasing an onboarding chain it held
|
|
40
|
+
while this card had the screen. This partial never opens
|
|
41
|
+
another modal itself.
|
|
42
|
+
|
|
43
|
+
PROPS (set by the opener, all optional — the host's policy produces them):
|
|
44
|
+
provider — normalised brand key ('phantom' / 'solflare' / 'backpack'),
|
|
45
|
+
or null when the account linked its wallet before the host
|
|
46
|
+
started remembering the brand.
|
|
47
|
+
providerLabel — how that brand writes its own name, for the row.
|
|
48
|
+
walletHint — the truncated address (4…4), so the user can confirm the
|
|
49
|
+
card is asking for the wallet they think it is.
|
|
50
|
+
|
|
51
|
+
WITH a remembered provider this is ONE row. Without one it is the same card
|
|
52
|
+
with the picker as its primary action — the fallback is never a dead end.
|
|
53
|
+
|
|
54
|
+
CONTRACT WITH THE HOST'S JS. Signing calls `window.solanaConnectAndVerify(name,
|
|
55
|
+
{ linkMode: false })`, the global every web3 Studio app already provides, and
|
|
56
|
+
hands a success to `window.handleSolanaVerifySuccess` when present. linkMode is
|
|
57
|
+
deliberately FALSE: the link path binds to the current user but does not grant
|
|
58
|
+
the on-chain session, which is the thing this card exists to obtain. One
|
|
59
|
+
inherited consequence, stated plainly: signing with a DIFFERENT wallet signs
|
|
60
|
+
you into that wallet's account, exactly as a standalone wallet button does. The
|
|
61
|
+
walletHint is shown precisely so that is a visible choice, not a surprise.
|
|
62
|
+
|
|
63
|
+
CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
|
|
64
|
+
host, so it must have ONE root element, and the x-data below is a
|
|
65
|
+
DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
|
|
66
|
+
included) closes it early and the whole component mounts as a silent no-op that
|
|
67
|
+
still renders markup. Keep every inner string SINGLE-quoted.
|
|
68
|
+
%>
|
|
69
|
+
<%
|
|
70
|
+
heading = local_assigns.fetch(:heading, "Sign in with your wallet")
|
|
71
|
+
subtext = local_assigns.fetch(:subtext,
|
|
72
|
+
"This account is secured by a Solana wallet. You are signed in, but this " \
|
|
73
|
+
"session can’t sign on-chain — so on-chain actions still need your wallet.")
|
|
74
|
+
help_url = local_assigns.fetch(:help_url, "/help")
|
|
75
|
+
help_label = local_assigns.fetch(:help_label, "Get help")
|
|
76
|
+
picker_modal_id = local_assigns.fetch(:picker_modal_id, "wallet-connect")
|
|
77
|
+
modal_id = local_assigns.fetch(:modal_id, "web3-step-up")
|
|
78
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
79
|
+
dismiss_event = local_assigns.fetch(:dismiss_event, "web3-step-up-dismissed")
|
|
80
|
+
%>
|
|
81
|
+
<div x-data="{
|
|
82
|
+
get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
|
|
83
|
+
connecting: false,
|
|
84
|
+
error: '',
|
|
85
|
+
// Set when the remembered wallet turns out not to be reachable in THIS
|
|
86
|
+
// browser (a different machine, the extension removed). It flips the card
|
|
87
|
+
// to the picker rather than leaving the user pressing a button that
|
|
88
|
+
// cannot work.
|
|
89
|
+
providerMissing: false,
|
|
90
|
+
// Which wallets this document can actually see. Read on a poll, NOT once
|
|
91
|
+
// at mount: wallet provider registration fills in asynchronously, and
|
|
92
|
+
// this card auto-opens on the render right after auth — the worst
|
|
93
|
+
// possible moment. A single early read is a coin flip that would badge an
|
|
94
|
+
// installed wallet as missing.
|
|
95
|
+
wallets: [],
|
|
96
|
+
_poll: null,
|
|
97
|
+
_onRegister: null,
|
|
98
|
+
get provider() { return this.props.provider || null; },
|
|
99
|
+
get providerLabel() { return this.props.providerLabel || null; },
|
|
100
|
+
get walletHint() { return this.props.walletHint || null; },
|
|
101
|
+
// One-click only while we remember a brand AND it is still reachable.
|
|
102
|
+
get canOneClick() { return !!this.provider && !this.providerMissing; },
|
|
103
|
+
// Does the remembered brand answer in THIS document right now? Drives the
|
|
104
|
+
// Installed badge, the same one the connect picker shows, so a detected
|
|
105
|
+
// wallet reads identically in both places.
|
|
106
|
+
get detected() {
|
|
107
|
+
var name = ('' + (this.provider || '')).toLowerCase();
|
|
108
|
+
if (!name) return false;
|
|
109
|
+
return (this.wallets || []).some(function(w) { return w.name && w.name.toLowerCase() === name; });
|
|
110
|
+
},
|
|
111
|
+
init() {
|
|
112
|
+
var self = this;
|
|
113
|
+
this.refresh();
|
|
114
|
+
this._onRegister = function() { if (!self.connecting) self.refresh(); };
|
|
115
|
+
window.addEventListener('wallet-standard:register-wallet', this._onRegister);
|
|
116
|
+
this._poll = setInterval(function() {
|
|
117
|
+
if (self.connecting) return;
|
|
118
|
+
self.refresh();
|
|
119
|
+
if (self.wallets.length > 0) { clearInterval(self._poll); self._poll = null; }
|
|
120
|
+
}, 1000);
|
|
121
|
+
},
|
|
122
|
+
destroy() {
|
|
123
|
+
if (this._poll) clearInterval(this._poll);
|
|
124
|
+
if (this._onRegister) window.removeEventListener('wallet-standard:register-wallet', this._onRegister);
|
|
125
|
+
},
|
|
126
|
+
refresh() {
|
|
127
|
+
this.wallets = (window.walletProvider && window.walletProvider.available && window.walletProvider.available()) || [];
|
|
128
|
+
},
|
|
129
|
+
reachable(name) {
|
|
130
|
+
if (!window.walletProvider || !window.walletProvider.get) return false;
|
|
131
|
+
return !!window.walletProvider.get(name);
|
|
132
|
+
},
|
|
133
|
+
async signIn() {
|
|
134
|
+
if (this.connecting) return;
|
|
135
|
+
var name = this.provider;
|
|
136
|
+
if (!name) return this.openPicker();
|
|
137
|
+
if (!this.reachable(name)) {
|
|
138
|
+
this.providerMissing = true;
|
|
139
|
+
this.error = 'We could not reach your ' + (this.providerLabel || 'wallet') + ' in this browser.';
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (typeof window.solanaConnectAndVerify !== 'function') {
|
|
143
|
+
this.error = 'Wallet sign-in is unavailable on this page.';
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
this.connecting = true; this.error = '';
|
|
147
|
+
try {
|
|
148
|
+
var result = await window.solanaConnectAndVerify(name, { linkMode: false });
|
|
149
|
+
if (result && result.success) {
|
|
150
|
+
if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
|
|
151
|
+
// Reload rather than follow a redirect: the user was already
|
|
152
|
+
// somewhere, and the only thing that changed is that this session
|
|
153
|
+
// can now sign. Sending them to a post-login landing would move
|
|
154
|
+
// them off the page they were reading.
|
|
155
|
+
window.location.reload();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.error = (result && result.error) || 'Verification failed.';
|
|
159
|
+
} catch (e) {
|
|
160
|
+
var msg = (e && e.code === 4001) ? 'Signature rejected' : ((e && e.message) || 'Connection failed');
|
|
161
|
+
if (typeof parseSolanaError === 'function') msg = parseSolanaError(msg);
|
|
162
|
+
this.error = msg;
|
|
163
|
+
}
|
|
164
|
+
this.connecting = false;
|
|
165
|
+
},
|
|
166
|
+
// Hand off to the host's brand picker. swap() rather than open() so the
|
|
167
|
+
// picker's back arrow returns HERE instead of stranding the user.
|
|
168
|
+
openPicker() {
|
|
169
|
+
if (this.connecting) return;
|
|
170
|
+
$store.<%= modal_store %>.swap('<%= picker_modal_id %>',
|
|
171
|
+
{ backTo: '<%= modal_id %>', stepUpProps: this.props, returnUrl: window.location.href });
|
|
172
|
+
},
|
|
173
|
+
// Dismiss. Dispatched so the HOST knows this card is done and can release
|
|
174
|
+
// whatever it was holding — this partial never opens another modal.
|
|
175
|
+
dismiss() {
|
|
176
|
+
if (this.connecting) return;
|
|
177
|
+
window.dispatchEvent(new CustomEvent('<%= dismiss_event %>'));
|
|
178
|
+
$store.<%= modal_store %>.close();
|
|
179
|
+
}
|
|
180
|
+
}"
|
|
181
|
+
class="relative">
|
|
182
|
+
|
|
183
|
+
<%# Brand sprite — MUST live inside this card's single root so the host's
|
|
184
|
+
<template x-if> clones it along with the card. A sibling of the root is
|
|
185
|
+
dropped and the icon paints empty. %>
|
|
186
|
+
<%= render "studio/modals/blocks/wallet_brand_sprite" %>
|
|
187
|
+
|
|
188
|
+
<div class="relative mb-1">
|
|
189
|
+
<button @click="dismiss()" :disabled="connecting"
|
|
190
|
+
class="absolute top-0 right-0 -mr-1 text-secondary hover:text-heading text-xl leading-none disabled:opacity-50"
|
|
191
|
+
aria-label="Close">×</button>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<%= render "studio/modals/blocks/card_header", icon_emoji: "🔐", title: heading do %>
|
|
195
|
+
<p class="text-sm text-body"><%= subtext %></p>
|
|
196
|
+
<% end %>
|
|
197
|
+
|
|
198
|
+
<%# PRIMARY — THE STANDARD WEB3 AUTH BUTTON: a wallet row, not a filled CTA.
|
|
199
|
+
Same shape the connect picker uses — brand mark, the wallet's own name, an
|
|
200
|
+
Installed badge, chevron — so a wallet reads identically everywhere it is
|
|
201
|
+
offered and this card does not invent a third look for one action.
|
|
202
|
+
|
|
203
|
+
It carries `pulse-cta` (engine-motion) because it is the ONE target on the
|
|
204
|
+
card and the whole point of the card is that the user should press it. %>
|
|
205
|
+
<template x-if="canOneClick">
|
|
206
|
+
<div>
|
|
207
|
+
<button type="button" @click="signIn()" :disabled="connecting"
|
|
208
|
+
style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
|
|
209
|
+
class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60 disabled:cursor-wait">
|
|
210
|
+
<span class="w-9 h-9 rounded-lg overflow-hidden flex items-center justify-center shrink-0">
|
|
211
|
+
<svg class="w-9 h-9" aria-hidden="true"><use :href="'#se-wallet-' + provider"></use></svg>
|
|
212
|
+
</span>
|
|
213
|
+
<span class="font-semibold text-heading" x-text="providerLabel"></span>
|
|
214
|
+
<span class="ml-auto flex items-center gap-2">
|
|
215
|
+
<span x-show="connecting" class="inline-flex items-center gap-1.5 text-xs text-secondary"><span class="spinner" aria-hidden="true"></span>Connecting…</span>
|
|
216
|
+
<span x-show="!connecting && detected" class="badge border-primary text-primary">Installed</span>
|
|
217
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4 text-muted">
|
|
218
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
219
|
+
</svg>
|
|
220
|
+
</span>
|
|
221
|
+
</button>
|
|
222
|
+
<%# One line on what pressing it DOES. A signature prompt is alarming
|
|
223
|
+
without it, and "no funds move" is the difference between clicking and
|
|
224
|
+
bailing. The address rides here so the user can confirm the card is
|
|
225
|
+
asking for the wallet they think it is. %>
|
|
226
|
+
<p class="text-[11px] text-muted mt-1.5 px-1">
|
|
227
|
+
Signing proves the wallet is yours — it does not move any funds.
|
|
228
|
+
<template x-if="walletHint">
|
|
229
|
+
<span>Wallet <span class="font-mono text-secondary" x-text="walletHint"></span></span>
|
|
230
|
+
</template>
|
|
231
|
+
</p>
|
|
232
|
+
</div>
|
|
233
|
+
</template>
|
|
234
|
+
|
|
235
|
+
<%# No remembered brand — the same row shape with no mark to show, so the two
|
|
236
|
+
halves of this card look like one card. %>
|
|
237
|
+
<template x-if="!canOneClick">
|
|
238
|
+
<button type="button" @click="openPicker()" :disabled="connecting"
|
|
239
|
+
style="--pulse-cta-color: var(--color-primary); --pulse-cta-strength: 0.4; --pulse-cta-scale: 1.02"
|
|
240
|
+
class="pulse-cta w-full flex items-center gap-3 p-3 rounded-xl bg-surface-alt border border-strong hover:bg-surface transition text-left disabled:opacity-60">
|
|
241
|
+
<%# A neutral wallet mark, NOT an emoji. This tile sits inches from a real
|
|
242
|
+
brand mark, so a platform emoji (the first pass used U+1F45B PURSE)
|
|
243
|
+
reads as a pink handbag next to Phantom and is the one thing on the
|
|
244
|
+
card that belongs to no design system. Stroked in the same idiom as
|
|
245
|
+
the chevron beside it, on theme tokens.
|
|
246
|
+
|
|
247
|
+
A billfold: body plus clasp, and NO band across the top. The first
|
|
248
|
+
drawing had one and it read as a credit-card magstripe at 20px — the
|
|
249
|
+
wrong object on a card whose whole subject is a wallet. %>
|
|
250
|
+
<span class="w-9 h-9 rounded-lg bg-inset flex items-center justify-center shrink-0" aria-hidden="true">
|
|
251
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 text-secondary">
|
|
252
|
+
<rect x="3" y="6.5" width="18" height="11" rx="2.5" />
|
|
253
|
+
<circle cx="16.6" cy="12" r="1.3" fill="currentColor" stroke="none" />
|
|
254
|
+
</svg>
|
|
255
|
+
</span>
|
|
256
|
+
<span class="font-semibold text-heading">Connect your wallet</span>
|
|
257
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="ml-auto w-4 h-4 text-muted">
|
|
258
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
259
|
+
</svg>
|
|
260
|
+
</button>
|
|
261
|
+
</template>
|
|
262
|
+
|
|
263
|
+
<template x-if="error">
|
|
264
|
+
<p class="text-red-400 text-sm mt-3 text-center" x-text="error"></p>
|
|
265
|
+
</template>
|
|
266
|
+
|
|
267
|
+
<%# SECONDARY — always reachable, so a user whose remembered wallet is the
|
|
268
|
+
wrong one is never cornered by our memory of it. %>
|
|
269
|
+
<template x-if="canOneClick">
|
|
270
|
+
<button type="button" @click="openPicker()" :disabled="connecting"
|
|
271
|
+
class="mt-3 block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
272
|
+
Use a different wallet
|
|
273
|
+
</button>
|
|
274
|
+
</template>
|
|
275
|
+
|
|
276
|
+
<div class="mt-4 pt-3 border-t border-strong">
|
|
277
|
+
<button type="button" @click="dismiss()" :disabled="connecting"
|
|
278
|
+
class="block w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
279
|
+
Not now
|
|
280
|
+
</button>
|
|
281
|
+
<%# THE ESCAPE HATCH. A self-custody wallet is the one credential a host
|
|
282
|
+
cannot reset for the user, so the card must not end without a way to
|
|
283
|
+
reach a human. Droppable only by passing help_url: nil, which is a
|
|
284
|
+
deliberate act at the callsite rather than an omission. %>
|
|
285
|
+
<% if help_url.present? %>
|
|
286
|
+
<p class="mt-2 text-center text-[11px] text-muted">
|
|
287
|
+
Can’t access your wallet?
|
|
288
|
+
<a href="<%= help_url %>" class="underline hover:text-secondary"><%= help_label %></a>
|
|
289
|
+
</p>
|
|
290
|
+
<% end %>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|