studio-engine 0.51.0 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +108 -0
- data/app/assets/javascripts/studio/alpine.js +16 -0
- data/app/controllers/studio/profiles_controller.rb +53 -0
- data/app/views/layouts/studio/_head.html.erb +20 -1
- data/app/views/studio/profiles/_birthday_fields.html.erb +210 -12
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +144 -0
- data/app/views/studio/profiles/_identity.html.erb +22 -1
- data/app/views/studio/profiles/_identity_body.html.erb +52 -23
- data/app/views/studio/profiles/_identity_styles.html.erb +76 -15
- data/app/views/studio/profiles/_newsletter_modals.html.erb +61 -0
- data/app/views/studio/profiles/_newsletter_section.html.erb +65 -0
- data/app/views/studio/profiles/edit.html.erb +26 -9
- data/app/views/studio/profiles/show.html.erb +35 -0
- data/lib/studio/engine.rb +1 -0
- data/lib/studio/newsletter.rb +70 -0
- data/lib/studio/profile_sections.rb +33 -6
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +9 -0
- metadata +6 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<%# The birthday calendar's Alpine component.
|
|
2
|
+
|
|
3
|
+
Its own partial so the field markup stays readable and this can be reasoned
|
|
4
|
+
about as behaviour.
|
|
5
|
+
|
|
6
|
+
WHY NOT REUSE turf-monster's contest_lock_picker. That component is the
|
|
7
|
+
nearest thing in the ecosystem and its GRID is where this one's shape came
|
|
8
|
+
from, but its navigation is built for the opposite problem: picking a lock
|
|
9
|
+
time a few weeks OUT, by stepping months from today. Stepping to 1985 that
|
|
10
|
+
way is roughly four hundred clicks. A birthday needs to JUMP — hence the year
|
|
11
|
+
and month selects in the header — and it needs the future closed off, which a
|
|
12
|
+
contest picker specifically must not do.
|
|
13
|
+
|
|
14
|
+
WHY `position: fixed` AND NOT `absolute`. The edit page renders its rows
|
|
15
|
+
inside `card p-0 overflow-hidden`, which CLIPS an absolutely-positioned child
|
|
16
|
+
— the popover would open and be invisible below the row. Fixed takes it out
|
|
17
|
+
of the clipping context entirely, at the cost of having to place it by hand
|
|
18
|
+
from the trigger's rect and re-place it while it is open. The compact identity
|
|
19
|
+
header is fixed for a related reason, so this is the file's second instance of
|
|
20
|
+
the same trade rather than a one-off.
|
|
21
|
+
%>
|
|
22
|
+
<script>
|
|
23
|
+
window.studioBirthdayPicker = function (initial) {
|
|
24
|
+
return {
|
|
25
|
+
open: false,
|
|
26
|
+
value: initial || "",
|
|
27
|
+
viewYear: 0,
|
|
28
|
+
viewMonth: 0,
|
|
29
|
+
top: 0,
|
|
30
|
+
left: 0,
|
|
31
|
+
width: 0,
|
|
32
|
+
|
|
33
|
+
// Oldest year offered. 120 covers every living person with room to spare;
|
|
34
|
+
// the point is a bounded list, because a select has to end somewhere.
|
|
35
|
+
get years() {
|
|
36
|
+
var now = new Date().getFullYear();
|
|
37
|
+
var out = [];
|
|
38
|
+
for (var y = now; y >= now - 120; y--) out.push(y);
|
|
39
|
+
return out;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
months: ["January", "February", "March", "April", "May", "June",
|
|
43
|
+
"July", "August", "September", "October", "November", "December"],
|
|
44
|
+
|
|
45
|
+
weekdays: ["S", "M", "T", "W", "T", "F", "S"],
|
|
46
|
+
|
|
47
|
+
init() {
|
|
48
|
+
this.syncViewFromValue();
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// The month the grid opens on: the chosen birthday when there is one, else
|
|
52
|
+
// a sensible landing spot. NOT today — opening a birthday picker on the
|
|
53
|
+
// current year means everyone starts 30+ years from where they are going.
|
|
54
|
+
syncViewFromValue() {
|
|
55
|
+
var parts = this.parse(this.value);
|
|
56
|
+
var now = new Date();
|
|
57
|
+
this.viewYear = parts ? parts.y : now.getFullYear() - 30;
|
|
58
|
+
this.viewMonth = parts ? parts.m - 1 : 0;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
parse(iso) {
|
|
62
|
+
var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ""));
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { y: +m[1], m: +m[2], d: +m[3] };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
get displayValue() {
|
|
68
|
+
var p = this.parse(this.value);
|
|
69
|
+
if (!p) return "";
|
|
70
|
+
return this.months[p.m - 1] + " " + p.d + ", " + p.y;
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// Leading blanks so the 1st lands under its real weekday, then the days.
|
|
74
|
+
calDays() {
|
|
75
|
+
var first = new Date(this.viewYear, this.viewMonth, 1).getDay();
|
|
76
|
+
var count = new Date(this.viewYear, this.viewMonth + 1, 0).getDate();
|
|
77
|
+
var cells = [];
|
|
78
|
+
for (var i = 0; i < first; i++) cells.push(null);
|
|
79
|
+
for (var d = 1; d <= count; d++) cells.push(d);
|
|
80
|
+
return cells;
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// A birthday cannot be in the future. Checked per-DAY rather than by
|
|
84
|
+
// clamping the year, because the boundary month is half valid.
|
|
85
|
+
isFuture(day) {
|
|
86
|
+
var candidate = new Date(this.viewYear, this.viewMonth, day);
|
|
87
|
+
var today = new Date();
|
|
88
|
+
today.setHours(0, 0, 0, 0);
|
|
89
|
+
return candidate > today;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
isSelected(day) {
|
|
93
|
+
var p = this.parse(this.value);
|
|
94
|
+
return !!p && p.y === this.viewYear && p.m === this.viewMonth + 1 && p.d === day;
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
pick(day) {
|
|
98
|
+
if (this.isFuture(day)) return;
|
|
99
|
+
var mm = String(this.viewMonth + 1).padStart(2, "0");
|
|
100
|
+
var dd = String(day).padStart(2, "0");
|
|
101
|
+
this.value = this.viewYear + "-" + mm + "-" + dd;
|
|
102
|
+
|
|
103
|
+
// `fields` belongs to the enclosing studioProfileForm component. Alpine's
|
|
104
|
+
// scope chain makes it reachable from here, and it is the SAME object, so
|
|
105
|
+
// assigning through it is what raises the save bar. Without this line the
|
|
106
|
+
// picker would set a value the dirty check never notices.
|
|
107
|
+
if (this.fields) this.fields.birthday = this.value;
|
|
108
|
+
|
|
109
|
+
this.open = false;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
clear() {
|
|
113
|
+
this.value = "";
|
|
114
|
+
if (this.fields) this.fields.birthday = "";
|
|
115
|
+
this.syncViewFromValue();
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
toggle() {
|
|
119
|
+
this.open = !this.open;
|
|
120
|
+
if (this.open) {
|
|
121
|
+
this.syncViewFromValue();
|
|
122
|
+
this.place();
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// Placed from the trigger's rect because the popover is fixed. Flips above
|
|
127
|
+
// the trigger when there is not room below, so it never opens off-screen on
|
|
128
|
+
// a short viewport.
|
|
129
|
+
place() {
|
|
130
|
+
var el = this.$refs.trigger;
|
|
131
|
+
if (!el) return;
|
|
132
|
+
var r = el.getBoundingClientRect();
|
|
133
|
+
var estimatedHeight = 340;
|
|
134
|
+
var below = window.innerHeight - r.bottom;
|
|
135
|
+
|
|
136
|
+
this.left = r.left;
|
|
137
|
+
this.width = r.width;
|
|
138
|
+
this.top = below < estimatedHeight && r.top > estimatedHeight
|
|
139
|
+
? r.top - estimatedHeight - 4
|
|
140
|
+
: r.bottom + 4;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
</script>
|
|
@@ -28,7 +28,28 @@
|
|
|
28
28
|
<%= render "studio/profiles/identity_mini", user: user %>
|
|
29
29
|
|
|
30
30
|
<% if editable %>
|
|
31
|
-
|
|
31
|
+
<%# `studio-identity-card-editable` is the hover hook: the operator asked for the
|
|
32
|
+
overlay to reveal on hovering the CARD, not only the picture, so the card
|
|
33
|
+
needs a class the stylesheet can hang that off. Deliberately NOT the read
|
|
34
|
+
page's `studio-identity-card`, which also paints a primary border to say
|
|
35
|
+
"this whole thing is a link" — a claim that is false here. %>
|
|
36
|
+
<%# THE WHOLE CARD IS THE TRIGGER (operator's call, 2026-08-15). The hover
|
|
37
|
+
already revealed the label across the whole card, so the click had to match
|
|
38
|
+
it — a surface that lights up on hover and does nothing when clicked reads
|
|
39
|
+
as broken.
|
|
40
|
+
|
|
41
|
+
`@click.self` is NOT used: the click must count anywhere on the card,
|
|
42
|
+
including on the name and the address. The inner avatar button keeps its own
|
|
43
|
+
handler with `.stop`, so clicking the picture fires the picker ONCE rather
|
|
44
|
+
than twice — the button and the card would otherwise both handle it and open
|
|
45
|
+
two file dialogs.
|
|
46
|
+
|
|
47
|
+
The button stays for keyboard and assistive tech: a div with a click handler
|
|
48
|
+
is unreachable by either, and this is still the only route to changing the
|
|
49
|
+
photo. %>
|
|
50
|
+
<div data-studio-identity-full
|
|
51
|
+
@click="$refs.filePicker.click()"
|
|
52
|
+
class="studio-identity-card-editable studio-identity-card-clickable card p-6 mb-6 text-center">
|
|
32
53
|
<%= render "studio/profiles/identity_body", user: user, editable: true, attachable: attachable %>
|
|
33
54
|
</div>
|
|
34
55
|
<% else %>
|
|
@@ -5,38 +5,67 @@
|
|
|
5
5
|
|
|
6
6
|
Locals: user, editable, attachable.
|
|
7
7
|
%>
|
|
8
|
-
|
|
8
|
+
<%# THE PICTURE ITSELF IS THE CONTROL ON THE EDIT PAGE (operator's call). The
|
|
9
|
+
28px pencil badge that used to sit in the corner is gone there: hovering the
|
|
10
|
+
card fades a label over the avatar, and clicking anywhere on it opens the
|
|
11
|
+
picker and then the cropper. A 128px target beats a 28px one, and the label
|
|
12
|
+
says what will happen instead of leaving a glyph to be interpreted.
|
|
13
|
+
|
|
14
|
+
The badge REMAINS on the read page, where it is decorative and the whole card
|
|
15
|
+
is the link. %>
|
|
16
|
+
<% avatar_style = "width: 128px; height: 128px;" %>
|
|
17
|
+
|
|
18
|
+
<% avatar_inner = capture do %>
|
|
9
19
|
<% if attachable && user.avatar.attached? %>
|
|
10
20
|
<%= image_tag user.avatar, class: "rounded-full object-cover",
|
|
11
|
-
style:
|
|
21
|
+
style: avatar_style, alt: user.display_name %>
|
|
12
22
|
<% else %>
|
|
13
23
|
<div class="rounded-full flex items-center justify-center font-bold text-white text-4xl"
|
|
14
|
-
style="
|
|
24
|
+
style="<%= avatar_style %> background-color: <%= user.avatar_color %>">
|
|
15
25
|
<%= user.avatar_initials %>
|
|
16
26
|
</div>
|
|
17
27
|
<% end %>
|
|
28
|
+
<% end %>
|
|
29
|
+
|
|
30
|
+
<% if editable && attachable %>
|
|
31
|
+
<%# A BUTTON, not a div with a click handler: this is the only route to
|
|
32
|
+
changing the photo, and a div is unreachable by keyboard and invisible to
|
|
33
|
+
assistive tech.
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
the destination and the accessible name; this is just the glyph. %>
|
|
35
|
-
<span class="<%= badge_class %>" style="<%= badge_style %>" aria-hidden="true">
|
|
36
|
-
<%= render "studio/profiles/pencil_icon" %>
|
|
35
|
+
The visible label doubles as the ACCESSIBLE NAME, which is why it lives
|
|
36
|
+
inside the button rather than beside it and why there is no aria-label to
|
|
37
|
+
drift from it. It is hidden by opacity, never by `display:none` or
|
|
38
|
+
`visibility:hidden` — those two remove an element from the accessibility
|
|
39
|
+
tree, which would leave this button silently unnamed for a screen reader
|
|
40
|
+
at rest. %>
|
|
41
|
+
<%# `.stop` because the CARD now carries the same handler. Without it the click
|
|
42
|
+
bubbles and the picker opens twice. %>
|
|
43
|
+
<button type="button" @click.stop="$refs.filePicker.click()"
|
|
44
|
+
class="studio-avatar studio-avatar-trigger relative mb-3 mx-auto block rounded-full overflow-hidden"
|
|
45
|
+
style="<%= avatar_style %>">
|
|
46
|
+
<%= avatar_inner %>
|
|
47
|
+
|
|
48
|
+
<span class="studio-avatar-overlay absolute inset-0 flex items-center justify-center rounded-full text-white font-semibold text-sm px-2 text-center">
|
|
49
|
+
Change photo
|
|
37
50
|
</span>
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
</button>
|
|
52
|
+
<% else %>
|
|
53
|
+
<div class="relative mb-3 studio-avatar mx-auto" style="<%= avatar_style %>">
|
|
54
|
+
<%= avatar_inner %>
|
|
55
|
+
|
|
56
|
+
<% if !editable %>
|
|
57
|
+
<%# DECORATIVE on the read page, and it has to be: the whole card is a link
|
|
58
|
+
now, and an <a> inside an <a> is invalid HTML that browsers repair by
|
|
59
|
+
closing the outer one early — which would silently cut the card's
|
|
60
|
+
clickable area down to whatever came before the badge. The card carries
|
|
61
|
+
the destination and the accessible name; this is just the glyph. %>
|
|
62
|
+
<span class="studio-avatar-badge absolute bottom-0 right-0 flex items-center justify-center rounded-full border-2 border-page bg-primary text-white transition"
|
|
63
|
+
style="width: 28px; height: 28px;" aria-hidden="true">
|
|
64
|
+
<%= render "studio/profiles/pencil_icon" %>
|
|
65
|
+
</span>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
40
69
|
|
|
41
70
|
<p class="text-3xl font-extrabold text-heading"><%= user.display_name %></p>
|
|
42
71
|
<% if user.respond_to?(:email) && user.email.present? %>
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
<%# The
|
|
1
|
+
<%# The identity header's styles — the read page's badge, the edit page's avatar
|
|
2
|
+
control, and the compact header that takes over on scroll.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
TWO DIFFERENT AFFORDANCES, because the two pages ask different questions.
|
|
5
|
+
|
|
6
|
+
READ — the whole card is a link to /profile/edit. A 28px badge sits in the
|
|
7
|
+
avatar's corner, empty until you hover the card, and then the pencil
|
|
8
|
+
fades in. Decorative: the card carries the destination.
|
|
9
|
+
EDIT — there is no badge. The PICTURE is the button (operator's call,
|
|
10
|
+
2026-08-15), and hovering the card fades a "Change photo" label over
|
|
11
|
+
it. A 128px target beats a 28px one, and a label beats a glyph.
|
|
5
12
|
|
|
6
13
|
WRITTEN AS CSS RATHER THAN TAILWIND UTILITIES on purpose. The engine ships a
|
|
7
14
|
prebuilt bundle to consumers, so a utility only exists if something already
|
|
@@ -21,7 +28,8 @@
|
|
|
21
28
|
visible there rather than never appearing
|
|
22
29
|
|
|
23
30
|
The badge circle itself is always visible in every case; it is only the
|
|
24
|
-
glyph inside it that waits.
|
|
31
|
+
glyph inside it that waits. The edit page's overlay follows the same three
|
|
32
|
+
paths for the same reasons.
|
|
25
33
|
%>
|
|
26
34
|
<style>
|
|
27
35
|
.studio-avatar-badge-icon {
|
|
@@ -29,19 +37,51 @@
|
|
|
29
37
|
transition: opacity 200ms ease;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
/* The
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
/* The READ page only. The badge is decorative there and the whole card is the
|
|
41
|
+
link, so hovering anywhere on it reveals the glyph.
|
|
42
|
+
|
|
43
|
+
The edit page's badge-button selectors used to live here and are GONE with
|
|
44
|
+
the button — the picture itself is the control there now, and its reveal is
|
|
45
|
+
the overlay block below. A selector for an element that no longer renders is
|
|
46
|
+
not harmless: it reads as coverage. */
|
|
35
47
|
.studio-identity-card:hover .studio-avatar-badge-icon,
|
|
36
48
|
.studio-identity-card:focus .studio-avatar-badge-icon,
|
|
37
49
|
.studio-identity-card:focus-visible .studio-avatar-badge-icon,
|
|
38
|
-
.studio-avatar:hover .studio-avatar-badge-icon
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
.studio-avatar:hover .studio-avatar-badge-icon {
|
|
51
|
+
opacity: 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ---- THE EDIT PAGE'S AVATAR CONTROL -------------------------------------
|
|
55
|
+
The picture is the button. A scrim + label fades in over it on hover, so the
|
|
56
|
+
resting state is just the photo. */
|
|
57
|
+
.studio-avatar-overlay {
|
|
58
|
+
opacity: 0;
|
|
59
|
+
background: rgba(0, 0, 0, 0.55);
|
|
60
|
+
transition: opacity 200ms ease;
|
|
61
|
+
/* The label must never be the reason the button changes size. */
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Hovering the CARD reveals it (operator's call — not only the picture), and
|
|
66
|
+
so does focusing the button, because a keyboard never hovers and this is the
|
|
67
|
+
only route to changing the photo. */
|
|
68
|
+
.studio-identity-card-editable:hover .studio-avatar-overlay,
|
|
69
|
+
.studio-avatar-trigger:hover .studio-avatar-overlay,
|
|
70
|
+
.studio-avatar-trigger:focus .studio-avatar-overlay,
|
|
71
|
+
.studio-avatar-trigger:focus-visible .studio-avatar-overlay {
|
|
42
72
|
opacity: 1;
|
|
43
73
|
}
|
|
44
74
|
|
|
75
|
+
/* The whole card is the trigger, so it has to say so. */
|
|
76
|
+
.studio-identity-card-clickable { cursor: pointer; }
|
|
77
|
+
|
|
78
|
+
/* The button is a bare circle; give focus something to land on that is visible
|
|
79
|
+
against both a photo and a flat initials colour. */
|
|
80
|
+
.studio-avatar-trigger:focus-visible {
|
|
81
|
+
outline: 2px solid var(--color-primary);
|
|
82
|
+
outline-offset: 3px;
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/* The card reads as clickable before you click it. Kept subtle — this is a
|
|
46
86
|
whole card lighting up, not a button. */
|
|
47
87
|
.studio-identity-card {
|
|
@@ -53,18 +93,38 @@
|
|
|
53
93
|
border-color: var(--color-primary);
|
|
54
94
|
}
|
|
55
95
|
|
|
56
|
-
/* No hover to give: show it and be done.
|
|
96
|
+
/* No hover to give: show it and be done.
|
|
97
|
+
|
|
98
|
+
The overlay is a JUDGEMENT CALL rather than the same rule. Showing the full
|
|
99
|
+
scrim permanently on touch would hide the photo it is describing, so the
|
|
100
|
+
label stays but the scrim thins to where the picture still reads through.
|
|
101
|
+
The affordance exists; the picture is not lost to it. */
|
|
57
102
|
@media (hover: none) {
|
|
58
103
|
.studio-avatar-badge-icon { opacity: 1; }
|
|
104
|
+
|
|
105
|
+
.studio-avatar-overlay {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
background: rgba(0, 0, 0, 0.35);
|
|
108
|
+
}
|
|
59
109
|
}
|
|
60
110
|
|
|
61
111
|
/* The compact header that takes over on scroll. FIXED so it costs no layout
|
|
62
112
|
height while hidden; pinned to the navbar height the host publishes. */
|
|
63
113
|
.studio-identity-mini {
|
|
64
114
|
position: fixed;
|
|
65
|
-
/*
|
|
66
|
-
|
|
67
|
-
|
|
115
|
+
/* --nav-bottom, NOT --nav-h, and the engine already learned this once: the
|
|
116
|
+
two differ by exactly the height of any chrome an app stacks ABOVE the
|
|
117
|
+
navbar, and mcritchie-industries' 47px environment banner is exactly such
|
|
118
|
+
chrome. test/integration/sidebar_navbar_render_test.rb refuses `--nav-h`
|
|
119
|
+
for the sidebar panel for this reason; this bar had the same bug.
|
|
120
|
+
A `fixed` element's `top` is a viewport coordinate, so it must be the
|
|
121
|
+
header's BOTTOM EDGE.
|
|
122
|
+
|
|
123
|
+
FLUSH, with no added gap. It first carried `+ 0.5rem` "so it reads as a
|
|
124
|
+
card floating over the page"; the operator saw that as a strip of dead
|
|
125
|
+
space between the navbar and the bar (2026-08-15). The bar is a
|
|
126
|
+
continuation of the chrome, not a card hovering under it. */
|
|
127
|
+
top: var(--nav-bottom, var(--nav-h, 0px));
|
|
68
128
|
/* CARD WIDTH, not full bleed. 42rem is max-w-2xl — the page container's
|
|
69
129
|
width — and the calc keeps it inside the viewport's gutters on a narrow
|
|
70
130
|
screen, where a fixed 42rem would run off the edge. Centred with the same
|
|
@@ -90,6 +150,7 @@
|
|
|
90
150
|
|
|
91
151
|
@media (prefers-reduced-motion: reduce) {
|
|
92
152
|
.studio-avatar-badge-icon,
|
|
153
|
+
.studio-avatar-overlay,
|
|
93
154
|
.studio-identity-card,
|
|
94
155
|
.studio-identity-mini { transition: none; }
|
|
95
156
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%# The newsletter row's two modals, registered into the page's scoped host.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
BOTH CARRY THE FORM THEY SUBMIT, so there is exactly one place each verb is
|
|
6
|
+
issued from. A confirmation whose button posts a form elsewhere on the page
|
|
7
|
+
drifts the moment that form moves.
|
|
8
|
+
|
|
9
|
+
newsletter-unsubscribe — the confirmation. DELETE.
|
|
10
|
+
newsletter-email — the address capture, for an account with none on
|
|
11
|
+
file. POST, with the address in the same request.
|
|
12
|
+
|
|
13
|
+
The email modal exists because a wallet-only account (turf-monster has many)
|
|
14
|
+
has no address, and a newsletter needs somewhere to send. It is written as the
|
|
15
|
+
account email but NOT marked verified — see the controller for why that
|
|
16
|
+
distinction is load-bearing.
|
|
17
|
+
%>
|
|
18
|
+
|
|
19
|
+
<%# --- leaving asks --------------------------------------------------------- %>
|
|
20
|
+
<template x-if="$store.profileModals.current()?.id === 'newsletter-unsubscribe'">
|
|
21
|
+
<div>
|
|
22
|
+
<%= render "studio/modals/blocks/card_header",
|
|
23
|
+
size: :lg,
|
|
24
|
+
icon: :error,
|
|
25
|
+
title: "Unsubscribe?",
|
|
26
|
+
subtitle: "You'll stop receiving updates. You can join again from this page any time." %>
|
|
27
|
+
|
|
28
|
+
<%= form_with url: profile_newsletter_path, method: :delete, data: { turbo: false } do %>
|
|
29
|
+
<button type="submit" class="btn btn-danger btn-lg w-full">Unsubscribe</button>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<button type="button" @click="$store.profileModals.close()"
|
|
33
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
34
|
+
Cancel
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<%# --- joining without an address on file ----------------------------------- %>
|
|
40
|
+
<template x-if="$store.profileModals.current()?.id === 'newsletter-email'">
|
|
41
|
+
<div>
|
|
42
|
+
<%= render "studio/modals/blocks/card_header",
|
|
43
|
+
size: :lg,
|
|
44
|
+
icon_color: :primary,
|
|
45
|
+
title: "Where should we send it?",
|
|
46
|
+
subtitle: "There's no email on this account yet. We'll use this for the newsletter." %>
|
|
47
|
+
|
|
48
|
+
<%= form_with url: profile_newsletter_path, method: :post, scope: :profile,
|
|
49
|
+
data: { turbo: false } do |f| %>
|
|
50
|
+
<%= f.email_field :email, required: true, autocomplete: "email",
|
|
51
|
+
placeholder: "you@example.com",
|
|
52
|
+
class: "input-field w-full mb-3" %>
|
|
53
|
+
<button type="submit" class="btn btn-primary btn-lg w-full">Subscribe</button>
|
|
54
|
+
<% end %>
|
|
55
|
+
|
|
56
|
+
<button type="button" @click="$store.profileModals.close()"
|
|
57
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
58
|
+
Cancel
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<%# The newsletter row — subscribe, and unsubscribe behind a confirmation.
|
|
2
|
+
|
|
3
|
+
Locals: user (required).
|
|
4
|
+
|
|
5
|
+
LIFTED FROM turf-monster's /account card, which has run this flow in
|
|
6
|
+
production, and deliberately stripped of everything turf-specific on the way:
|
|
7
|
+
its 25-seed on-chain welcome bonus, its quest state, its seeds level-up
|
|
8
|
+
payload. What survives is the part every app needs — two states, a join, a
|
|
9
|
+
leave, and a confirmation before leaving.
|
|
10
|
+
|
|
11
|
+
SERVER-RENDERED, not JSON. turf's version POSTs and patches the card in place
|
|
12
|
+
because it sits beside a live seeds counter that must tick in the same
|
|
13
|
+
breath. Nothing on /profile is live, so a form and a redirect is the simpler
|
|
14
|
+
mechanism and the one that still works with no JavaScript at all. The only
|
|
15
|
+
JavaScript here is the confirmation.
|
|
16
|
+
|
|
17
|
+
ASYMMETRIC ON PURPOSE: joining is one click, leaving asks. Joining is
|
|
18
|
+
reversible from this same card in one more click, so a confirm step would be
|
|
19
|
+
friction protecting nothing; a mis-click on LEAVE is silent until the next
|
|
20
|
+
send that never arrives.
|
|
21
|
+
%>
|
|
22
|
+
<% subscribed = Studio::Newsletter.subscribed?(user) %>
|
|
23
|
+
|
|
24
|
+
<% if subscribed %>
|
|
25
|
+
<div class="flex items-center justify-between gap-3 flex-wrap">
|
|
26
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
27
|
+
<svg class="w-5 h-5 text-primary flex-shrink-0" fill="none" stroke="currentColor"
|
|
28
|
+
stroke-width="2.5" viewBox="0 0 24 24" aria-hidden="true">
|
|
29
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
30
|
+
</svg>
|
|
31
|
+
<span class="text-body">Subscribed</span>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<%# Opens the confirmation rather than submitting. The FORM it submits lives
|
|
35
|
+
in the modal, so there is exactly one place the DELETE is issued from. %>
|
|
36
|
+
<button type="button" @click="$store.profileModals.open('newsletter-unsubscribe')"
|
|
37
|
+
class="text-sm text-secondary hover:text-heading transition underline-offset-2 hover:underline">
|
|
38
|
+
Unsubscribe
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
<% else %>
|
|
42
|
+
<div class="flex items-center justify-between gap-3 flex-wrap">
|
|
43
|
+
<p class="text-secondary text-sm min-w-0">
|
|
44
|
+
<% if Studio::Newsletter.ever_joined?(user) %>
|
|
45
|
+
You're not on the list. Join again any time.
|
|
46
|
+
<% else %>
|
|
47
|
+
Occasional updates. No spam, and one click to leave.
|
|
48
|
+
<% end %>
|
|
49
|
+
</p>
|
|
50
|
+
|
|
51
|
+
<%# NO EMAIL ON FILE — a wallet-only account. Asking in a modal rather than
|
|
52
|
+
growing a second inline form keeps the resting card one line, and it is
|
|
53
|
+
the same shape the operator asked for: the flow uses the modals. %>
|
|
54
|
+
<% if Studio::Newsletter.needs_email?(user) %>
|
|
55
|
+
<button type="button" @click="$store.profileModals.open('newsletter-email')"
|
|
56
|
+
class="btn btn-primary btn-sm flex-shrink-0">
|
|
57
|
+
Subscribe
|
|
58
|
+
</button>
|
|
59
|
+
<% else %>
|
|
60
|
+
<%= button_to "Subscribe", profile_newsletter_path, method: :post,
|
|
61
|
+
class: "btn btn-primary btn-sm flex-shrink-0",
|
|
62
|
+
form: { class: "flex-shrink-0" } %>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
<% end %>
|
|
@@ -98,14 +98,31 @@
|
|
|
98
98
|
<%= render "studio/profiles/form_script" %>
|
|
99
99
|
|
|
100
100
|
<% if avatar_editable %>
|
|
101
|
-
<%#
|
|
101
|
+
<%# THE `x-data` IS LOAD-BEARING, and this page was missing it. Alpine 3 only
|
|
102
|
+
initialises trees rooted at an x-data, and studio/modals/_scoped_host
|
|
103
|
+
declares none of its own — so its outer `<template x-if>` sits inert, the
|
|
104
|
+
store registers fine, `open()` pushes onto the stack, and NOTHING RENDERS.
|
|
105
|
+
Every symptom points at the modal id or the store; none of them is the
|
|
106
|
+
cause.
|
|
107
|
+
|
|
108
|
+
This host sits AFTER the studioProfileForm div closes, so it had no Alpine
|
|
109
|
+
scope above it at all. Found while wiring the newsletter row's confirmation
|
|
110
|
+
on /profile, where the identical shape produced an identical silent
|
|
111
|
+
failure — current() returning the right id with no dialog in the document.
|
|
112
|
+
The engine's other live call site (studio/emails/index.html.erb) renders
|
|
113
|
+
inside `x-data="emailRecipients(...)"`, which is the only reason this
|
|
114
|
+
pattern has ever worked anywhere.
|
|
115
|
+
|
|
116
|
+
Mounted ONCE per page. Two hosts sharing a store name would register the
|
|
102
117
|
same modal ids twice and render duplicate cards. %>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
<div x-data>
|
|
119
|
+
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
120
|
+
<template x-if="$store.profileModals.current()?.id === 'crop-photo'">
|
|
121
|
+
<div><%= render "studio/modals/crop_photo", store: "profileModals" %></div>
|
|
122
|
+
</template>
|
|
123
|
+
<template x-if="$store.profileModals.current()?.id === 'saving'">
|
|
124
|
+
<div><%= render "studio/modals/saving", store: "profileModals" %></div>
|
|
125
|
+
</template>
|
|
126
|
+
<% end %>
|
|
127
|
+
</div>
|
|
111
128
|
<% end %>
|
|
@@ -6,9 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
Editing lives at /profile/edit. The avatar's badge links there, so the same
|
|
8
8
|
control in the same place means "change this" on both pages.
|
|
9
|
+
|
|
10
|
+
THE MODAL HOST IS CONDITIONAL, and that is the registry's `modals:` key doing
|
|
11
|
+
the job it was documented for. Until the newsletter row shipped, NOTHING on
|
|
12
|
+
this page opened a modal, so mounting a host would have been furniture for
|
|
13
|
+
nobody. Now one row asks and the host appears; a host that declares the row
|
|
14
|
+
away gets no host again, automatically.
|
|
15
|
+
|
|
16
|
+
NOT studio/cropper_assets. That is ~40 KB of cropper.js for an upload
|
|
17
|
+
affordance this page does not have — the avatar is read-only here and the
|
|
18
|
+
picker lives on /profile/edit. A host is not a cropper.
|
|
9
19
|
%>
|
|
10
20
|
<% content_for(:title, "Profile") %>
|
|
11
21
|
|
|
22
|
+
<% modal_rows = @profile_sections.to_a.select { |section| section[:modals] } %>
|
|
23
|
+
|
|
12
24
|
<div class="max-w-2xl mx-auto py-8">
|
|
13
25
|
<%= render "studio/profiles/identity", user: current_user, editable: false %>
|
|
14
26
|
|
|
@@ -26,3 +38,26 @@
|
|
|
26
38
|
</div>
|
|
27
39
|
<% end %>
|
|
28
40
|
</div>
|
|
41
|
+
|
|
42
|
+
<% if modal_rows.any? %>
|
|
43
|
+
<%# THE `x-data` IS LOAD-BEARING, and its absence is silent. Alpine 3 only
|
|
44
|
+
initialises trees rooted at an x-data, and _scoped_host declares none of its
|
|
45
|
+
own — its outer `<template x-if>` is inert without an Alpine scope above it.
|
|
46
|
+
The store still registers, `open()` still pushes onto the stack, and NOTHING
|
|
47
|
+
RENDERS. Measured on this very page: current() returned the right modal id
|
|
48
|
+
while the document contained no dialog at all.
|
|
49
|
+
|
|
50
|
+
The engine's own live call site (studio/emails/index.html.erb) happens to
|
|
51
|
+
render the host inside `x-data="emailRecipients(...)"`, which is the only
|
|
52
|
+
reason this has ever worked anywhere.
|
|
53
|
+
|
|
54
|
+
Mounted ONCE per page, on the page's own store — two hosts sharing a store
|
|
55
|
+
name would register the same ids twice and render duplicate cards. %>
|
|
56
|
+
<div x-data>
|
|
57
|
+
<%= render "studio/modals/scoped_host", store: "profileModals" do %>
|
|
58
|
+
<% modal_rows.each do |section| %>
|
|
59
|
+
<%= render section[:modals], user: current_user %>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
<% end %>
|