studio-engine 0.74.7 → 0.74.8
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 +802 -631
- data/README.md +5 -2
- data/app/views/studio/modals/blocks/_close_x.html.erb +6 -4
- data/app/views/studio/modals/blocks/_rail_row.html.erb +3 -1
- data/app/views/style/_modals.html.erb +190 -380
- data/app/views/style/modals/_ds_email_field.html.erb +58 -0
- data/app/views/style/modals/_ds_resend_footer.html.erb +70 -0
- data/app/views/style/modals/_ds_stack_demo.html.erb +63 -0
- data/app/views/style/modals/_wallet_connect.html.erb +26 -12
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +4 -2
- metadata +4 -6
- data/app/views/style/modals/_auth.html.erb +0 -307
- data/app/views/style/modals/_ds_onramp_hub.html.erb +0 -71
- data/app/views/style/modals/_ds_wallet_topup.html.erb +0 -69
- data/app/views/style/modals/_entry_tokens.html.erb +0 -188
- data/app/views/style/modals/_onchain_tx.html.erb +0 -86
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Living style guide specimen — the modal family's email input,
|
|
3
|
+
studio/modals/shared/_email_field.
|
|
4
|
+
|
|
5
|
+
THIS IS A BLOCK DEMO, NOT A CARD. The engine does not own an auth MODAL — the
|
|
6
|
+
sign-in card is each app's, mounted on that app's own host and reviewed on that
|
|
7
|
+
app's own section of this guide. What the engine owns is the PIECES that card
|
|
8
|
+
is built from, and this is the one every app's auth card renders. It had no
|
|
9
|
+
specimen of its own until 2026-09-09 because style/modals/_auth, a mirror of
|
|
10
|
+
turf-monster's card, happened to render it — so retiring the mirror is what
|
|
11
|
+
made the gap visible.
|
|
12
|
+
|
|
13
|
+
BOTH FACES ARE SHOWN because `validator` is a real fork, not a flag. Off, the
|
|
14
|
+
partial is self-contained and the SERVER stays the only boundary; on, it wraps
|
|
15
|
+
itself in x-data="emailValidator()" and paints a right-edge spinner / check / X
|
|
16
|
+
that only works if the CONSUMER ships that factory. An app that passes
|
|
17
|
+
validator: true without the factory gets an Alpine scope error and a dead
|
|
18
|
+
indicator, so the two are drawn side by side to make the dependency legible.
|
|
19
|
+
This page ships no emailValidator, which is why the right-hand field's
|
|
20
|
+
indicator stays blank — that is the demo, not a defect.
|
|
21
|
+
|
|
22
|
+
Single root: the outer <div> is the host's required root.
|
|
23
|
+
%>
|
|
24
|
+
<div class="relative">
|
|
25
|
+
<%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
|
|
26
|
+
|
|
27
|
+
<div class="text-center pt-1 mb-4">
|
|
28
|
+
<h3 class="text-heading font-bold text-lg leading-tight">Email field</h3>
|
|
29
|
+
<p class="text-xs text-muted mt-1">studio/modals/shared/_email_field</p>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="space-y-4" x-data="{ email: '' }">
|
|
33
|
+
<div>
|
|
34
|
+
<p class="text-2xs font-bold text-secondary uppercase tracking-wide mb-1.5">Plain</p>
|
|
35
|
+
<%= render "studio/modals/shared/email_field",
|
|
36
|
+
name: nil, x_model: "email", required: true %>
|
|
37
|
+
<p class="text-2xs text-muted mt-1.5">
|
|
38
|
+
No dependency beyond the theme. <code class="font-mono">x_model</code>
|
|
39
|
+
resolves in the ENCLOSING scope, so the value lands on the host component
|
|
40
|
+
rather than on the field.
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div>
|
|
45
|
+
<p class="text-2xs font-bold text-secondary uppercase tracking-wide mb-1.5">With validator</p>
|
|
46
|
+
<%= render "studio/modals/shared/email_field",
|
|
47
|
+
name: nil, x_model: "email", required: true, validator: true %>
|
|
48
|
+
<p class="text-2xs text-muted mt-1.5">
|
|
49
|
+
Adds <code class="font-mono">x-data="emailValidator()"</code> and the
|
|
50
|
+
right-edge indicator. <strong>The factory is the consumer’s to
|
|
51
|
+
ship</strong> — this guide does not, so the indicator stays blank here.
|
|
52
|
+
</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<button type="button" @click="$store.dsModals.close()"
|
|
57
|
+
class="btn btn-primary btn-lg w-full mt-5">Done</button>
|
|
58
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Living style guide specimen — the magic-link resend footer,
|
|
3
|
+
studio/modals/auth/_resend_footer.
|
|
4
|
+
|
|
5
|
+
A BLOCK DEMO, for the same reason as _ds_email_field: the engine owns this
|
|
6
|
+
fragment, not the auth card that renders it. Both of the auth card's
|
|
7
|
+
link-sent steps render it, and they are identical — which is the whole reason
|
|
8
|
+
it was extracted.
|
|
9
|
+
|
|
10
|
+
IT NEEDS A HOST, AND THAT IS THE POINT. The footer reads props.submitting,
|
|
11
|
+
props.resendCooldown and props.resendError, and calls resendMagicLink(), all
|
|
12
|
+
resolved from the ENCLOSING x-data — never from a store. So the x-data below
|
|
13
|
+
is a stand-in for the auth card's own component, and it is the contract this
|
|
14
|
+
specimen documents: drop this partial into a scope that supplies none of those
|
|
15
|
+
and nothing errors, the link simply never enables and the cooldown never
|
|
16
|
+
counts. The stub here drives a real 5s cooldown so all three states are
|
|
17
|
+
reachable: idle, in-flight, counting down.
|
|
18
|
+
|
|
19
|
+
THE COOLDOWN IS SHORTENED ON PURPOSE. Production counts 60s; a reviewer will
|
|
20
|
+
not wait 60s to watch a number tick, and a specimen nobody watches documents
|
|
21
|
+
nothing. The partial does not own the duration — the host does.
|
|
22
|
+
|
|
23
|
+
Single root: the outer <div> is the host's required root.
|
|
24
|
+
%>
|
|
25
|
+
<div class="relative"
|
|
26
|
+
x-data="{
|
|
27
|
+
props: { submitting: null, resendCooldown: 0, resendError: '' },
|
|
28
|
+
_timer: null,
|
|
29
|
+
resendMagicLink() {
|
|
30
|
+
if (this.props.submitting || this.props.resendCooldown > 0) return;
|
|
31
|
+
this.props.resendError = '';
|
|
32
|
+
this.props.submitting = 'magic-link';
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
this.props.submitting = null;
|
|
35
|
+
this.props.resendCooldown = 5;
|
|
36
|
+
this._timer = setInterval(() => {
|
|
37
|
+
this.props.resendCooldown -= 1;
|
|
38
|
+
if (this.props.resendCooldown <= 0) clearInterval(this._timer);
|
|
39
|
+
}, 1000);
|
|
40
|
+
}, 900);
|
|
41
|
+
},
|
|
42
|
+
fail() {
|
|
43
|
+
this.props.submitting = null;
|
|
44
|
+
this.props.resendCooldown = 0;
|
|
45
|
+
this.props.resendError = 'That link could not be sent. Try again in a moment.';
|
|
46
|
+
},
|
|
47
|
+
// Alpine calls destroy() when the component leaves the DOM. Without it the
|
|
48
|
+
// interval outlives the closed card and keeps decrementing a dead object.
|
|
49
|
+
destroy() { clearInterval(this._timer); }
|
|
50
|
+
}">
|
|
51
|
+
<%= render "studio/modals/blocks/close_x", modal_store: "dsModals" %>
|
|
52
|
+
|
|
53
|
+
<div class="text-center pt-1 mb-4">
|
|
54
|
+
<div class="text-2xl leading-none mb-2">📬</div>
|
|
55
|
+
<h3 class="text-heading font-bold text-lg leading-tight">Check your email</h3>
|
|
56
|
+
<p class="text-xs text-body mt-1">
|
|
57
|
+
The footer below is the shared piece — everything above it belongs to
|
|
58
|
+
the app’s own card.
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div class="border-t border-subtle pt-4">
|
|
63
|
+
<%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<button type="button" @click="fail()"
|
|
67
|
+
class="block mx-auto mt-4 text-2xs text-muted hover:text-secondary underline underline-offset-2">
|
|
68
|
+
Show the error line
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Living style guide specimen — THE STACK-MECHANICS VEHICLE.
|
|
3
|
+
|
|
4
|
+
This card exists to be POKED AT, not to be copied. Everything under
|
|
5
|
+
"Stack behaviour" below the specimen grid — dismissibility, the
|
|
6
|
+
minimum-visible-duration floor, LIFO stacking, and advance() — needs a card on
|
|
7
|
+
the stack to demonstrate itself on, and the mechanics are the engine's while
|
|
8
|
+
the card is incidental.
|
|
9
|
+
|
|
10
|
+
IT REPLACED A MIRROR, AND THAT IS THE POINT. Until 2026-09-09 every one of
|
|
11
|
+
those demos ran on style/modals/_onchain_tx, a copy of turf-monster's
|
|
12
|
+
on-chain card. Borrowing a consumer's card as a test vehicle is how a copy
|
|
13
|
+
earns tenure: it drifts from the original, and deleting it takes six unrelated
|
|
14
|
+
demos down with it. This card is built from engine blocks only
|
|
15
|
+
(studio/modals/blocks/_card_header), belongs to no app, and answers to nothing
|
|
16
|
+
but the store — so it can never disagree with a card that ships.
|
|
17
|
+
|
|
18
|
+
THREE FACES ON ONE ID, which is exactly what advance() needs. props.state
|
|
19
|
+
picks the face and the entry is never replaced, so a timed transition patches a
|
|
20
|
+
LIVE card rather than pushing a new one. Three separate ids could not show that
|
|
21
|
+
— and the three cards those ids would name (ds-processing, ds-success,
|
|
22
|
+
ds-error) already have their own specimens in System & status, where they are
|
|
23
|
+
the subject rather than the vehicle.
|
|
24
|
+
|
|
25
|
+
It deliberately does NOT auto-resolve. blocks/_processing_card takes a
|
|
26
|
+
resolve_expr and self-terminates, which is right for a specimen of that block
|
|
27
|
+
and wrong here: a card that closes itself cannot demonstrate that Escape is
|
|
28
|
+
locked.
|
|
29
|
+
|
|
30
|
+
Single root: the outer <div> is the host's required root.
|
|
31
|
+
%>
|
|
32
|
+
<div x-data="{ get props() { var c = Alpine.store('dsModals').current(); return (c && c.props) || {}; } }">
|
|
33
|
+
<template x-if="(props.state || 'processing') === 'processing'">
|
|
34
|
+
<div>
|
|
35
|
+
<%= render "studio/modals/blocks/card_header",
|
|
36
|
+
spinner: true,
|
|
37
|
+
title_key: "props.title || 'Working…'",
|
|
38
|
+
subtitle_key: "props.message || 'This card is the stack-mechanics vehicle.'" %>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<template x-if="props.state === 'success'">
|
|
43
|
+
<div>
|
|
44
|
+
<%= render "studio/modals/blocks/card_header",
|
|
45
|
+
icon_color: "success",
|
|
46
|
+
title_key: "props.title || 'Done'",
|
|
47
|
+
subtitle_key: "props.message || 'Resolved in place — the stack entry was never replaced.'" %>
|
|
48
|
+
<button type="button" @click="$store.dsModals.close()"
|
|
49
|
+
class="btn btn-primary btn-lg w-full">Close</button>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<template x-if="props.state === 'error'">
|
|
54
|
+
<div>
|
|
55
|
+
<%= render "studio/modals/blocks/card_header",
|
|
56
|
+
icon: :error,
|
|
57
|
+
title_key: "props.title || 'That did not work'",
|
|
58
|
+
subtitle_key: "props.message || 'The error face of the same stack entry.'" %>
|
|
59
|
+
<button type="button" @click="$store.dsModals.close()"
|
|
60
|
+
class="btn btn-outline btn-lg w-full">Close</button>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
</div>
|
|
@@ -55,29 +55,43 @@
|
|
|
55
55
|
},
|
|
56
56
|
onInit() { this.ageAttested = this.props.ageAttested === true; },
|
|
57
57
|
// DEMO walk continuity: the picked wallet resolves, then the card swaps to
|
|
58
|
-
// the
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// the stack-mechanics vehicle in its processing face, so the walk still ends
|
|
59
|
+
// somewhere rather than on a closed overlay.
|
|
60
|
+
//
|
|
61
|
+
// IT SWAPPED TO 'onchain-tx' UNTIL 2026-09-09, and that id is gone — it named
|
|
62
|
+
// a mirror of turf-monster's card. A swap to an UNREGISTERED id is the
|
|
63
|
+
// quietest failure this page has: the store accepts it, the overlay stays
|
|
64
|
+
// open, every <template x-if> misses, and the card renders EMPTY. Nothing
|
|
65
|
+
// throws and nothing logs. Keep this target on an id registered in
|
|
66
|
+
// style/_modals.html.erb.
|
|
61
67
|
onConnected() {
|
|
62
68
|
this.connecting = false;
|
|
63
69
|
this.picking = '';
|
|
64
|
-
Alpine.store('dsModals').swap('
|
|
65
|
-
state: 'processing',
|
|
70
|
+
Alpine.store('dsModals').swap('ds-stack-demo', {
|
|
71
|
+
state: 'processing',
|
|
66
72
|
title: 'Confirming on-chain',
|
|
67
73
|
message: 'Waiting for the wallet signature\u2026'
|
|
68
74
|
});
|
|
75
|
+
setTimeout(function () {
|
|
76
|
+
var c = Alpine.store('dsModals').current();
|
|
77
|
+
if (!c || c.id !== 'ds-stack-demo' || c._closing) return;
|
|
78
|
+
Alpine.store('dsModals').advance({
|
|
79
|
+
state: 'success', title: 'Wallet connected',
|
|
80
|
+
message: 'The picker handed off and this card resolved in place.'
|
|
81
|
+
});
|
|
82
|
+
}, 1600);
|
|
69
83
|
},
|
|
70
84
|
// No app to deep-link into from the guide — treat the mobile Phantom row
|
|
71
85
|
// as picking Phantom so the card still walks somewhere.
|
|
72
86
|
onDeepLink() { this.pick('Phantom'); },
|
|
87
|
+
// A REAL APP SWAPS BACK TO ITS OWN SIGN-IN CARD HERE, reading props.backTo
|
|
88
|
+
// — that is the partial's contract and solana-studio still honours it. This
|
|
89
|
+
// GUIDE cannot: the engine ships no auth card, and the 'auth' branch that
|
|
90
|
+
// used to live here pointed at style/modals/_auth, the retired
|
|
91
|
+
// turf-monster mirror. An unregistered swap target renders an EMPTY card
|
|
92
|
+
// silently, so the branch was removed rather than left aimed at nothing.
|
|
73
93
|
onBack() {
|
|
74
|
-
|
|
75
|
-
Alpine.store('dsModals').swap('auth',
|
|
76
|
-
{ step: 'credentials', submitting: null, formError: '' },
|
|
77
|
-
{ direction: 'back' });
|
|
78
|
-
} else {
|
|
79
|
-
Alpine.store('dsModals').close();
|
|
80
|
-
}
|
|
94
|
+
Alpine.store('dsModals').close();
|
|
81
95
|
}
|
|
82
96
|
JS
|
|
83
97
|
%>
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -916,8 +916,10 @@ module Studio
|
|
|
916
916
|
# the OAuth popup DO stay app-side.
|
|
917
917
|
#
|
|
918
918
|
# The gate is auth_methods, NOT auth_methods && feature?(:web3), even though
|
|
919
|
-
#
|
|
920
|
-
#
|
|
919
|
+
# an app's auth modal computes its wallet button from both (turf-monster's
|
|
920
|
+
# app/views/modals/_auth.html.erb; this engine ships no auth card, and the
|
|
921
|
+
# style-guide copy that used to be cited here was a mirror of turf's,
|
|
922
|
+
# retired 2026-09-09). Deliberate: auth_methods says
|
|
921
923
|
# which CREDENTIALS this app accepts, features gates PRODUCT SURFACES, and
|
|
922
924
|
# these three paths are the credential exchange itself. phantom_callback is
|
|
923
925
|
# the mobile deep-link RETURN url — a wallet app that declared :wallet but
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.74.
|
|
4
|
+
version: 0.74.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -519,17 +519,15 @@ files:
|
|
|
519
519
|
- app/views/style/board/_demo_card.html.erb
|
|
520
520
|
- app/views/style/index.html.erb
|
|
521
521
|
- app/views/style/modals/_age_gate.html.erb
|
|
522
|
-
- app/views/style/modals/_auth.html.erb
|
|
523
522
|
- app/views/style/modals/_birthday.html.erb
|
|
524
523
|
- app/views/style/modals/_ds_close_x.html.erb
|
|
525
|
-
- app/views/style/modals/
|
|
524
|
+
- app/views/style/modals/_ds_email_field.html.erb
|
|
526
525
|
- app/views/style/modals/_ds_rail_row.html.erb
|
|
527
|
-
- app/views/style/modals/
|
|
526
|
+
- app/views/style/modals/_ds_resend_footer.html.erb
|
|
527
|
+
- app/views/style/modals/_ds_stack_demo.html.erb
|
|
528
528
|
- app/views/style/modals/_email_change_pending.html.erb
|
|
529
|
-
- app/views/style/modals/_entry_tokens.html.erb
|
|
530
529
|
- app/views/style/modals/_it_begins.html.erb
|
|
531
530
|
- app/views/style/modals/_newsletter_email.html.erb
|
|
532
|
-
- app/views/style/modals/_onchain_tx.html.erb
|
|
533
531
|
- app/views/style/modals/_rate_limit_general.html.erb
|
|
534
532
|
- app/views/style/modals/_unsubscribe_confirm.html.erb
|
|
535
533
|
- app/views/style/modals/_wallet_connect.html.erb
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
<%#
|
|
2
|
-
Auth wizard — the design-system port of turf-monster's modals/_auth. The
|
|
3
|
-
canonical sign-in step machine, faithful to the real modal's UX, wired to the
|
|
4
|
-
living style guide's page-scoped host ($store.dsModals) so it opens live on any
|
|
5
|
-
app (including McRitchie Studio) without an auth backend.
|
|
6
|
-
|
|
7
|
-
Steps (opened at any step via open('auth', { step, ... })):
|
|
8
|
-
credentials — Google + Solana + email magic-link request
|
|
9
|
-
magic-link-sent — "check your inbox" after the email request
|
|
10
|
-
magic-link-resent — success confirmation after a resend (directional slide)
|
|
11
|
-
redirect — countdown auto-redirect (drain CTA demo)
|
|
12
|
-
|
|
13
|
-
What differs from the production modal: the tokens / USDC funding sub-flow is
|
|
14
|
-
dropped (that is contest-entry-specific), and every credential CTA runs behind
|
|
15
|
-
a page stub — window.postMagicLink resolves { success: true } so the magic-link
|
|
16
|
-
step machine advances for real; Google briefly shows its waiting state then
|
|
17
|
-
resets; Solana swaps to the wallet-connect picker (a real ported specimen).
|
|
18
|
-
No real auth happens — the demo shows the real UI.
|
|
19
|
-
|
|
20
|
-
LOCALS:
|
|
21
|
-
web3_gem (Boolean, required) — does solana-studio resolve, meaning is the
|
|
22
|
-
wallet-connect picker on the view path. style/_modals owns the lookup and
|
|
23
|
-
registers wallet-connect behind the same flag. Fetched without a default on
|
|
24
|
-
purpose: a caller that forgets it should fail loudly here rather than
|
|
25
|
-
silently drop Solana sign-in from an app that has the gem.
|
|
26
|
-
|
|
27
|
-
THE CREDENTIAL SLOT. Google and magic-link are implemented by this engine, so
|
|
28
|
-
they render below directly. Wallet is not: it belongs to the web3 bolt-on, and
|
|
29
|
-
a base-template app — a newsletter, a hub — must not carry markup it can never
|
|
30
|
-
render. So the wallet button is CONTRIBUTED by whichever layer implements it,
|
|
31
|
-
as a partial this file merely looks for:
|
|
32
|
-
|
|
33
|
-
solana_studio/auth/wallet_credential
|
|
34
|
-
|
|
35
|
-
Bundling the gem IS the registration. An app without that layer renders
|
|
36
|
-
nothing here and carries no wallet markup at all, rather than shipping a
|
|
37
|
-
button hidden behind a flag.
|
|
38
|
-
|
|
39
|
-
TWO LAYERS ANSWER TWO DIFFERENT QUESTIONS, and they are deliberately NOT
|
|
40
|
-
merged into one:
|
|
41
|
-
|
|
42
|
-
Ruby, below IS IT IMPLEMENTED. Is the picker this button swaps to
|
|
43
|
-
registered (web3_gem), and does a layer actually ship the
|
|
44
|
-
credential partial. This gates the RENDER.
|
|
45
|
-
Alpine, in the SHOULD IT SHOW. methodOn('wallet'), which reads
|
|
46
|
-
contributed props.methods first so the Sign in card's toggle overrides
|
|
47
|
-
partial the server default outright. This gates VISIBILITY.
|
|
48
|
-
|
|
49
|
-
KEEPING POLICY OUT OF THE RUBY GATE IS LOAD-BEARING, and it is why
|
|
50
|
-
auth_method?(:wallet) and feature?(:web3) are NOT terms in the render gate.
|
|
51
|
-
Fold them in and the button leaves the DOM on a web3-off app that bundles the
|
|
52
|
-
gem — and then ticking Solana Wallet on the Sign in card turns the divider
|
|
53
|
-
below on above a button that is not there, because the divider reads
|
|
54
|
-
methodOn('wallet') too. Measured: that is the exact failure this slot was
|
|
55
|
-
meant to remove, reintroduced one layer up.
|
|
56
|
-
|
|
57
|
-
NOT gated on the slot, deliberately: _methodDefaults.wallet below. It stays
|
|
58
|
-
the app's own policy answer and is unchanged by the move. It is the fallback
|
|
59
|
-
methodOn uses when an opener passes no methods hash, and on a base app nothing
|
|
60
|
-
reaches it — the Sign in card always passes explicit booleans, and the only
|
|
61
|
-
opener that omits them is the wallet picker's back button, which needs the gem
|
|
62
|
-
to exist at all.
|
|
63
|
-
|
|
64
|
-
CRITICAL: rendered inside <template x-if="id==='auth'"> — Alpine requires a
|
|
65
|
-
SINGLE root, so everything lives inside the outer <div>. The x-data is a
|
|
66
|
-
double-quoted attribute: keep it free of double-quotes and backticks.
|
|
67
|
-
%>
|
|
68
|
-
<% web3_gem = local_assigns.fetch(:web3_gem) %>
|
|
69
|
-
<%# BOTH terms are load-bearing, and they fail differently.
|
|
70
|
-
|
|
71
|
-
web3_gem is the picker this button swaps to actually REGISTERED. A layer
|
|
72
|
-
that ships the credential partial without the picker draws a button that
|
|
73
|
-
opens an empty panel — not hypothetical: solana-studio 0.5.2 shipped
|
|
74
|
-
exactly that pair, the credential without wallet_connect.
|
|
75
|
-
exists? does a layer ship the button AT ALL. Without it, an app whose
|
|
76
|
-
picker resolves but whose credential does not raises Missing partial in
|
|
77
|
-
front of someone signing in, instead of rendering no button.
|
|
78
|
-
|
|
79
|
-
Same three-term lookup_context.exists? the modal host uses for host_extras
|
|
80
|
-
(studio/modals/_host.html.erb): name, prefixes, partial. %>
|
|
81
|
-
<% wallet_credential = "solana_studio/auth/wallet_credential" %>
|
|
82
|
-
<% wallet_credential_available = web3_gem &&
|
|
83
|
-
lookup_context.exists?("wallet_credential", ["solana_studio/auth"], true) %>
|
|
84
|
-
<div x-data="{
|
|
85
|
-
email: '',
|
|
86
|
-
ageAttested: false,
|
|
87
|
-
ageError: false,
|
|
88
|
-
googleTimer: null,
|
|
89
|
-
attested() {
|
|
90
|
-
if (!this.termsOn()) return true;
|
|
91
|
-
if (this.ageAttested) { this.ageError = false; return true; }
|
|
92
|
-
this.ageError = true;
|
|
93
|
-
return false;
|
|
94
|
-
},
|
|
95
|
-
get props() {
|
|
96
|
-
var c = Alpine.store('dsModals').current();
|
|
97
|
-
return (c && c.props) || {};
|
|
98
|
-
},
|
|
99
|
-
// Method configuration — which credential methods render. props.methods
|
|
100
|
-
// overrides per-key; unset keys fall back to the app's Studio.auth_method?
|
|
101
|
-
// (wallet also needs the web3 capability). props.terms gates the age
|
|
102
|
-
// attestation. This standardizes the engine auth modal as method-configurable.
|
|
103
|
-
_methodDefaults: { magicLink: <%= Studio.auth_method?(:magic_link) %>, google: <%= Studio.auth_method?(:google) %>, wallet: <%= Studio.auth_method?(:wallet) && Studio.feature?(:web3) %> },
|
|
104
|
-
_termsDefault: true,
|
|
105
|
-
methodOn(m) {
|
|
106
|
-
var v = this.props.methods;
|
|
107
|
-
return (v && typeof v[m] === 'boolean') ? v[m] : !!this._methodDefaults[m];
|
|
108
|
-
},
|
|
109
|
-
termsOn() {
|
|
110
|
-
var t = this.props.terms;
|
|
111
|
-
return (typeof t === 'boolean') ? t : this._termsDefault;
|
|
112
|
-
},
|
|
113
|
-
get isCredentialsStep() {
|
|
114
|
-
var s = this.props.step;
|
|
115
|
-
if (!s) return true;
|
|
116
|
-
if (s === 'redirect' || s === 'magic-link-sent' || s === 'magic-link-resent') return false;
|
|
117
|
-
return true;
|
|
118
|
-
},
|
|
119
|
-
loginGoogle() {
|
|
120
|
-
if (!this.attested()) return;
|
|
121
|
-
var p = this.props;
|
|
122
|
-
if (!p || p.submitting) return;
|
|
123
|
-
p.googleError = '';
|
|
124
|
-
p.submitting = 'google';
|
|
125
|
-
var self = this;
|
|
126
|
-
this.googleTimer = setTimeout(function () {
|
|
127
|
-
var pp = self.props;
|
|
128
|
-
if (pp) pp.submitting = null;
|
|
129
|
-
}, 1200);
|
|
130
|
-
},
|
|
131
|
-
async submitMagicLink() {
|
|
132
|
-
if (!this.attested()) return;
|
|
133
|
-
var p = this.props;
|
|
134
|
-
if (!p || p.submitting) return;
|
|
135
|
-
p.formError = '';
|
|
136
|
-
if (!this.email) { p.formError = 'Enter your email.'; return; }
|
|
137
|
-
p.submitting = 'magic-link';
|
|
138
|
-
try {
|
|
139
|
-
var data = await window.postMagicLink(this.email, { ageAttested: true });
|
|
140
|
-
if (!data || !data.success) { p.formError = (data && data.error) || 'Could not send the link. Try again.'; p.submitting = null; return; }
|
|
141
|
-
Alpine.store('dsModals').advance({ step: 'magic-link-sent', sentEmail: this.email, submitting: null, formError: '' });
|
|
142
|
-
} catch (e) {
|
|
143
|
-
var p2 = this.props;
|
|
144
|
-
if (p2) { p2.formError = 'Network error. Try again.'; p2.submitting = null; }
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
_resendTimer: null,
|
|
148
|
-
startResendCooldown(seconds) {
|
|
149
|
-
var self = this;
|
|
150
|
-
var p = this.props;
|
|
151
|
-
if (!p) return;
|
|
152
|
-
p.resendCooldown = seconds || 60;
|
|
153
|
-
if (this._resendTimer) clearInterval(this._resendTimer);
|
|
154
|
-
this._resendTimer = setInterval(function () {
|
|
155
|
-
var pp = self.props;
|
|
156
|
-
if (!pp) { clearInterval(self._resendTimer); self._resendTimer = null; return; }
|
|
157
|
-
pp.resendCooldown = (pp.resendCooldown || 0) - 1;
|
|
158
|
-
if (pp.resendCooldown <= 0) {
|
|
159
|
-
pp.resendCooldown = 0;
|
|
160
|
-
clearInterval(self._resendTimer);
|
|
161
|
-
self._resendTimer = null;
|
|
162
|
-
}
|
|
163
|
-
}, 1000);
|
|
164
|
-
},
|
|
165
|
-
async resendMagicLink() {
|
|
166
|
-
var p = this.props;
|
|
167
|
-
if (!p || p.submitting || (p.resendCooldown || 0) > 0) return;
|
|
168
|
-
p.resendError = '';
|
|
169
|
-
p.submitting = 'magic-link';
|
|
170
|
-
try {
|
|
171
|
-
var data = await window.postMagicLink(p.sentEmail, { ageAttested: true });
|
|
172
|
-
if (data && data.success) {
|
|
173
|
-
Alpine.store('dsModals').advance({ step: 'magic-link-resent' });
|
|
174
|
-
this.startResendCooldown(60);
|
|
175
|
-
} else {
|
|
176
|
-
p.resendError = (data && data.error) || 'Could not resend — wait a moment and try again.';
|
|
177
|
-
}
|
|
178
|
-
} catch (e) {
|
|
179
|
-
p.resendError = 'Could not resend — check your connection and try again.';
|
|
180
|
-
}
|
|
181
|
-
p.submitting = null;
|
|
182
|
-
},
|
|
183
|
-
destroy() {
|
|
184
|
-
if (this._resendTimer) { clearInterval(this._resendTimer); this._resendTimer = null; }
|
|
185
|
-
if (this.googleTimer) { clearTimeout(this.googleTimer); this.googleTimer = null; }
|
|
186
|
-
}
|
|
187
|
-
}"
|
|
188
|
-
class="relative">
|
|
189
|
-
|
|
190
|
-
<%# === CREDENTIALS STEP ================================================== %>
|
|
191
|
-
<div x-show="isCredentialsStep" x-cloak>
|
|
192
|
-
<div class="relative mb-3 -mt-2">
|
|
193
|
-
<h3 class="text-heading font-bold text-lg leading-tight text-center pt-1">Sign in</h3>
|
|
194
|
-
<button @click="$store.dsModals.close()"
|
|
195
|
-
class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
|
|
196
|
-
aria-label="Close">×</button>
|
|
197
|
-
</div>
|
|
198
|
-
<%= render "studio/modals/blocks/progress_pill", current: 1, total: 3 %>
|
|
199
|
-
<p class="text-xs text-secondary mb-4 text-center"
|
|
200
|
-
x-text="'Your ' + ((props && props.picksRequired) || 6) + ' picks are saved — sign in to submit your lineup.'"></p>
|
|
201
|
-
|
|
202
|
-
<div class="mb-4" x-show="termsOn()">
|
|
203
|
-
<%= render "studio/modals/shared/age_attestation" %>
|
|
204
|
-
</div>
|
|
205
|
-
|
|
206
|
-
<%# The credential CTAs gate on props.submitting. It MUST be coerced with
|
|
207
|
-
!! — on a fresh open the modal is opened with { step, picksRequired }
|
|
208
|
-
and no submitting key, and x-bind:disabled on a reactive-undefined value
|
|
209
|
-
renders as disabled=true (verified live), which would inert the whole
|
|
210
|
-
form. !!props.submitting yields a real boolean so the form is enabled
|
|
211
|
-
until a CTA actually sets submitting. Keep the !! on every gate below. %>
|
|
212
|
-
<%# 1. Google — opens a popup in production; here it shows the waiting state.
|
|
213
|
-
Gated on methodOn('google') so the specimen's toggles configure it. %>
|
|
214
|
-
<button @click="loginGoogle()"
|
|
215
|
-
x-show="methodOn('google')"
|
|
216
|
-
:disabled="!!props.submitting"
|
|
217
|
-
class="btn btn-neutral btn-lg w-full gap-3 mb-3 disabled:cursor-wait">
|
|
218
|
-
<span x-show="props.submitting !== 'google'" class="inline-flex"><%= render "components/google_logo" %></span>
|
|
219
|
-
<span x-show="props.submitting === 'google'" class="spinner" aria-hidden="true"></span>
|
|
220
|
-
<span x-text="props.submitting === 'google' ? 'Waiting for Google…' : 'Google'"></span>
|
|
221
|
-
</button>
|
|
222
|
-
<template x-if="props.googleError">
|
|
223
|
-
<p role="alert" class="text-red-400 text-xs -mt-2 mb-3" x-text="props.googleError"></p>
|
|
224
|
-
</template>
|
|
225
|
-
|
|
226
|
-
<%# 2. Wallet — CONTRIBUTED, not written here. See the credential-slot note
|
|
227
|
-
at the top of this file. The partial owns the button, its brand mark
|
|
228
|
-
and its click handler; this engine owns only the decision to look for
|
|
229
|
-
it. An app with no web3 layer on its view path renders nothing at all
|
|
230
|
-
here, which is the whole point: the base template ships no wallet
|
|
231
|
-
markup.
|
|
232
|
-
|
|
233
|
-
Visibility is still Alpine's. The contributed partial carries
|
|
234
|
-
x-show methodOn('wallet'), so the Sign in card's toggle drives it
|
|
235
|
-
exactly as it drove the button that used to live here, and the divider
|
|
236
|
-
below can never float above a button the DOM is missing. %>
|
|
237
|
-
<% if wallet_credential_available %>
|
|
238
|
-
<%= render wallet_credential, modal_store: "dsModals" %>
|
|
239
|
-
<% end %>
|
|
240
|
-
|
|
241
|
-
<%# "or" divider — only when magic-link AND a social method are both on. %>
|
|
242
|
-
<div class="relative my-4" x-show="methodOn('magicLink') && (methodOn('google') || methodOn('wallet'))">
|
|
243
|
-
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-strong"></div></div>
|
|
244
|
-
<div class="relative flex justify-center text-sm"><span class="bg-surface px-3 text-secondary">or</span></div>
|
|
245
|
-
</div>
|
|
246
|
-
|
|
247
|
-
<%# 3. Email magic link — no password. postMagicLink stub advances the step.
|
|
248
|
-
Gated on methodOn('magicLink'). %>
|
|
249
|
-
<form @submit.prevent="submitMagicLink()" novalidate x-show="methodOn('magicLink')">
|
|
250
|
-
<div class="mb-3">
|
|
251
|
-
<label class="block text-xs text-secondary mb-1 font-medium">Email</label>
|
|
252
|
-
<%= render "studio/modals/shared/email_field",
|
|
253
|
-
name: nil, x_model: "email",
|
|
254
|
-
placeholder: "you@example.com", required: true,
|
|
255
|
-
disabled_expr: "!!props.submitting" %>
|
|
256
|
-
</div>
|
|
257
|
-
<template x-if="props.formError">
|
|
258
|
-
<p role="alert" class="text-red-400 text-xs mb-3" x-text="props.formError"></p>
|
|
259
|
-
</template>
|
|
260
|
-
<button type="submit" :disabled="!!props.submitting"
|
|
261
|
-
class="btn btn-neutral btn-lg w-full gap-2 disabled:cursor-wait">
|
|
262
|
-
<span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
|
|
263
|
-
<span x-text="props.submitting === 'magic-link' ? 'Sending link…' : 'Email Link'"></span>
|
|
264
|
-
</button>
|
|
265
|
-
<p class="text-xs text-muted text-center mt-2">No password needed — we'll email you a one-tap link.</p>
|
|
266
|
-
</form>
|
|
267
|
-
</div>
|
|
268
|
-
|
|
269
|
-
<%# === MAGIC-LINK-SENT STEP ============================================== %>
|
|
270
|
-
<template x-if="props.step === 'magic-link-sent'">
|
|
271
|
-
<div>
|
|
272
|
-
<%= render "studio/modals/blocks/card_header", icon_emoji: '📬', title: 'Check your inbox' do %>
|
|
273
|
-
We emailed <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
|
|
274
|
-
a one-tap sign-in link. Check your email and
|
|
275
|
-
<span class="text-heading font-semibold">Click the Magic Link</span> to continue.
|
|
276
|
-
<% end %>
|
|
277
|
-
<%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
|
|
278
|
-
</div>
|
|
279
|
-
</template>
|
|
280
|
-
|
|
281
|
-
<%# === MAGIC-LINK-RESENT STEP =========================================== %>
|
|
282
|
-
<template x-if="props.step === 'magic-link-resent'">
|
|
283
|
-
<div>
|
|
284
|
-
<%= render "studio/modals/blocks/card_header", icon_color: 'primary', title: 'Link Resent!' do %>
|
|
285
|
-
We just resent <span class="text-heading font-medium break-all" x-text="props.sentEmail"></span>
|
|
286
|
-
a one-tap sign-in link. Check your email and
|
|
287
|
-
<span class="text-heading font-semibold">Click the Magic Link</span> to continue.
|
|
288
|
-
<% end %>
|
|
289
|
-
<%= render "studio/modals/auth/resend_footer", modal_store: "dsModals" %>
|
|
290
|
-
</div>
|
|
291
|
-
</template>
|
|
292
|
-
|
|
293
|
-
<%# === REDIRECT STEP ===================================================== %>
|
|
294
|
-
<template x-if="props.step === 'redirect'">
|
|
295
|
-
<div>
|
|
296
|
-
<%= render "studio/modals/blocks/card_header",
|
|
297
|
-
icon_emoji_key: "props.icon || '📍'",
|
|
298
|
-
title_key: "props.title || 'Heading out'",
|
|
299
|
-
subtitle_key: "props.message || 'Taking you to the next step.'" %>
|
|
300
|
-
<%= render "studio/modals/blocks/cta_redirect",
|
|
301
|
-
href_key: "props.url",
|
|
302
|
-
label_key: "props.cta || 'Go now'",
|
|
303
|
-
duration_seconds: 5,
|
|
304
|
-
modal_store: "dsModals" %>
|
|
305
|
-
</div>
|
|
306
|
-
</template>
|
|
307
|
-
</div>
|