studio-engine 0.17.0 → 0.19.0
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 +101 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +620 -5
- data/app/controllers/concerns/studio/magic_link_issuing.rb +39 -0
- data/app/controllers/magic_links_controller.rb +5 -14
- data/app/controllers/studio/local_emails_controller.rb +1 -1
- data/app/controllers/studio/local_reviews_controller.rb +50 -0
- data/app/controllers/style_controller.rb +25 -0
- data/app/mailers/user_mailer.rb +6 -9
- data/app/views/studio/modals/_crop_photo.html.erb +5 -1
- data/app/views/studio/modals/_host.html.erb +5 -0
- data/app/views/studio/modals/_image_upload.html.erb +16 -5
- data/app/views/studio/modals/_saving.html.erb +6 -2
- data/app/views/studio/modals/auth/_resend_footer.html.erb +25 -0
- data/app/views/studio/modals/blocks/_card_header.html.erb +83 -0
- data/app/views/studio/modals/blocks/_cta_redirect.html.erb +73 -0
- data/app/views/studio/modals/blocks/_onchain_success.html.erb +52 -0
- data/app/views/studio/modals/blocks/_progress_pill.html.erb +23 -0
- data/app/views/studio/modals/blocks/_shell.html.erb +35 -0
- data/app/views/studio/modals/blocks/_solana_tx_link.html.erb +42 -0
- data/app/views/studio/modals/shared/_age_attestation.html.erb +34 -0
- data/app/views/studio/modals/shared/_email_field.html.erb +70 -0
- data/app/views/studio/modals/templates/_action.html.erb +25 -0
- data/app/views/studio/modals/templates/_form.html.erb +25 -0
- data/app/views/studio/modals/templates/_status.html.erb +45 -0
- data/app/views/studio/modals/templates/_success.html.erb +23 -0
- data/app/views/studio/modals/templates/_wizard.html.erb +75 -0
- data/app/views/style/_modal_specimen.html.erb +120 -0
- data/app/views/style/_modals.html.erb +690 -0
- data/app/views/style/_specimen.html.erb +64 -0
- data/app/views/style/_tasks.html.erb +112 -0
- data/app/views/style/_theme.html.erb +222 -0
- data/app/views/style/_tricks.html.erb +323 -0
- data/app/views/style/index.html.erb +60 -0
- data/app/views/style/modals/_auth.html.erb +252 -0
- data/app/views/style/modals/_onchain_tx.html.erb +62 -0
- data/app/views/style/modals/_wallet_connect.html.erb +138 -0
- data/app/views/style/modals/_wallet_deposit.html.erb +56 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +43 -1
- metadata +29 -4
- data/app/controllers/design_system_controller.rb +0 -12
- data/app/views/design_system/_specimen.html.erb +0 -27
- data/app/views/design_system/index.html.erb +0 -232
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
# sign_in_existing / sign_up_new building blocks.
|
|
16
16
|
class MagicLinksController < ApplicationController
|
|
17
17
|
include Studio::LinkConsumption
|
|
18
|
+
include Studio::MagicLinkIssuing
|
|
18
19
|
|
|
19
20
|
skip_before_action :require_authentication
|
|
20
21
|
layout false, only: :confirm
|
|
@@ -57,18 +58,8 @@ class MagicLinksController < ApplicationController
|
|
|
57
58
|
redirect_to login_path, alert: "That sign-in link is invalid or has expired. Request a fresh one below."
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# Studio::Link row (URL: /l/<token>) — the short, unified scheme. The mailer
|
|
65
|
-
# builds the matching URL (see UserMailer#magic_link). sign_in_existing /
|
|
66
|
-
# sign_up_new / safe_path come from Studio::LinkConsumption.
|
|
67
|
-
def issue_magic_link(email, return_to)
|
|
68
|
-
if Studio.magic_link_store == :database
|
|
69
|
-
Studio::Link.create_magic_link(email: email, return_to: return_to).token
|
|
70
|
-
else
|
|
71
|
-
MagicLink.generate(email: email, return_to: return_to)
|
|
72
|
-
end
|
|
73
|
-
end
|
|
61
|
+
# issue_magic_link (mint in the configured store) comes from
|
|
62
|
+
# Studio::MagicLinkIssuing, shared with UserMailer — which builds the URL that
|
|
63
|
+
# consumes it — so the mint and its landing URL cannot drift apart.
|
|
64
|
+
# sign_in_existing / sign_up_new / safe_path come from Studio::LinkConsumption.
|
|
74
65
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# GET /_studio/local_review?email=<who>&return_to=<path> — the LOCAL half of
|
|
5
|
+
# the task board's WAITING APPROVAL button.
|
|
6
|
+
#
|
|
7
|
+
# Why it must live here, on the local app. A magic link signs the recipient
|
|
8
|
+
# into the app that MINTED it: the token is a row (or a signed blob) in that
|
|
9
|
+
# app's own store, and Studio::LinkToken.sanitize_path keeps `return_to` a
|
|
10
|
+
# same-origin PATH. So a link minted by the production board can only ever
|
|
11
|
+
# land on production — no matter what host the path belongs to. The board
|
|
12
|
+
# therefore stops minting and REDIRECTS here, to the local server named by the
|
|
13
|
+
# task's local_url, which mints in its own database and lands the operator
|
|
14
|
+
# signed-in on the page under review.
|
|
15
|
+
#
|
|
16
|
+
# It mints for whatever email it is handed, unauthenticated — so it is bound
|
|
17
|
+
# to the same floor as the local email inbox (Studio::LocalEmailsController):
|
|
18
|
+
# never in production, and only for a loopback request. That is the same
|
|
19
|
+
# exposure the inbox already carries, which hands every captured sign-in link
|
|
20
|
+
# to any local reader. It is a development desk convenience; it is not a
|
|
21
|
+
# sign-in path.
|
|
22
|
+
class LocalReviewsController < ApplicationController
|
|
23
|
+
include Studio::MagicLinkIssuing
|
|
24
|
+
|
|
25
|
+
skip_before_action :require_authentication, raise: false
|
|
26
|
+
|
|
27
|
+
before_action :require_local_development!
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
email = Studio::LinkToken.normalize_email(params[:email])
|
|
31
|
+
return redirect_to(login_path, alert: MISSING_EMAIL) unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
|
32
|
+
|
|
33
|
+
# return_to is passed through raw: BOTH stores sanitize it to a same-origin
|
|
34
|
+
# path on the way in (Studio::Link.create_magic_link and MagicLink.generate
|
|
35
|
+
# each call Studio::LinkToken.sanitize_path). Re-sanitizing here would be a
|
|
36
|
+
# second spelling of a rule that already has one owner — and a mutation run
|
|
37
|
+
# confirmed it guards nothing the store does not already guard.
|
|
38
|
+
token = issue_magic_link(email, params[:return_to])
|
|
39
|
+
redirect_to magic_link_url_for(token), allow_other_host: false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
MISSING_EMAIL = "Add ?email=<your address> to mint a local review link."
|
|
45
|
+
|
|
46
|
+
def require_local_development!
|
|
47
|
+
head :not_found unless Studio.local_tool_enabled?(request_local: request.local?)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# The living style guide — a single admin-gated page (/admin/style) that renders
|
|
2
|
+
# the engine's shared UI primitives (buttons, surfaces, the seven motion
|
|
3
|
+
# primitives, the leveling badges, and the theme role tokens) live in the host
|
|
4
|
+
# app's theme. Mirrors ThemeSettingsController: a plain host-inherited controller,
|
|
5
|
+
# gated by `require_admin` from the already-included Studio::ErrorHandling concern.
|
|
6
|
+
# The view is a bare content wrapper that renders inside each host's
|
|
7
|
+
# application.html.erb, so it inherits that app's navbar + theme automatically.
|
|
8
|
+
#
|
|
9
|
+
# Reached at /admin/style (helper admin_style_path). The legacy /admin/design_system
|
|
10
|
+
# route redirects here and keeps its admin_design_system_path helper resolving, so a
|
|
11
|
+
# shipped host sidebar link that still points at the old helper keeps working.
|
|
12
|
+
class StyleController < ApplicationController
|
|
13
|
+
before_action :require_admin
|
|
14
|
+
|
|
15
|
+
# Load the live theme so the Theme section (the landing section, folded in from
|
|
16
|
+
# /admin/theme) shows this app's saved colors + the CSRF-protected editor.
|
|
17
|
+
# Best-effort: the section renders from Studio.theme_config defaults when no
|
|
18
|
+
# ThemeSetting row exists, so a load hiccup must not take the whole page down.
|
|
19
|
+
def index
|
|
20
|
+
@theme_setting = ThemeSetting.current if defined?(ThemeSetting)
|
|
21
|
+
@theme_defaults = Studio.theme_config if Studio.respond_to?(:theme_config)
|
|
22
|
+
rescue StandardError => e
|
|
23
|
+
Rails.logger.warn("[style] theme preload skipped: #{e.message}")
|
|
24
|
+
end
|
|
25
|
+
end
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
class UserMailer < ApplicationMailer
|
|
2
|
+
# magic_link_url_for — the URL that consumes the token, matched to
|
|
3
|
+
# Studio.magic_link_store. Shared with the issuers that MINT the token
|
|
4
|
+
# (MagicLinksController, Studio::LocalReviewsController) so the two halves
|
|
5
|
+
# cannot drift apart.
|
|
6
|
+
include Studio::MagicLinkIssuing
|
|
7
|
+
|
|
2
8
|
# Branded shell (banner + card) for engine-sent UserMailer emails. An app with
|
|
3
9
|
# its own UserMailer + branded_mailer layout (e.g. turf-monster) overrides both.
|
|
4
10
|
layout "branded_mailer"
|
|
@@ -18,13 +24,4 @@ class UserMailer < ApplicationMailer
|
|
|
18
24
|
@banner_alt = "Your #{@app_name} sign-in link"
|
|
19
25
|
mail(to: email, subject: "Your #{@app_name} sign-in link")
|
|
20
26
|
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
# Match the emailed URL to Studio.magic_link_store: the short /l/<token> for
|
|
25
|
-
# the :database scheme, the legacy /magic_link/<token> for :signed. The
|
|
26
|
-
# request side (MagicLinksController#issue_magic_link) mints the matching token.
|
|
27
|
-
def magic_link_url_for(token)
|
|
28
|
-
Studio.magic_link_via_l_route? ? link_url(token: token) : magic_link_url(token: token)
|
|
29
|
-
end
|
|
30
27
|
end
|
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
|
|
22
22
|
Single root: lives inside the host's <template x-if>, so only the outer
|
|
23
23
|
div is the top-level element here.
|
|
24
|
+
|
|
25
|
+
Local: store — Alpine store name this modal is mounted in. Default "modals".
|
|
26
|
+
The living style guide passes store: "dsModals" for its page-scoped host.
|
|
24
27
|
%>
|
|
25
|
-
|
|
28
|
+
<% crop_store = local_assigns.fetch(:store, "modals") %>
|
|
29
|
+
<div x-data="cropPhotoModal({ store: '<%= crop_store %>' })">
|
|
26
30
|
<h3 class="text-heading font-bold text-lg text-center mb-4">Crop Photo</h3>
|
|
27
31
|
|
|
28
32
|
<template x-if="error">
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
store registration, and the window.StudioModals.holdAtLeast(ms)
|
|
9
9
|
helper. The animation keyframes ship inline in the style block
|
|
10
10
|
below, so consumers need no extra CSS to get the full behavior.
|
|
11
|
+
A canonical copy of the SAME keyframes also lives in the shared
|
|
12
|
+
motion layer (app/assets/tailwind/studio_engine/engine-motion.css)
|
|
13
|
+
for pages that drive a modal host WITHOUT rendering this partial —
|
|
14
|
+
e.g. the living style guide's page-scoped dsModals host. The two are
|
|
15
|
+
byte-identical; keep them in sync if you retune a curve or duration.
|
|
11
16
|
Consumer integration example and API reference: the "Modal host"
|
|
12
17
|
section of the gem README.
|
|
13
18
|
%>
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
function cropPhotoModal(opts) {
|
|
27
27
|
opts = opts || {};
|
|
28
28
|
return {
|
|
29
|
+
// Which Alpine store this modal is mounted in. Defaults to 'modals' (the
|
|
30
|
+
// shared host); the living style guide passes { store: 'dsModals' } so the
|
|
31
|
+
// crop-photo modal opens on its page-scoped host without colliding with
|
|
32
|
+
// the app-level shared host.
|
|
33
|
+
_storeName: opts.store || 'modals',
|
|
29
34
|
cropper: null,
|
|
30
35
|
imageUrl: null,
|
|
31
36
|
fromParent: false,
|
|
@@ -46,7 +51,7 @@
|
|
|
46
51
|
dispatch: false,
|
|
47
52
|
|
|
48
53
|
init() {
|
|
49
|
-
var current = this.$store.
|
|
54
|
+
var current = this.$store[this._storeName].current();
|
|
50
55
|
var props = (current && current.props) || {};
|
|
51
56
|
if (props.aspectRatio) this.aspectRatio = props.aspectRatio;
|
|
52
57
|
if (props.maxWidth) this.maxWidth = props.maxWidth;
|
|
@@ -77,8 +82,14 @@
|
|
|
77
82
|
});
|
|
78
83
|
// Cropping in progress — lock the modal (no click-outside / escape) so
|
|
79
84
|
// an accidental click doesn't discard the crop. Cancel still works.
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
// Also reflect the crop sub-state onto the store entry (cropReady) so
|
|
86
|
+
// an external observer can tell the picker state apart from the crop
|
|
87
|
+
// state — the modal's own `imageUrl` is LOCAL x-data and never lands on
|
|
88
|
+
// props, so props alone can't distinguish them. The living style guide
|
|
89
|
+
// reads this to move its active-card glow from the Image Upload card to
|
|
90
|
+
// the Crop Photo card the moment an image is loaded (picker -> cropper).
|
|
91
|
+
var cur = self.$store[self._storeName].current();
|
|
92
|
+
if (cur && cur.props) { cur.props.dismissible = false; cur.props.cropReady = true; }
|
|
82
93
|
});
|
|
83
94
|
},
|
|
84
95
|
|
|
@@ -112,7 +123,7 @@
|
|
|
112
123
|
|
|
113
124
|
cancel() {
|
|
114
125
|
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
115
|
-
this.$store.
|
|
126
|
+
this.$store[this._storeName].close();
|
|
116
127
|
},
|
|
117
128
|
|
|
118
129
|
confirm() {
|
|
@@ -134,7 +145,7 @@
|
|
|
134
145
|
// dispatch mode: the opener's host owns the post-confirm flow
|
|
135
146
|
// (processing modal -> success toast), so don't pop the stack here.
|
|
136
147
|
// fromParent (no dispatch): close the modal now.
|
|
137
|
-
if (!self.dispatch) self.$store.
|
|
148
|
+
if (!self.dispatch) self.$store[self._storeName].close();
|
|
138
149
|
}, "image/png");
|
|
139
150
|
}
|
|
140
151
|
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<%# Generic "saving" loading card, opened by window.submitFormWithProgress with
|
|
2
2
|
a { title } prop while a Turbo form uploads. Register it in your modal-host
|
|
3
|
-
block alongside crop-photo. Single root for the host's <template x-if>.
|
|
3
|
+
block alongside crop-photo. Single root for the host's <template x-if>.
|
|
4
|
+
|
|
5
|
+
Local: store — Alpine store name backing the title read. Default "modals".
|
|
6
|
+
The living style guide passes store: "dsModals" for its page-scoped host. %>
|
|
7
|
+
<% saving_store = local_assigns.fetch(:store, "modals") %>
|
|
4
8
|
<div>
|
|
5
9
|
<%= render "studio/modals/blocks/processing_card",
|
|
6
|
-
title_key: "$store.
|
|
10
|
+
title_key: "$store.#{saving_store}.current()?.props?.title || 'Saving…'" %>
|
|
7
11
|
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Resend-link + error + Close footer, shared by the auth modal's
|
|
3
|
+
magic-link-sent and magic-link-resent steps (identical in both). Ported from
|
|
4
|
+
turf-monster. Rendered INSIDE each step's root <div> (so the parent
|
|
5
|
+
<template x-if>'s single-root rule is unaffected) and within the auth modal's
|
|
6
|
+
x-data scope, so props.* and resendMagicLink() resolve from the host
|
|
7
|
+
component. Resend is a LINK; disabled during an in-flight resend and the 60s
|
|
8
|
+
cooldown, which it counts down inline. Keep this free of double-quotes and
|
|
9
|
+
backticks inside the Alpine expressions.
|
|
10
|
+
|
|
11
|
+
Locals:
|
|
12
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
13
|
+
%>
|
|
14
|
+
<%
|
|
15
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
16
|
+
%>
|
|
17
|
+
<button @click="resendMagicLink()" :disabled="props.submitting === 'magic-link' || (props.resendCooldown || 0) > 0"
|
|
18
|
+
class="block mx-auto text-sm text-primary hover:text-primary-300 underline underline-offset-2 disabled:opacity-50 disabled:no-underline">
|
|
19
|
+
<span class="inline-flex items-center justify-center gap-1.5">
|
|
20
|
+
<span x-show="props.submitting === 'magic-link'" class="spinner" aria-hidden="true"></span>
|
|
21
|
+
<span x-text="props.submitting === 'magic-link' ? 'Resending…' : ((props.resendCooldown || 0) > 0 ? ('Resend available in ' + props.resendCooldown + 's') : 'Resend link')"></span>
|
|
22
|
+
</span>
|
|
23
|
+
</button>
|
|
24
|
+
<p x-show="props.resendError" x-cloak class="text-xs text-red-400 mt-3 text-center" x-text="props.resendError"></p>
|
|
25
|
+
<button @click="$store.<%= modal_store %>.close()" class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">Close</button>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Shared modal card header — icon pill + title + subtext. Owns the visual top
|
|
3
|
+
of every success / confirmation / status modal so the header style stays
|
|
4
|
+
aligned across the family. Each callsite slots its own action area (button,
|
|
5
|
+
link, hold button) BELOW this header. Ported from turf-monster into the engine
|
|
6
|
+
(studio-engine v0.18) so the shared modal blocks live in one place.
|
|
7
|
+
|
|
8
|
+
Locals (all optional except title/title_key):
|
|
9
|
+
title: static title string
|
|
10
|
+
title_key: Alpine expression for the title (x-text). Priority over title.
|
|
11
|
+
subtitle: static subtext string
|
|
12
|
+
subtitle_key: Alpine expression for the subtext (x-text). Priority over subtitle.
|
|
13
|
+
icon_color: color token for the default check — 'primary' (default),
|
|
14
|
+
'success', 'warning', 'mint'.
|
|
15
|
+
icon_emoji: large emoji (text-5xl, no pill) instead of the default check.
|
|
16
|
+
icon_emoji_key: Alpine expression returning the emoji (x-text). Priority
|
|
17
|
+
over icon_emoji.
|
|
18
|
+
icon: :error — X mark in a red pill (hard error states).
|
|
19
|
+
:external_tab — diagonal arrow pill (action in another tab).
|
|
20
|
+
spinner: truthy — three bouncing dots instead of the check pill,
|
|
21
|
+
for processing / loading states.
|
|
22
|
+
size: :md (default) OR :lg (celebration end-state, text-3xl title).
|
|
23
|
+
|
|
24
|
+
Block: if given, the block's content replaces the subtitle area. Explicit
|
|
25
|
+
subtitle locals are checked BEFORE block_given? — a partial's block_given? can
|
|
26
|
+
read TRUE off the layout yield even with no block passed (see the engine's
|
|
27
|
+
Rails-partial block_given? note), so lead with the locals.
|
|
28
|
+
%>
|
|
29
|
+
<%
|
|
30
|
+
icon_color = local_assigns[:icon_color] || 'primary'
|
|
31
|
+
size = local_assigns[:size] || :md
|
|
32
|
+
pill_class = size == :lg ? 'w-14 h-14' : 'w-12 h-12'
|
|
33
|
+
svg_class = size == :lg ? 'w-7 h-7' : 'w-6 h-6'
|
|
34
|
+
title_cls = size == :lg ? 'text-3xl font-bold text-heading mb-2' : 'text-lg font-bold text-heading mb-2'
|
|
35
|
+
sub_cls = size == :lg ? 'text-sm text-secondary mb-4' : 'text-xs text-secondary mb-5'
|
|
36
|
+
%>
|
|
37
|
+
<div class="text-center">
|
|
38
|
+
<% if local_assigns[:spinner] %>
|
|
39
|
+
<div class="mx-auto <%= pill_class %> flex items-center justify-center mb-4">
|
|
40
|
+
<div class="loading-dots">
|
|
41
|
+
<span></span>
|
|
42
|
+
<span></span>
|
|
43
|
+
<span></span>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<% elsif local_assigns[:icon] == :error %>
|
|
47
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-red-500/15 flex items-center justify-center mb-4">
|
|
48
|
+
<svg class="<%= svg_class %> text-red-400" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
49
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
50
|
+
</svg>
|
|
51
|
+
</div>
|
|
52
|
+
<% elsif local_assigns[:icon] == :external_tab %>
|
|
53
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
|
|
54
|
+
<svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
55
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25"/>
|
|
56
|
+
</svg>
|
|
57
|
+
</div>
|
|
58
|
+
<% elsif local_assigns[:icon_emoji_key] %>
|
|
59
|
+
<div class="text-5xl mb-4 leading-none" x-text="<%= icon_emoji_key %>"></div>
|
|
60
|
+
<% elsif local_assigns[:icon_emoji] %>
|
|
61
|
+
<div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
|
|
62
|
+
<% else %>
|
|
63
|
+
<div class="mx-auto <%= pill_class %> rounded-full bg-<%= icon_color %>/15 flex items-center justify-center mb-4">
|
|
64
|
+
<svg class="<%= svg_class %> text-<%= icon_color %>" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
65
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
66
|
+
</svg>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<% if local_assigns[:title_key] %>
|
|
71
|
+
<h3 class="<%= title_cls %>" x-text="<%= title_key %>"></h3>
|
|
72
|
+
<% elsif local_assigns[:title] %>
|
|
73
|
+
<h3 class="<%= title_cls %>"><%= title %></h3>
|
|
74
|
+
<% end %>
|
|
75
|
+
|
|
76
|
+
<% if local_assigns[:subtitle_key] %>
|
|
77
|
+
<p class="<%= sub_cls %>" x-text="<%= subtitle_key %>"></p>
|
|
78
|
+
<% elsif local_assigns[:subtitle] %>
|
|
79
|
+
<p class="<%= sub_cls %>"><%= subtitle %></p>
|
|
80
|
+
<% elsif block_given? %>
|
|
81
|
+
<p class="<%= sub_cls %>"><%= yield %></p>
|
|
82
|
+
<% end %>
|
|
83
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Primary CTA with a drain animation that auto-redirects when the countdown
|
|
3
|
+
hits zero. Used on celebration cards where the user is leaving the modal
|
|
4
|
+
anyway — the drain is both a progress bar AND a click-now affordance. Ported
|
|
5
|
+
from turf-monster.
|
|
6
|
+
|
|
7
|
+
The drain always plays. The redirect is gated:
|
|
8
|
+
- URL truthy → drains, then window.location = URL
|
|
9
|
+
- URL null → drains; timer fires a no-op, a click falls through to
|
|
10
|
+
closing the modal (so a gallery preview stays alive).
|
|
11
|
+
|
|
12
|
+
Locals (optional except href_key):
|
|
13
|
+
href_key — Alpine expression: the destination URL or null. Re-read
|
|
14
|
+
inside the timer fire so callers can lazy-resolve.
|
|
15
|
+
label_html — Button label HTML. Default "Continue".
|
|
16
|
+
label_key — Alpine expression returning the label (x-text). Priority
|
|
17
|
+
over label_html.
|
|
18
|
+
duration_seconds — Drain + redirect timer in seconds. Default 5.
|
|
19
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
20
|
+
|
|
21
|
+
Co-located x-data self-manages the timer. Keep the x-data free of
|
|
22
|
+
double-quotes and backticks — it is a double-quoted HTML attribute.
|
|
23
|
+
%>
|
|
24
|
+
<%
|
|
25
|
+
href_key = local_assigns.fetch(:href_key)
|
|
26
|
+
label_html = local_assigns.fetch(:label_html, "Continue")
|
|
27
|
+
duration_seconds = local_assigns.fetch(:duration_seconds, 5)
|
|
28
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
29
|
+
%>
|
|
30
|
+
<div x-data="{
|
|
31
|
+
_redirectTimer: null,
|
|
32
|
+
redirecting: false,
|
|
33
|
+
go(url) {
|
|
34
|
+
if (!url) return false;
|
|
35
|
+
this.redirecting = true;
|
|
36
|
+
try { window.location.href = url; } catch (_) {}
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
init() {
|
|
40
|
+
var self = this;
|
|
41
|
+
self._redirectTimer = setTimeout(function () {
|
|
42
|
+
self._redirectTimer = null;
|
|
43
|
+
self.go(<%= href_key %>);
|
|
44
|
+
}, <%= duration_seconds * 1000 %>);
|
|
45
|
+
},
|
|
46
|
+
destroy() {
|
|
47
|
+
if (this._redirectTimer) { clearTimeout(this._redirectTimer); this._redirectTimer = null; }
|
|
48
|
+
}
|
|
49
|
+
}">
|
|
50
|
+
<a :href="(<%= href_key %>) || '#'"
|
|
51
|
+
@click="
|
|
52
|
+
if (redirecting) { $event.preventDefault(); return; }
|
|
53
|
+
if ($event.metaKey || $event.ctrlKey || $event.shiftKey) return;
|
|
54
|
+
$event.preventDefault();
|
|
55
|
+
var u = (<%= href_key %>);
|
|
56
|
+
if (u) { go(u); } else { $store.<%= modal_store %>.close(); }
|
|
57
|
+
"
|
|
58
|
+
:class="redirecting ? 'pointer-events-none' : ''"
|
|
59
|
+
:aria-disabled="redirecting ? 'true' : 'false'"
|
|
60
|
+
class="btn btn-primary btn-lg w-full relative overflow-hidden no-underline">
|
|
61
|
+
<div class="absolute inset-0 pointer-events-none origin-left"
|
|
62
|
+
x-show="!redirecting"
|
|
63
|
+
style="background: rgba(255,255,255,0.18); animation: studio-modal-drain <%= duration_seconds %>s linear forwards;"></div>
|
|
64
|
+
<% if local_assigns[:label_key] %>
|
|
65
|
+
<span class="relative z-10" x-show="!redirecting" x-text="<%= label_key %>"></span>
|
|
66
|
+
<% else %>
|
|
67
|
+
<span class="relative z-10" x-show="!redirecting"><%= label_html.html_safe %></span>
|
|
68
|
+
<% end %>
|
|
69
|
+
<span class="relative z-10 inline-flex items-center justify-center" x-show="redirecting" x-cloak>
|
|
70
|
+
<span class="spinner" style="--spinner-color: currentColor" aria-hidden="true"></span>
|
|
71
|
+
</span>
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Generic on-chain transaction success card — the default-fallback success
|
|
3
|
+
state for any Solana transaction that isn't a domain-specific celebration
|
|
4
|
+
(faucet mint, on-chain rename, contest creation). Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Same visual spine as a rich entry card (card_header at :lg with the primary
|
|
7
|
+
check pill + branded Solana tx link) MINUS the enrichments: no seeds bar, no
|
|
8
|
+
level-up, no auto-redirect drain. The CTA is a plain optional link; when no
|
|
9
|
+
cta label/href is supplied the card degrades to just the Dismiss link.
|
|
10
|
+
|
|
11
|
+
Single root: content lives in one <div> so Alpine's <template x-if> clones it
|
|
12
|
+
whole.
|
|
13
|
+
|
|
14
|
+
Locals:
|
|
15
|
+
tx_signature_key — Alpine expression returning the tx hash (link mounts when truthy)
|
|
16
|
+
title_key — Alpine expression returning the headline
|
|
17
|
+
subtitle_key — Alpine expression returning the sub-headline
|
|
18
|
+
cta_label_key — Alpine expression returning the CTA label (button mounts when truthy)
|
|
19
|
+
cta_href_key — Alpine expression returning the CTA href
|
|
20
|
+
cluster_param — explorer cluster query, forwarded to solana_tx_link. Default "".
|
|
21
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
22
|
+
%>
|
|
23
|
+
<%
|
|
24
|
+
tx_signature_key = local_assigns.fetch(:tx_signature_key)
|
|
25
|
+
title_key = local_assigns.fetch(:title_key)
|
|
26
|
+
subtitle_key = local_assigns.fetch(:subtitle_key)
|
|
27
|
+
cta_label_key = local_assigns.fetch(:cta_label_key)
|
|
28
|
+
cta_href_key = local_assigns.fetch(:cta_href_key)
|
|
29
|
+
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
30
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
31
|
+
%>
|
|
32
|
+
<div>
|
|
33
|
+
<%= render "studio/modals/blocks/card_header",
|
|
34
|
+
size: :lg,
|
|
35
|
+
icon_color: 'primary',
|
|
36
|
+
title_key: title_key,
|
|
37
|
+
subtitle_key: subtitle_key %>
|
|
38
|
+
|
|
39
|
+
<%= render "studio/modals/blocks/solana_tx_link",
|
|
40
|
+
tx_signature_key: tx_signature_key, cluster_param: cluster_param %>
|
|
41
|
+
|
|
42
|
+
<template x-if="(<%= cta_label_key %>)">
|
|
43
|
+
<a :href="(<%= cta_href_key %>) || '#'"
|
|
44
|
+
class="btn btn-primary btn-lg w-full no-underline"
|
|
45
|
+
x-text="<%= cta_label_key %>"></a>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<button @click="$store.<%= modal_store %>.close()"
|
|
49
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
50
|
+
Dismiss
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Segmented progress pill — N rounded bars in a row, the first `current`
|
|
3
|
+
filled in primary, the rest muted outlines. Drops into any wizard-style modal
|
|
4
|
+
step to show position in a multi-step flow. Ported from turf-monster.
|
|
5
|
+
|
|
6
|
+
Locals:
|
|
7
|
+
current — 1-indexed step number to fill up to (current: 2, total: 3 fills
|
|
8
|
+
2/3). Required.
|
|
9
|
+
total — total segment count. Default 3.
|
|
10
|
+
%>
|
|
11
|
+
<%
|
|
12
|
+
current = local_assigns.fetch(:current)
|
|
13
|
+
total = local_assigns.fetch(:total, 3)
|
|
14
|
+
%>
|
|
15
|
+
<div class="flex items-center justify-center gap-1.5 mx-auto max-w-[200px] mb-4">
|
|
16
|
+
<% total.times do |i| %>
|
|
17
|
+
<% if (i + 1) <= current %>
|
|
18
|
+
<div class="h-1.5 flex-1 rounded-full bg-primary"></div>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="h-1.5 flex-1 rounded-full bg-inset border border-subtle"></div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Shared modal shell — opt-in convenience wrapper for a title row + close ×.
|
|
3
|
+
Ported from turf-monster. Wrap content with:
|
|
4
|
+
render layout: "studio/modals/blocks/shell", locals: { title: "…" } do … end
|
|
5
|
+
|
|
6
|
+
Locals (all optional):
|
|
7
|
+
title — top-row title string.
|
|
8
|
+
dismissible — default true; suppress the close button when false. (The host
|
|
9
|
+
gates escape + click-outside on props.dismissible at open()
|
|
10
|
+
time, not this flag.)
|
|
11
|
+
modal_store — Alpine store name backing close(). Default "modals".
|
|
12
|
+
%>
|
|
13
|
+
<%
|
|
14
|
+
shell_title = local_assigns[:title]
|
|
15
|
+
shell_dismissible = local_assigns.fetch(:dismissible, true)
|
|
16
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
17
|
+
%>
|
|
18
|
+
<div>
|
|
19
|
+
<% if shell_title || shell_dismissible %>
|
|
20
|
+
<div class="flex items-start justify-between gap-3 -mt-2 -mr-2 mb-3">
|
|
21
|
+
<% if shell_title %>
|
|
22
|
+
<h3 class="text-heading font-bold text-lg leading-tight pt-1"><%= shell_title %></h3>
|
|
23
|
+
<% else %>
|
|
24
|
+
<span></span>
|
|
25
|
+
<% end %>
|
|
26
|
+
<% if shell_dismissible %>
|
|
27
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
28
|
+
class="text-secondary hover:text-heading text-xl leading-none flex-shrink-0"
|
|
29
|
+
aria-label="Close">×</button>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<%= yield %>
|
|
35
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Branded Solana explorer tx link — gradient Solana mark + truncated signature
|
|
3
|
+
+ external-link glyph in a subtle bordered pill. Shared by the on-chain
|
|
4
|
+
success cards (studio/modals/blocks/_onchain_success and a consumer's entry
|
|
5
|
+
card). Mounts only when the signature expression is truthy. Ported from
|
|
6
|
+
turf-monster; the cluster query is a plain local so the engine partial carries
|
|
7
|
+
no Solana::Config dependency.
|
|
8
|
+
|
|
9
|
+
Only one success card is ever in the DOM at a time, so the fixed gradient id
|
|
10
|
+
is collision-safe.
|
|
11
|
+
|
|
12
|
+
Locals:
|
|
13
|
+
tx_signature_key — Alpine expression returning the Solana tx hash. Required.
|
|
14
|
+
cluster_param — appended to the explorer URL (e.g. "?cluster=devnet").
|
|
15
|
+
Default "" (mainnet).
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
tx_signature_key = local_assigns.fetch(:tx_signature_key)
|
|
19
|
+
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
20
|
+
%>
|
|
21
|
+
<template x-if="(<%= tx_signature_key %>)">
|
|
22
|
+
<div class="text-center mb-3 -mt-1">
|
|
23
|
+
<a :href="'https://explorer.solana.com/tx/' + (<%= tx_signature_key %>) + '<%= cluster_param %>'"
|
|
24
|
+
target="_blank" rel="noopener"
|
|
25
|
+
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border border-subtle hover:border-primary/50 transition group no-underline"
|
|
26
|
+
style="background: rgb(var(--color-primary-500-rgb) / 0.06);">
|
|
27
|
+
<svg width="14" height="11" viewBox="0 0 397 311" fill="none">
|
|
28
|
+
<defs><linearGradient id="tx-solana-grad" x1="361" y1="-9" x2="153" y2="389" gradientUnits="userSpaceOnUse">
|
|
29
|
+
<stop offset="0" stop-color="#00FFA3"/><stop offset="1" stop-color="#DC1FFF"/>
|
|
30
|
+
</linearGradient></defs>
|
|
31
|
+
<path d="M65 234c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11l64-63z" fill="url(#tx-solana-grad)"/>
|
|
32
|
+
<path d="M65 4c2-2 6-4 9-4h317c6 0 9 7 5 11l-63 63c-2 2-6 4-9 4H6c-6 0-9-7-5-11L65 4z" fill="url(#tx-solana-grad)"/>
|
|
33
|
+
<path d="M333 119c-2-2-6-4-9-4H7c-6 0-9 7-5 11l63 63c2 2 6 4 9 4h317c6 0 9-7 5-11l-63-63z" fill="url(#tx-solana-grad)"/>
|
|
34
|
+
</svg>
|
|
35
|
+
<span class="font-mono text-[11px] text-secondary group-hover:text-primary transition"
|
|
36
|
+
x-text="(<%= tx_signature_key %>) ? ((<%= tx_signature_key %>).substring(0, 16) + '…') : ''"></span>
|
|
37
|
+
<svg class="w-3 h-3 text-muted group-hover:text-primary transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
38
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
39
|
+
</svg>
|
|
40
|
+
</a>
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Legal-age attestation checkbox — underwriting compliance for skill-based
|
|
3
|
+
contests. Ported from turf-monster into the engine. Binds x-model into the
|
|
4
|
+
caller's Alpine scope; the caller gates its CTAs on that property. The SERVER
|
|
5
|
+
is the real boundary — every signup endpoint rejects new account creation
|
|
6
|
+
without the attestation.
|
|
7
|
+
|
|
8
|
+
Unlike the turf-monster original, this engine partial does NOT self-gate on an
|
|
9
|
+
AppFlags flag — a consumer that wants the attestation renders it; one that
|
|
10
|
+
doesn't simply omits the render. Callers that omit it initialize their
|
|
11
|
+
ageAttested Alpine state to true so every CTA gate passes.
|
|
12
|
+
|
|
13
|
+
Locals (all optional):
|
|
14
|
+
x_model — Alpine model target (default "ageAttested")
|
|
15
|
+
error_model — Alpine error-flag target (default "ageError")
|
|
16
|
+
%>
|
|
17
|
+
<%
|
|
18
|
+
x_model = local_assigns.fetch(:x_model, "ageAttested")
|
|
19
|
+
error_model = local_assigns.fetch(:error_model, "ageError")
|
|
20
|
+
%>
|
|
21
|
+
<div>
|
|
22
|
+
<label class="flex items-start gap-2.5 text-xs text-secondary leading-snug cursor-pointer select-none">
|
|
23
|
+
<input type="checkbox" x-model="<%= x_model %>" @change="<%= error_model %> = false"
|
|
24
|
+
data-age-attestation
|
|
25
|
+
class="mt-0.5 w-4 h-4 shrink-0 rounded accent-primary cursor-pointer">
|
|
26
|
+
<span>
|
|
27
|
+
I confirm I am of legal age to participate in skill-based contests in my
|
|
28
|
+
state (18+ in most states; 19+ in AL/NE; 21+ in IA/MA/VA).
|
|
29
|
+
</span>
|
|
30
|
+
</label>
|
|
31
|
+
<p x-show="<%= error_model %>" x-cloak class="text-xs text-red-400 mt-1.5 ml-6">
|
|
32
|
+
Please confirm you are of legal age to continue.
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|