studio-engine 0.19.0 → 0.21.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 +119 -0
- data/app/assets/tailwind/studio_engine/engine-motion.css +58 -0
- data/app/views/studio/_age_verify_assets.html.erb +120 -0
- data/app/views/studio/modals/blocks/_age_verify.html.erb +106 -0
- data/app/views/studio/modals/blocks/_digit_reel.html.erb +41 -0
- data/app/views/studio/modals/blocks/_entry_confirmed.html.erb +86 -0
- data/app/views/studio/modals/blocks/_free_entry_earned.html.erb +46 -0
- data/app/views/studio/modals/blocks/_seeds_bar.html.erb +125 -0
- data/app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb +40 -0
- data/app/views/style/_modals.html.erb +178 -6
- data/app/views/style/modals/_age_verify.html.erb +17 -0
- data/app/views/style/modals/_entry_tokens.html.erb +123 -0
- data/app/views/style/modals/_wallet_connect.html.erb +33 -6
- data/lib/studio/version.rb +1 -1
- metadata +15 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 14686260b35b4cd72975c3bf156020de61094144f09d8e71f3d6eeb045b5bcc3
|
|
4
|
+
data.tar.gz: 35b5f1bb5cb7f1236d21baa47c3e28d6d7f00420eb775da0719cd9874b29b4b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a8b31f5f092a8998590c47877fb23631316fbaee2ab8bca2199038d27497775db342a5ba4f2b759bd09b2ae44256ad57b857cea5742731f84c59a8525a3b1e3
|
|
7
|
+
data.tar.gz: 506f56fbadf51b53c7a1f779678bab0ba1de8fc485493acb4d3ac8aa19ddfd29c4b103d789107d3f4258b381d709fdefdd00fc7ab81dd8cdfffbc59f8baffb88
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,125 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.21.0 — 2026-07-27
|
|
6
|
+
|
|
7
|
+
Phase 2 of the studio-engine modal convergence: homes the entry-time age-gate DOB
|
|
8
|
+
modal as a policy-free engine primitive, and documents the app-specific entry-token
|
|
9
|
+
purchase flow as a living-style-guide specimen.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Age-gate DOB modal — `studio/modals/blocks/_age_verify`.** The entry-time
|
|
14
|
+
date-of-birth gate (Month / Day / Year + a live "too young" hint + submit),
|
|
15
|
+
homed in the engine as the heavier sibling of the existing
|
|
16
|
+
`studio/modals/shared/_age_attestation` checkbox. **Run one or the other:** the
|
|
17
|
+
attestation is a signup-time "I confirm I'm of legal age" checkbox; this is an
|
|
18
|
+
entry-time DOB gate a server can recompute and stamp.
|
|
19
|
+
|
|
20
|
+
**The engine hardcodes NO legal policy** — the whole seam is app-supplied:
|
|
21
|
+
`min_age` (REQUIRED, with **no engine default** — 18 is itself a policy value),
|
|
22
|
+
`submit_url` (the app owns the authoritative recompute + DOB persistence),
|
|
23
|
+
`state` (an optional PASSIVE jurisdiction label — the modal never detects
|
|
24
|
+
geography and never offers an editable state field, so a spoofed client state
|
|
25
|
+
can't lower the bar), and `fine_print` (the app's per-jurisdiction legal copy;
|
|
26
|
+
the engine ships only a neutral, policy-free default). The per-state age table
|
|
27
|
+
that the app's policy encodes never enters the engine.
|
|
28
|
+
|
|
29
|
+
- **`ageVerifyModal` factory — `studio/_age_verify_assets`.** The Alpine data for
|
|
30
|
+
the DOB modal, shipped at page level (a `<script>` cloned out of a modal-host
|
|
31
|
+
template never runs). A consumer renders it once in its layout, like the shared
|
|
32
|
+
alpine factories; mirrors how `studio/_cropper_assets` homes `cropPhotoModal`.
|
|
33
|
+
The factory carries zero policy — no age default, no state table; the client
|
|
34
|
+
age math is UX only, the server recompute stays authoritative.
|
|
35
|
+
|
|
36
|
+
- **Living style guide — new "Eligibility & entry" group.** Adds two specimens to
|
|
37
|
+
the `/admin/style` Modals section:
|
|
38
|
+
- The **age-gate** specimen, opened with a clearly-labelled DEMO policy (21+ in
|
|
39
|
+
CA) that documents the app-supplied seam, gated by
|
|
40
|
+
`Studio.feature?(:age_gate)` — disabled-but-present-yet-openable when off,
|
|
41
|
+
like the web3 specimens.
|
|
42
|
+
- The **entry-tokens** specimen — the app-specific purchase flow
|
|
43
|
+
(picker → confirming → minted) documented by composing engine chrome
|
|
44
|
+
(`shell`, `card_header`, `progress_pill`) with illustrative demo packs. The
|
|
45
|
+
engine does NOT own the packs, pricing, rails, or the on-chain mint. The copy
|
|
46
|
+
reflects the real on-chain model: an entry token is a **prepaid entry credit
|
|
47
|
+
recorded on-chain** (not a wallet-held SPL token), **minted server-side** once
|
|
48
|
+
payment clears and **consumed** when the app's hold-to-confirm creates the
|
|
49
|
+
entry.
|
|
50
|
+
|
|
51
|
+
### Capability gating
|
|
52
|
+
|
|
53
|
+
- **`:age_gate`** — a new, independent capability flag gating the age-gate
|
|
54
|
+
specimen (and the intended consumer mount). Off by default; a consumer opts in
|
|
55
|
+
via `config.features`.
|
|
56
|
+
|
|
57
|
+
### Unchanged
|
|
58
|
+
|
|
59
|
+
- Backward compatible: every addition is additive/optional. Nothing existing
|
|
60
|
+
changes behaviour, and the lighter `_age_attestation` checkbox is untouched.
|
|
61
|
+
|
|
62
|
+
## 0.20.0 — 2026-07-27
|
|
63
|
+
|
|
64
|
+
Phase 1 of the studio-engine modal convergence: the engine now OWNS the wallet
|
|
65
|
+
brand icons, homes the entry-confirmed celebration, and gates the web3/leveling
|
|
66
|
+
enrichments behind the two independent capability flags.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **Wallet brand icons ship inline from the engine** — new
|
|
71
|
+
`studio/modals/blocks/_wallet_brand_sprite` emits a hidden inline-SVG sprite
|
|
72
|
+
with the Phantom (purple ghost), Solflare (yellow S), and Backpack (red pack)
|
|
73
|
+
marks as `<symbol>`s. The Connect-wallet picker resolves a mark by name
|
|
74
|
+
(`brandIcon(name)` → `<use href="#se-wallet-…">`) and falls back to a letter
|
|
75
|
+
tile only for an unknown wallet. Every consumer inherits crisp brand icons
|
|
76
|
+
with **zero per-app asset files** — before this, the picker referenced
|
|
77
|
+
app-served PNGs (`/wallet-phantom.png`, …), so a fresh app that had not copied
|
|
78
|
+
those into its own `public/` showed 404 letter tiles.
|
|
79
|
+
|
|
80
|
+
- **Entry-confirmed celebration — `studio/modals/blocks/_entry_confirmed`.** The
|
|
81
|
+
canonical "you're in" card, homed in the engine and composed via the
|
|
82
|
+
`_success_card` **yield slot**: `_success_card` owns the web3 spine (the `:lg`
|
|
83
|
+
check header, branded Solana tx link, drain CTA with auto-redirect, confetti),
|
|
84
|
+
and `_entry_confirmed` injects the leveling enrichment into its block. All
|
|
85
|
+
numbers are **app-supplied** via Alpine expressions (default `props.*`):
|
|
86
|
+
`txSignature`, `lobbyUrl`, `seedsEarned`, `seedsTotal`. Turf-specific copy is
|
|
87
|
+
parameterized, not baked in.
|
|
88
|
+
|
|
89
|
+
- **Seeds progress bar — `studio/modals/blocks/_seeds_bar` + `_digit_reel`.**
|
|
90
|
+
Ported from Turf Monster and generalized: the animated 5-segment fill with a
|
|
91
|
+
4-phase level-up sequence (fill → level pop → drain → refill) and the
|
|
92
|
+
rolling-digit counter. The `--bar-progress` registered custom property,
|
|
93
|
+
`.seeds-bar-continuous`, `.seeds-reel-track`, and the `seedsShimmer` keyframe
|
|
94
|
+
ship in `engine-motion.css`. Motion is class-owned (timing via a
|
|
95
|
+
`--seeds-fill-dur` knob) so `prefers-reduced-motion` actually cancels the
|
|
96
|
+
bar-fill, shimmer, and reel.
|
|
97
|
+
|
|
98
|
+
- **Free Entry Earned modal — `studio/modals/blocks/_free_entry_earned`.** The
|
|
99
|
+
standalone level-up reward modal, parameterized (store, icon, title, copy).
|
|
100
|
+
|
|
101
|
+
- **Living style guide** gains an engine-owned wallet-icon picker, an
|
|
102
|
+
"Entry confirmed" (web3) specimen, and "Entry + seeds level-up" +
|
|
103
|
+
"Free Entry Earned" (leveling) specimens, so the gated variants demo correctly
|
|
104
|
+
(generic-success vs seeds vs free-entry).
|
|
105
|
+
|
|
106
|
+
### Capability gating
|
|
107
|
+
|
|
108
|
+
- **`:web3`** gates the generic entry-confirmed success (branded tx link +
|
|
109
|
+
heading + CTA), alongside the existing wallet / on-chain / deposit modals.
|
|
110
|
+
- **`:leveling`** — independent of `:web3` — gates the seeds bar and the Free
|
|
111
|
+
Entry Earned reward. `_entry_confirmed` self-gates its block on
|
|
112
|
+
`Studio.feature?(:leveling)`, so a **web3-only app (leveling off) gets the
|
|
113
|
+
clean success card automatically** — no seeds, no free entry.
|
|
114
|
+
|
|
115
|
+
### Unchanged
|
|
116
|
+
|
|
117
|
+
- Backward compatible: every addition is additive/optional (new locals default
|
|
118
|
+
to today's behavior). The shared `_success_card`, `_card_header`,
|
|
119
|
+
`_cta_redirect`, and the other blocks are untouched in default behavior, so
|
|
120
|
+
existing McRitchie Studio and Turf Monster consumers render identically. No
|
|
121
|
+
engine view-primitive gains any signing / key material — signing stays an
|
|
122
|
+
app-supplied callback seam.
|
|
123
|
+
|
|
5
124
|
## 0.19.0 — 2026-07-27
|
|
6
125
|
|
|
7
126
|
### Added
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
.level-up-pop level-up pop + glow burst (ported, TM)
|
|
28
28
|
.nav-level-pop nav level-up bounce (ported, TM)
|
|
29
29
|
.badge-with-sheen one-shot diagonal sheen wrapper (ported, TM)
|
|
30
|
+
.seeds-bar-continuous 5-segment lockstep progress fill (ported, TM)
|
|
30
31
|
|
|
31
32
|
─────────────────────────────────────────────────────────────────────────────
|
|
32
33
|
OPT-IN — this layer does NOT auto-bundle.
|
|
@@ -927,6 +928,63 @@
|
|
|
927
928
|
}
|
|
928
929
|
}
|
|
929
930
|
|
|
931
|
+
/* =============================================================================
|
|
932
|
+
SEEDS BAR — the continuous 5-segment progress fill behind the leveling
|
|
933
|
+
entry-confirmed celebration (studio/modals/blocks/_seeds_bar). Ported from
|
|
934
|
+
turf-monster. ONE registered custom property (--bar-progress) drives all five
|
|
935
|
+
segment clip-paths in lockstep off a SINGLE transition curve, instead of five
|
|
936
|
+
chained ones. .seeds-bar-continuous parks it at 0 and eases changes;
|
|
937
|
+
seedsShimmer is the light sweep that rides across the filled bar. The whole
|
|
938
|
+
family is capability-gated at the view layer by Studio.feature?(:leveling), so
|
|
939
|
+
an app with leveling off never renders a bar and these classes idle unused.
|
|
940
|
+
========================================================================== */
|
|
941
|
+
|
|
942
|
+
/* Registered custom property — makes --bar-progress interpolatable so a single
|
|
943
|
+
transition animates all five segment widths together (one ease, not five). */
|
|
944
|
+
@property --bar-progress {
|
|
945
|
+
syntax: "<number>";
|
|
946
|
+
initial-value: 0;
|
|
947
|
+
inherits: true;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/* The motion PROPERTIES (transition / animation) live on these classes — NOT on
|
|
951
|
+
the view's inline style — precisely so the reduced-motion override below can
|
|
952
|
+
reach and cancel them. An inline `style="transition: …"` beats a class rule on
|
|
953
|
+
specificity, so a class-based @media override could never win against it; the
|
|
954
|
+
view keeps only the dynamic values inline (the Alpine-bound transform /
|
|
955
|
+
--bar-progress, and the fill duration knob --seeds-fill-dur). */
|
|
956
|
+
.seeds-bar-continuous {
|
|
957
|
+
--bar-progress: 0;
|
|
958
|
+
transition: --bar-progress var(--seeds-fill-dur, 1.2s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/* The rolling digit reel's slide — same fill duration as the bar (via the shared
|
|
962
|
+
--seeds-fill-dur knob) so the counter tracks the fill. Class-based so
|
|
963
|
+
reduced-motion can cancel it (the view binds only the transform inline). */
|
|
964
|
+
.seeds-reel-track {
|
|
965
|
+
transition: transform var(--seeds-fill-dur, 1.2s) cubic-bezier(0.16, 1, 0.3, 1);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/* Light sweep across a filled segment. The five segments stagger a shared band
|
|
969
|
+
(via per-segment offsets in the view) so it reads as one continuous wave. */
|
|
970
|
+
.seeds-shimmer {
|
|
971
|
+
animation: seedsShimmer 2.5s ease-in-out 0.4s 1 both;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
@keyframes seedsShimmer {
|
|
975
|
+
0% { transform: translateX(-100%); }
|
|
976
|
+
100% { transform: translateX(200%); }
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/* Reduced-motion: drop the interpolation + shimmer; the bar, reel, and shimmer
|
|
980
|
+
all snap to their resting value. Reachable because the motion lives on the
|
|
981
|
+
classes above, not on inline styles. */
|
|
982
|
+
@media (prefers-reduced-motion: reduce) {
|
|
983
|
+
.seeds-bar-continuous { transition: none; }
|
|
984
|
+
.seeds-reel-track { transition: none; }
|
|
985
|
+
.seeds-shimmer { animation: none; }
|
|
986
|
+
}
|
|
987
|
+
|
|
930
988
|
/* =============================================================================
|
|
931
989
|
Reduced-motion: honor the user preference. Animated primitives settle into a
|
|
932
990
|
sensible static resting state; the static primitives (fade-edge, text-gradient,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
ageVerifyModal factory — the Alpine data for the engine age-gate DOB modal
|
|
3
|
+
(studio/modals/blocks/_age_verify). Ships here, at PAGE level, on purpose: the
|
|
4
|
+
modal partial mounts inside a modal host's single-root template, and a script
|
|
5
|
+
cloned out of a template never runs. So a consumer renders THIS once in its
|
|
6
|
+
layout (like the shared alpine factories), and the modal partial anywhere below
|
|
7
|
+
just references window.ageVerifyModal. Mirrors how studio/_cropper_assets homes
|
|
8
|
+
the cropPhotoModal factory for the crop modal.
|
|
9
|
+
|
|
10
|
+
The factory holds ZERO legal policy — no age default, no state table. minAge is
|
|
11
|
+
whatever the app hands it (a required prop upstream); the client "too young"
|
|
12
|
+
hint is UX only, and the server recompute at submit_url stays authoritative.
|
|
13
|
+
|
|
14
|
+
opts: { minAge, state, url, store, demo }.
|
|
15
|
+
minAge app-supplied minimum age (no engine fallback — 0 means unsupplied).
|
|
16
|
+
state passive jurisdiction label (server-detected upstream; never editable).
|
|
17
|
+
url app endpoint the DOB POSTs to (the app owns the authoritative check).
|
|
18
|
+
store Alpine modal store name backing close() (default "modals").
|
|
19
|
+
demo style-guide preview: resolve locally, skip the network POST.
|
|
20
|
+
%>
|
|
21
|
+
<script>
|
|
22
|
+
(function () {
|
|
23
|
+
if (window.ageVerifyModal) return;
|
|
24
|
+
window.ageVerifyModal = function (opts) {
|
|
25
|
+
opts = opts || {};
|
|
26
|
+
var now = new Date();
|
|
27
|
+
return {
|
|
28
|
+
month: "", day: "", year: "",
|
|
29
|
+
submitting: false,
|
|
30
|
+
error: "",
|
|
31
|
+
// No engine age default: 18 is itself a policy value. The app supplies it.
|
|
32
|
+
minAge: parseInt(opts.minAge, 10) || 0,
|
|
33
|
+
stateCode: opts.state || "",
|
|
34
|
+
url: opts.url || "",
|
|
35
|
+
store: opts.store || "modals",
|
|
36
|
+
demo: !!opts.demo,
|
|
37
|
+
months: [
|
|
38
|
+
{ n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
|
|
39
|
+
{ n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
|
|
40
|
+
{ n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
|
|
41
|
+
{ n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
|
|
42
|
+
],
|
|
43
|
+
// Year range: 13 years ago (youngest plausible) back 100 years. This is a
|
|
44
|
+
// sane input range, not an age bar — the app's minAge decides eligibility.
|
|
45
|
+
years: (function () {
|
|
46
|
+
var ys = [], top = now.getFullYear() - 13, bottom = now.getFullYear() - 100;
|
|
47
|
+
for (var y = top; y >= bottom; y--) ys.push(y);
|
|
48
|
+
return ys;
|
|
49
|
+
})(),
|
|
50
|
+
// Days available for the chosen month/year (handles 28/29/30/31).
|
|
51
|
+
get dayOptions() {
|
|
52
|
+
var m = parseInt(this.month, 10), y = parseInt(this.year, 10);
|
|
53
|
+
var max = 31;
|
|
54
|
+
if (m) max = new Date(y || 2000, m, 0).getDate();
|
|
55
|
+
var ds = [];
|
|
56
|
+
for (var d = 1; d <= max; d++) ds.push(d);
|
|
57
|
+
return ds;
|
|
58
|
+
},
|
|
59
|
+
get complete() {
|
|
60
|
+
return !!(this.month && this.day && this.year);
|
|
61
|
+
},
|
|
62
|
+
get computedAge() {
|
|
63
|
+
if (!this.complete) return null;
|
|
64
|
+
var dob = new Date(parseInt(this.year, 10), parseInt(this.month, 10) - 1, parseInt(this.day, 10));
|
|
65
|
+
var age = now.getFullYear() - dob.getFullYear();
|
|
66
|
+
var hadBirthday = (now.getMonth() > dob.getMonth()) ||
|
|
67
|
+
(now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate());
|
|
68
|
+
if (!hadBirthday) age -= 1;
|
|
69
|
+
return age;
|
|
70
|
+
},
|
|
71
|
+
// Clamp an out-of-range day when the month changes (e.g. Feb after 31).
|
|
72
|
+
onMonthChange() {
|
|
73
|
+
if (this.day && parseInt(this.day, 10) > this.dayOptions.length) this.day = "";
|
|
74
|
+
this.error = "";
|
|
75
|
+
},
|
|
76
|
+
// Success path: flip a session flag if present, close via the app's store,
|
|
77
|
+
// and let the app resume its own flow (the entry gate listens for this).
|
|
78
|
+
_finish() {
|
|
79
|
+
try {
|
|
80
|
+
var sess = window.Alpine && Alpine.store && Alpine.store("session");
|
|
81
|
+
if (sess) sess.ageVerified = true;
|
|
82
|
+
} catch (e) {}
|
|
83
|
+
try { Alpine.store(this.store).close(); } catch (e) {}
|
|
84
|
+
window.dispatchEvent(new CustomEvent("age-verified"));
|
|
85
|
+
},
|
|
86
|
+
submit() {
|
|
87
|
+
if (!this.complete || this.submitting) return;
|
|
88
|
+
this.error = "";
|
|
89
|
+
// Client courtesy gate only — the server recompute is authoritative.
|
|
90
|
+
if (this.computedAge !== null && this.minAge && this.computedAge < this.minAge) return;
|
|
91
|
+
this.submitting = true;
|
|
92
|
+
var self = this;
|
|
93
|
+
if (this.demo) {
|
|
94
|
+
// Style-guide preview: no backend, resolve locally so the modal
|
|
95
|
+
// demonstrates the success handoff without an endpoint.
|
|
96
|
+
setTimeout(function () { self.submitting = false; self._finish(); }, 450);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
|
100
|
+
fetch(this.url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
103
|
+
body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
|
|
104
|
+
})
|
|
105
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
106
|
+
.then(function (res) {
|
|
107
|
+
self.submitting = false;
|
|
108
|
+
if (res.ok && res.body && res.body.verified) { self._finish(); return; }
|
|
109
|
+
self.error = (res.body && res.body.error) ||
|
|
110
|
+
"We couldn't verify your age. Please check the date and try again.";
|
|
111
|
+
})
|
|
112
|
+
.catch(function () {
|
|
113
|
+
self.submitting = false;
|
|
114
|
+
self.error = "Network error — please try again.";
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
})();
|
|
120
|
+
</script>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Age-gate DOB modal — engine primitive. Collects a real date of birth
|
|
3
|
+
(Month / Day / Year), shows a live "too young" hint, and submits. Homed in the
|
|
4
|
+
engine as the heavier sibling of studio/modals/shared/_age_attestation (the
|
|
5
|
+
lighter one-checkbox legal attestation). RUN ONE OR THE OTHER, not both: the
|
|
6
|
+
attestation is a signup-time "I confirm I'm of legal age" checkbox; this is an
|
|
7
|
+
entry-time DOB gate that a server can recompute and stamp.
|
|
8
|
+
|
|
9
|
+
CRITICAL — the engine hardcodes NO legal policy. It renders the modal UI, the
|
|
10
|
+
DOB fields, and the submit; everything legal is app-supplied:
|
|
11
|
+
|
|
12
|
+
min_age (Integer, REQUIRED) — the app's minimum age. There is NO engine
|
|
13
|
+
default (18 is itself a policy value); the app resolves it from
|
|
14
|
+
its own policy against its own server-detected jurisdiction.
|
|
15
|
+
submit_url (String, REQUIRED) — the app endpoint the DOB POSTs to. The
|
|
16
|
+
app owns the authoritative recompute + DOB persistence; the modal
|
|
17
|
+
only shows the response.
|
|
18
|
+
state (String, optional) — a PASSIVE jurisdiction label for the copy
|
|
19
|
+
(e.g. "CA"). Server-detected upstream; the modal never detects
|
|
20
|
+
geography and never offers an editable state field (a spoofable
|
|
21
|
+
client state must not be able to lower the bar). Blank => the
|
|
22
|
+
jurisdiction clause is dropped.
|
|
23
|
+
title (String, optional) — modal title. Default "Verify your age".
|
|
24
|
+
intro (String, optional) — the lead sentence after the age line.
|
|
25
|
+
fine_print (String, optional) — the app's legal / jurisdiction copy (e.g.
|
|
26
|
+
its per-state age table). The engine ships only a neutral,
|
|
27
|
+
policy-free default; the app passes its real copy here.
|
|
28
|
+
modal_store (String, optional) — Alpine store backing close(). Default "modals".
|
|
29
|
+
demo (Boolean, optional) — style-guide preview: resolve locally, no POST.
|
|
30
|
+
|
|
31
|
+
Requires window.ageVerifyModal — render studio/_age_verify_assets once at
|
|
32
|
+
layout level (the factory can't ship inside this template; a cloned script
|
|
33
|
+
never runs). Single root element (modal-host template mount rule).
|
|
34
|
+
%>
|
|
35
|
+
<%
|
|
36
|
+
min_age = local_assigns.fetch(:min_age)
|
|
37
|
+
submit_url = local_assigns.fetch(:submit_url)
|
|
38
|
+
state = local_assigns[:state].to_s.strip
|
|
39
|
+
title = local_assigns.fetch(:title, "Verify your age")
|
|
40
|
+
intro = local_assigns.fetch(:intro, "Enter your date of birth to confirm you're eligible — we only ask once.")
|
|
41
|
+
fine_print = local_assigns.fetch(:fine_print, "We use your date of birth only to confirm eligibility.")
|
|
42
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
43
|
+
demo = local_assigns.fetch(:demo, false)
|
|
44
|
+
%>
|
|
45
|
+
<div x-data="ageVerifyModal({ minAge: <%= min_age.to_i %>, state: '<%= j state %>', url: '<%= j submit_url %>', store: '<%= j modal_store %>', demo: <%= demo ? 'true' : 'false' %> })">
|
|
46
|
+
<%= render layout: "studio/modals/blocks/shell", locals: { title: title, modal_store: modal_store } do %>
|
|
47
|
+
<p class="text-sm text-muted mb-4">
|
|
48
|
+
You must be <span class="text-heading font-semibold"><%= min_age %>+</span><%= state.present? ? " in #{state}" : "" %>.
|
|
49
|
+
<%= intro %>
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
<%# Month / Day / Year selectors %>
|
|
53
|
+
<div class="grid grid-cols-3 gap-2 mb-4">
|
|
54
|
+
<div>
|
|
55
|
+
<label class="label-upper block mb-1">Month</label>
|
|
56
|
+
<select x-model="month" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
57
|
+
<option value="" disabled>Month</option>
|
|
58
|
+
<template x-for="m in months" :key="m.n">
|
|
59
|
+
<option :value="m.n" x-text="m.label"></option>
|
|
60
|
+
</template>
|
|
61
|
+
</select>
|
|
62
|
+
</div>
|
|
63
|
+
<div>
|
|
64
|
+
<label class="label-upper block mb-1">Day</label>
|
|
65
|
+
<select x-model="day" @change="error = ''" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
66
|
+
<option value="" disabled>Day</option>
|
|
67
|
+
<template x-for="d in dayOptions" :key="d">
|
|
68
|
+
<option :value="d" x-text="d"></option>
|
|
69
|
+
</template>
|
|
70
|
+
</select>
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<label class="label-upper block mb-1">Year</label>
|
|
74
|
+
<select x-model="year" @change="onMonthChange()" class="input-field !px-3 !py-2.5 text-sm appearance-none cursor-pointer">
|
|
75
|
+
<option value="" disabled>Year</option>
|
|
76
|
+
<template x-for="y in years" :key="y">
|
|
77
|
+
<option :value="y" x-text="y"></option>
|
|
78
|
+
</template>
|
|
79
|
+
</select>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<%# Live too-young hint — shown once a full date is picked and it fails the
|
|
84
|
+
app-supplied bar, so the user sees the problem before submitting. %>
|
|
85
|
+
<template x-if="complete && computedAge !== null && minAge && computedAge < minAge">
|
|
86
|
+
<p class="text-red-400 text-sm mb-3" x-text="'You must be ' + minAge + '+' + (stateCode ? ' in ' + stateCode : '') + '.'"></p>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<%# Server error (invalid date, or the authoritative too-young check). %>
|
|
90
|
+
<template x-if="error">
|
|
91
|
+
<p class="text-red-400 text-sm mb-3" x-text="error"></p>
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<button type="button" @click="submit()"
|
|
95
|
+
class="btn btn-primary btn-lg w-full"
|
|
96
|
+
:disabled="!complete || submitting || (computedAge !== null && minAge && computedAge < minAge)">
|
|
97
|
+
<span x-show="!submitting">Confirm & Continue</span>
|
|
98
|
+
<span x-show="submitting" class="inline-flex items-center justify-center gap-2" style="display: none;">
|
|
99
|
+
<span class="cta-spinner" aria-hidden="true"></span>
|
|
100
|
+
Verifying…
|
|
101
|
+
</span>
|
|
102
|
+
</button>
|
|
103
|
+
|
|
104
|
+
<p class="text-[11px] text-muted text-center mt-3 leading-snug"><%= fine_print %></p>
|
|
105
|
+
<% end %>
|
|
106
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Rolling-digit reel — a vertical stack of the values 0..max clipped to a single
|
|
3
|
+
cell, translated by -(value * cell_em)em so it slides as `value` changes. The
|
|
4
|
+
shared core behind rotating counters (a single 0–100 reel for the seeds bar,
|
|
5
|
+
per-unit em cells for countdowns). Ported from turf-monster; app-agnostic (only
|
|
6
|
+
Alpine expressions + locals — no app models or helpers).
|
|
7
|
+
|
|
8
|
+
Renders INSIDE a parent Alpine x-data scope: `value` and `transition` are Alpine
|
|
9
|
+
*expressions* evaluated there (e.g. "displaySeeds"; "'transform 1.9s ...'"). This
|
|
10
|
+
owns only the clip window + reel mechanics — colour, font, and any unit letter
|
|
11
|
+
live on the caller's wrapper.
|
|
12
|
+
|
|
13
|
+
Cells are em-sized, so the reel scales with the inherited font size; cell_em is
|
|
14
|
+
both the cell height (em) and the translate multiplier, so a value of N lands
|
|
15
|
+
cell N exactly under the window.
|
|
16
|
+
|
|
17
|
+
Locals:
|
|
18
|
+
value (String) — Alpine expr for the current value (drives translate)
|
|
19
|
+
max (Integer) — highest cell; renders cells 0..max
|
|
20
|
+
transition (String) — Alpine expr for the CSS transition (or "'none'")
|
|
21
|
+
width (String) — CSS width of the clip window, e.g. "2ch" / "3.2ch"
|
|
22
|
+
cell_em (Numeric?) — cell height in em + translate multiplier (default 1)
|
|
23
|
+
pad (Bool?) — zero-pad cell numbers to 2 digits (default false)
|
|
24
|
+
transition (String?) — Alpine expr for the slide transition when track_class
|
|
25
|
+
is NOT used (default "'none'"). Inline, so a caller can
|
|
26
|
+
snap on rollover; NOT reachable by reduced-motion CSS.
|
|
27
|
+
track_class(String?) — a CSS class that owns the slide transition. When set,
|
|
28
|
+
the reel binds ONLY the transform inline and the class
|
|
29
|
+
supplies the transition — so a reduced-motion @media
|
|
30
|
+
rule can cancel it (the seeds bar uses this). %>
|
|
31
|
+
<%
|
|
32
|
+
cell_em = local_assigns.fetch(:cell_em, 1)
|
|
33
|
+
pad = local_assigns.fetch(:pad, false)
|
|
34
|
+
track_class = local_assigns[:track_class]
|
|
35
|
+
transition = local_assigns.fetch(:transition, "'none'")
|
|
36
|
+
%>
|
|
37
|
+
<span style="display:inline-block;overflow:hidden;height:<%= cell_em %>em;line-height:<%= cell_em %>em;width:<%= width %>;text-align:right;">
|
|
38
|
+
<span class="block<%= " #{track_class}" if track_class %>" :style="{ transform: 'translateY(-' + (<%= value %> * <%= cell_em %>) + 'em)'<%= track_class ? "" : ", transition: #{transition}" %> }">
|
|
39
|
+
<% (0..max).each do |n| %><span class="block" style="height:<%= cell_em %>em;line-height:<%= cell_em %>em;"><%= pad ? format("%02d", n) : n %></span><% end %>
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Entry-confirmed celebration — the canonical "you're in" card for an on-chain
|
|
3
|
+
(or entry-token-funded) entry. Homed in the engine and composed via the
|
|
4
|
+
_success_card YIELD SLOT (its documented purpose): _success_card owns the web3
|
|
5
|
+
spine — the :lg check header, the branded Solana tx link, the drain CTA with
|
|
6
|
+
auto-redirect, and the confetti — and this partial injects the LEVELING
|
|
7
|
+
enrichment (the seeds bar + level-up reward) into its block.
|
|
8
|
+
|
|
9
|
+
Capability gating (the two flags are independent):
|
|
10
|
+
* The generic success (heading + branded tx link + CTA) is a web3 celebration;
|
|
11
|
+
a caller renders this only in a :web3 context.
|
|
12
|
+
* The seeds bar + "Free Entry Earned" reward render ONLY under
|
|
13
|
+
Studio.feature?(:leveling). A web3-only app (leveling OFF) gets the clean
|
|
14
|
+
success card — no seeds, no free entry — automatically, because this partial
|
|
15
|
+
self-gates the block on the flag.
|
|
16
|
+
|
|
17
|
+
All numbers are APP-SUPPLIED via Alpine expressions (defaulting to props.*): the
|
|
18
|
+
engine renders the card, the app feeds txSignature / lobbyUrl / seedsEarned /
|
|
19
|
+
seedsTotal. The reward copy + CTA label + seeds-per-level are parameters, so no
|
|
20
|
+
Turf-specific string is baked in.
|
|
21
|
+
|
|
22
|
+
Single root: the outer <div> is one element so a <template x-if> clones it whole.
|
|
23
|
+
|
|
24
|
+
Locals (all optional; sensible props.* / static defaults):
|
|
25
|
+
modal_store — Alpine store name backing props/close. Default "modals".
|
|
26
|
+
title_key — Alpine expr for the headline. Default "props.title || 'Good Luck'".
|
|
27
|
+
subtitle — static sub-headline. Default "Entry Confirmed".
|
|
28
|
+
subtitle_key — Alpine expr for the sub-headline (priority over subtitle).
|
|
29
|
+
tx_signature_key — Alpine expr for the Solana tx hash. Default "props.txSignature".
|
|
30
|
+
cluster_param — explorer cluster query (e.g. "?cluster=devnet"). Default "".
|
|
31
|
+
lobby_url_key — Alpine expr for the CTA href + auto-redirect. Default "props.lobbyUrl".
|
|
32
|
+
cta_label — primary CTA text. Default "Continue".
|
|
33
|
+
seeds_earned_key — Alpine expr: seeds awarded this entry. Default "props.seedsEarned".
|
|
34
|
+
seeds_total_key — Alpine expr: new seeds total. Default "props.seedsTotal".
|
|
35
|
+
seeds_per_level — Integer seeds per level. Default 100.
|
|
36
|
+
free_entry_label — reward copy on a level-up. Default "Free Entry Earned 🎟️".
|
|
37
|
+
%>
|
|
38
|
+
<%
|
|
39
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
40
|
+
title_key = local_assigns.fetch(:title_key, "props.title || 'Good Luck'")
|
|
41
|
+
subtitle = local_assigns.fetch(:subtitle, "Entry Confirmed")
|
|
42
|
+
subtitle_key = local_assigns[:subtitle_key]
|
|
43
|
+
tx_signature_key = local_assigns.fetch(:tx_signature_key, "props.txSignature")
|
|
44
|
+
cluster_param = local_assigns.fetch(:cluster_param, "")
|
|
45
|
+
lobby_url_key = local_assigns.fetch(:lobby_url_key, "props.lobbyUrl")
|
|
46
|
+
cta_label = local_assigns.fetch(:cta_label, "Continue")
|
|
47
|
+
seeds_earned_key = local_assigns.fetch(:seeds_earned_key, "props.seedsEarned")
|
|
48
|
+
seeds_total_key = local_assigns.fetch(:seeds_total_key, "props.seedsTotal")
|
|
49
|
+
seeds_per_level = local_assigns.fetch(:seeds_per_level, 100)
|
|
50
|
+
free_entry_label = local_assigns.fetch(:free_entry_label, "Free Entry Earned 🎟️")
|
|
51
|
+
|
|
52
|
+
sc_locals = {
|
|
53
|
+
large_title: true,
|
|
54
|
+
icon_color: "primary",
|
|
55
|
+
title_key: title_key,
|
|
56
|
+
tx_signature_key: tx_signature_key,
|
|
57
|
+
tx_solana: true,
|
|
58
|
+
cta_label: cta_label,
|
|
59
|
+
cta_href_key: lobby_url_key,
|
|
60
|
+
cta_drain: true,
|
|
61
|
+
auto_redirect_url_key: lobby_url_key,
|
|
62
|
+
confetti: true
|
|
63
|
+
}
|
|
64
|
+
if subtitle_key
|
|
65
|
+
sc_locals[:message_key] = subtitle_key
|
|
66
|
+
else
|
|
67
|
+
sc_locals[:message] = subtitle
|
|
68
|
+
end
|
|
69
|
+
%>
|
|
70
|
+
<%# props getter + clusterParam live on the root scope so _success_card's nested
|
|
71
|
+
Alpine expressions (props.txSignature, props.lobbyUrl, clusterParam) resolve up
|
|
72
|
+
the scope chain. clusterParam feeds _success_card's branded tx-link URL. %>
|
|
73
|
+
<div x-data="{ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; }, clusterParam: '<%= cluster_param %>' }">
|
|
74
|
+
<%= render "studio/modals/blocks/success_card", sc_locals do %>
|
|
75
|
+
<% if Studio.feature?(:leveling) %>
|
|
76
|
+
<%# Leveling enrichment — mounts only when this entry awarded seeds. %>
|
|
77
|
+
<template x-if="(<%= seeds_earned_key %>) > 0">
|
|
78
|
+
<%= render "studio/modals/blocks/seeds_bar",
|
|
79
|
+
seeds_earned_key: seeds_earned_key,
|
|
80
|
+
seeds_total_key: seeds_total_key,
|
|
81
|
+
seeds_per_level: seeds_per_level,
|
|
82
|
+
free_entry_label: free_entry_label %>
|
|
83
|
+
</template>
|
|
84
|
+
<% end %>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Free-entry-earned — the standalone level-up celebration modal. Pops when a user
|
|
3
|
+
crosses a seed level (the reward for leveling). Ported from turf-monster and
|
|
4
|
+
generalized: the store, icon, title, and reward copy are all parameters, so no
|
|
5
|
+
app-specific string is baked in.
|
|
6
|
+
|
|
7
|
+
Capability: a :leveling primitive — a caller opens it only under
|
|
8
|
+
Studio.feature?(:leveling).
|
|
9
|
+
|
|
10
|
+
Single root: the outer <div> is one element so a <template x-if> clones it whole.
|
|
11
|
+
|
|
12
|
+
Locals (all optional):
|
|
13
|
+
modal_store — Alpine store name backing props/close. Default "modals".
|
|
14
|
+
icon_emoji — celebration glyph. Default "🎉".
|
|
15
|
+
title_key — Alpine expr for the headline. Default shows "Level N" from
|
|
16
|
+
props.level, else "Free Entry Earned".
|
|
17
|
+
subtitle — static reward copy. Default a generic level-up line.
|
|
18
|
+
subtitle_key — Alpine expr for the reward copy (priority over subtitle).
|
|
19
|
+
close_label — dismiss link text. Default "Close".
|
|
20
|
+
%>
|
|
21
|
+
<%
|
|
22
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
23
|
+
icon_emoji = local_assigns.fetch(:icon_emoji, "🎉")
|
|
24
|
+
title_key = local_assigns.fetch(:title_key, "props.level ? ('Level ' + props.level) : 'Free Entry Earned'")
|
|
25
|
+
subtitle = local_assigns.fetch(:subtitle, "You leveled up and earned a reward. Keep earning more with each level.")
|
|
26
|
+
subtitle_key = local_assigns[:subtitle_key]
|
|
27
|
+
close_label = local_assigns.fetch(:close_label, "Close")
|
|
28
|
+
|
|
29
|
+
ch_locals = { size: :lg, icon_emoji: icon_emoji, title_key: title_key }
|
|
30
|
+
if subtitle_key
|
|
31
|
+
ch_locals[:subtitle_key] = subtitle_key
|
|
32
|
+
else
|
|
33
|
+
ch_locals[:subtitle] = subtitle
|
|
34
|
+
end
|
|
35
|
+
%>
|
|
36
|
+
<%# Confetti on mount — prefer a free-entry-specific burst if the host defined one,
|
|
37
|
+
else the shared success confetti; both are optional (guarded). %>
|
|
38
|
+
<div x-data="{ get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; } }"
|
|
39
|
+
x-init="setTimeout(function () { try { (window.fireFreeEntryConfetti || window.fireSuccessConfetti || function(){})(); } catch (e) {} }, 120)">
|
|
40
|
+
<%= render "studio/modals/blocks/card_header", ch_locals %>
|
|
41
|
+
|
|
42
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
43
|
+
class="block mx-auto mt-4 text-sm text-secondary hover:text-heading transition">
|
|
44
|
+
<%= close_label %>
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|