studio-engine 0.113.0 → 0.114.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 +116 -0
- data/README.md +139 -0
- data/app/assets/tailwind/studio_engine/engine.css +52 -0
- data/app/helpers/studio_navbar_helper.rb +47 -2
- data/app/javascript/studio/alpine_scopes.js +9 -3
- data/app/javascript/studio/host_fetchers.js +16 -0
- data/app/javascript/studio/modal_countdown.js +95 -0
- data/app/javascript/studio/newsletter_modal.js +252 -0
- data/app/views/components/_sidebar_panel.html.erb +22 -3
- data/app/views/components/_user_nav.html.erb +37 -4
- data/app/views/layouts/_navbar.html.erb +60 -10
- data/app/views/studio/_alpine_scopes_guard.html.erb +9 -7
- data/app/views/studio/_cropper_assets.html.erb +15 -3
- data/app/views/studio/modals/blocks/_birthday.html.erb +18 -0
- data/app/views/studio/modals/blocks/_change_username.html.erb +39 -1
- data/app/views/studio/modals/blocks/_error_card.html.erb +57 -1
- data/app/views/studio/modals/blocks/_success_card.html.erb +45 -4
- data/app/views/studio/profiles/_newsletter_modals.html.erb +281 -19
- data/app/views/style/_modals.html.erb +65 -0
- data/lib/studio/navbar_account_sidebar.rb +128 -0
- data/lib/studio/navbar_brand.rb +84 -0
- data/lib/studio/navbar_signed_out_cta.rb +77 -8
- data/lib/studio/navbar_slots.rb +36 -14
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +129 -0
- metadata +6 -1
|
@@ -21,11 +21,34 @@
|
|
|
21
21
|
|
|
22
22
|
Everything else (finalize_url, finalize_hook, saved_event, quest_label,
|
|
23
23
|
leveling, modal_store, demo, celebration copy) passes straight through.
|
|
24
|
+
|
|
25
|
+
locked (Hash, optional) — the rename is not open to this person yet, and the
|
|
26
|
+
card says why instead of offering the input. The APP decides (a
|
|
27
|
+
frozen account, a first contest not yet entered); the engine only
|
|
28
|
+
draws the answer:
|
|
29
|
+
title: required, e.g. "Username locked".
|
|
30
|
+
subtitle: the reason.
|
|
31
|
+
cta_label: with cta_href, a primary link to where the rename
|
|
32
|
+
cta_href: unlocks (it closes the modal on the way), and a Dismiss
|
|
33
|
+
link under it. Without them, one Dismiss button.
|
|
34
|
+
dismiss: the dismiss label. Default "Dismiss".
|
|
35
|
+
Any other key raises. Absent (the default), the card renders exactly
|
|
36
|
+
as it did before the local existed.
|
|
24
37
|
%>
|
|
25
38
|
<%
|
|
26
39
|
current_username = local_assigns.fetch(:current_username)
|
|
27
40
|
|
|
28
|
-
|
|
41
|
+
# A Hash or nothing; a misspelt key would otherwise drop the CTA silently.
|
|
42
|
+
locked = local_assigns[:locked]
|
|
43
|
+
unless locked.nil?
|
|
44
|
+
unless locked.is_a?(Hash) && locked[:title].present? && (locked.keys - %i[title subtitle cta_label cta_href dismiss]).empty?
|
|
45
|
+
raise ArgumentError, "blocks/_change_username locked: takes { title:, subtitle:, cta_label:, cta_href:, dismiss: } with a title, got #{locked.inspect}"
|
|
46
|
+
end
|
|
47
|
+
# IDENTIFIER position ($store.<name>.close()) — VALIDATED, never escaped.
|
|
48
|
+
locked_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
username_locals = local_assigns.except(:current_username, :locked).reverse_merge(
|
|
29
52
|
title: "Change Username",
|
|
30
53
|
description: "Pick a name that's right for you.",
|
|
31
54
|
input: true,
|
|
@@ -43,4 +66,19 @@
|
|
|
43
66
|
celebrate_subtitle: "Nice — you earned 25 seeds by completing a quest. Complete more quests to earn a Free Entry Token."
|
|
44
67
|
)
|
|
45
68
|
%>
|
|
69
|
+
<% if locked %>
|
|
70
|
+
<div data-username-locked>
|
|
71
|
+
<%= render "studio/modals/blocks/card_header",
|
|
72
|
+
size: :lg, icon: :error, title: locked[:title], subtitle: locked[:subtitle] %>
|
|
73
|
+
<% if locked[:cta_label] && locked[:cta_href] %>
|
|
74
|
+
<%= link_to locked[:cta_label], locked[:cta_href],
|
|
75
|
+
class: "btn btn-primary btn-lg w-full", "@click": "$store.#{locked_store}.close()" %>
|
|
76
|
+
<button type="button" @click="$store.<%= locked_store %>.close()"
|
|
77
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition"><%= locked.fetch(:dismiss, "Dismiss") %></button>
|
|
78
|
+
<% else %>
|
|
79
|
+
<button type="button" @click="$store.<%= locked_store %>.close()" class="btn btn-outline btn-lg w-full"><%= locked.fetch(:dismiss, "Dismiss") %></button>
|
|
80
|
+
<% end %>
|
|
81
|
+
</div>
|
|
82
|
+
<% else %>
|
|
46
83
|
<%= render "studio/modals/blocks/leveling_activity", username_locals %>
|
|
84
|
+
<% end %>
|
|
@@ -20,6 +20,26 @@
|
|
|
20
20
|
|
|
21
21
|
secondary_label: optional secondary action ("Close", "Cancel", etc).
|
|
22
22
|
secondary_event: dispatch event for the secondary action.
|
|
23
|
+
|
|
24
|
+
Closing the modal, and counting down to it (each optional; unset, the card
|
|
25
|
+
renders exactly as it did before they existed):
|
|
26
|
+
cta_close: boolean. The primary button closes the modal store instead
|
|
27
|
+
of reloading or dispatching. Raises with cta_reload or
|
|
28
|
+
cta_event.
|
|
29
|
+
secondary_close: boolean. The secondary action closes the modal store.
|
|
30
|
+
Raises with secondary_event.
|
|
31
|
+
countdown_key: Alpine expression for the seconds to wait, read once at
|
|
32
|
+
mount (e.g. "$store.modals.current().props.secondsLeft", from a 429's
|
|
33
|
+
Retry-After). Adds a "Try again in 12s." line that counts
|
|
34
|
+
down (minutes above 90s) and is hidden at zero.
|
|
35
|
+
countdown_text: the words before the wait. Default "Try again in".
|
|
36
|
+
auto_close: with countdown_key, close the store at zero. Default true.
|
|
37
|
+
modal_store: the Alpine store the close actions and the auto-close
|
|
38
|
+
close. Default "modals"; validated as a JS identifier
|
|
39
|
+
(Studio::JsIdentifier), since the buttons splice it as a name.
|
|
40
|
+
|
|
41
|
+
The countdown is studio/modal_countdown's studioModalCountdown(), published by
|
|
42
|
+
studio/alpine_scopes; this card carries no script.
|
|
23
43
|
%>
|
|
24
44
|
<%
|
|
25
45
|
icon_emoji = local_assigns[:icon_emoji] || "⏳" # ⏳
|
|
@@ -33,12 +53,37 @@
|
|
|
33
53
|
# interpolated form is load-bearing.
|
|
34
54
|
cta_event_js = Studio::JsLiteral.in_attribute(local_assigns[:cta_event])
|
|
35
55
|
secondary_event_js = Studio::JsLiteral.in_attribute(local_assigns[:secondary_event])
|
|
56
|
+
|
|
57
|
+
# THE CLOSE ACTIONS. A third way to wire a button that already has two, so
|
|
58
|
+
# naming two at once is refused rather than settled by branch order: a Retry
|
|
59
|
+
# that silently closes instead of dispatching is a card that looks right.
|
|
60
|
+
cta_close = !!local_assigns[:cta_close]
|
|
61
|
+
secondary_close = !!local_assigns[:secondary_close]
|
|
62
|
+
if cta_close && (local_assigns[:cta_reload] || local_assigns[:cta_event])
|
|
63
|
+
raise ArgumentError, "blocks/_error_card: cta_close closes the store; it takes no cta_reload or cta_event"
|
|
64
|
+
end
|
|
65
|
+
if secondary_close && local_assigns[:secondary_event]
|
|
66
|
+
raise ArgumentError, "blocks/_error_card: secondary_close closes the store; it takes no secondary_event"
|
|
67
|
+
end
|
|
68
|
+
# IDENTIFIER position ($store.<name>.close()) — VALIDATED, never escaped.
|
|
69
|
+
modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
70
|
+
|
|
71
|
+
# The countdown's x-data. countdown_key is an Alpine EXPRESSION by contract,
|
|
72
|
+
# like message_key, and lands as code; the store is a validated identifier,
|
|
73
|
+
# so it cannot leave the string it sits in. tag.attributes encodes the double
|
|
74
|
+
# quote, so the expression cannot close the attribute either.
|
|
75
|
+
countdown_key = local_assigns[:countdown_key]
|
|
76
|
+
countdown_text = local_assigns.fetch(:countdown_text, "Try again in")
|
|
77
|
+
countdown_data = if countdown_key
|
|
78
|
+
"studioModalCountdown({ seconds: (#{countdown_key}), store: '#{modal_store}', " \
|
|
79
|
+
"autoClose: #{local_assigns.fetch(:auto_close, true) ? 'true' : 'false'} })".html_safe
|
|
80
|
+
end
|
|
36
81
|
%>
|
|
37
82
|
<%# role="alert" on the CARD, not on its message: the message is server-rendered
|
|
38
83
|
static text, so it never "changes" for a live region to notice. The modal host
|
|
39
84
|
renders content through <template x-if>, so this whole card ENTERS the DOM when
|
|
40
85
|
the failure happens — which is the event a screen reader can announce. %>
|
|
41
|
-
<div role="alert" class="text-center py-6">
|
|
86
|
+
<div role="alert"<% if countdown_data %> <%= tag.attributes("x-data": countdown_data) %><% end %> class="text-center py-6">
|
|
42
87
|
<div class="text-5xl mb-4 leading-none"><%= icon_emoji %></div>
|
|
43
88
|
<p class="text-sm text-heading font-semibold mb-2"><%= title %></p>
|
|
44
89
|
|
|
@@ -48,10 +93,16 @@
|
|
|
48
93
|
<p class="text-xs text-secondary mb-4"><%= message %></p>
|
|
49
94
|
<% end %>
|
|
50
95
|
|
|
96
|
+
<% if countdown_data %>
|
|
97
|
+
<%# aria-live="off": the card is role="alert", and a label ticking inside it would be announced every second. %>
|
|
98
|
+
<p class="text-xs text-secondary mb-4" x-show="counting" aria-live="off"><%= countdown_text %> <span class="text-heading font-semibold" x-text="waitLabel"></span>.</p>
|
|
99
|
+
<% end %>
|
|
51
100
|
<% if local_assigns[:cta_reload] %>
|
|
52
101
|
<button @click="window.location.reload()" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
53
102
|
<% elsif local_assigns[:cta_event] %>
|
|
54
103
|
<button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
104
|
+
<% elsif cta_close %>
|
|
105
|
+
<button type="button" @click="$store.<%= modal_store %>.close()" class="btn btn-outline btn-sm"><%= cta_label %></button>
|
|
55
106
|
<% end %>
|
|
56
107
|
|
|
57
108
|
<% if local_assigns[:secondary_label] && local_assigns[:secondary_event] %>
|
|
@@ -59,5 +110,10 @@
|
|
|
59
110
|
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
60
111
|
<%= secondary_label %>
|
|
61
112
|
</button>
|
|
113
|
+
<% elsif local_assigns[:secondary_label] && secondary_close %>
|
|
114
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
115
|
+
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
116
|
+
<%= secondary_label %>
|
|
117
|
+
</button>
|
|
62
118
|
<% end %>
|
|
63
119
|
</div>
|
|
@@ -35,13 +35,21 @@
|
|
|
35
35
|
on the left and a launch arrow on the
|
|
36
36
|
right. The "canonical" tx link.
|
|
37
37
|
|
|
38
|
-
slot_position: :below_cta (default) or :
|
|
39
|
-
renders.
|
|
38
|
+
slot_position: :below_cta (default), :above_cta, or :message — where
|
|
39
|
+
the block renders. :message puts it in the message's
|
|
40
|
+
place, for copy richer than one string (two bound
|
|
41
|
+
addresses with bold spans); it then replaces
|
|
42
|
+
message / message_key. See the note on the slot itself.
|
|
40
43
|
cta_label: primary CTA button text.
|
|
41
44
|
cta_href_key: Alpine expression for the button's href.
|
|
42
45
|
Mutually exclusive with cta_event.
|
|
43
46
|
cta_event: window event name dispatched on click.
|
|
44
47
|
Mutually exclusive with cta_href_key.
|
|
48
|
+
cta_close: boolean. The CTA closes the modal store
|
|
49
|
+
($store.<modal_store>.close()) instead of following
|
|
50
|
+
a href or dispatching an event: the "Got it" of an
|
|
51
|
+
informational card. Raises with cta_href_key or
|
|
52
|
+
cta_event.
|
|
45
53
|
cta_drain: boolean. When set together with
|
|
46
54
|
auto_redirect_url_key, the countdown
|
|
47
55
|
drains as a translucent overlay on the CTA
|
|
@@ -51,6 +59,11 @@
|
|
|
51
59
|
|
|
52
60
|
secondary_label: optional secondary action label.
|
|
53
61
|
secondary_event: optional secondary action dispatch event.
|
|
62
|
+
secondary_close: boolean. The secondary action closes the modal store
|
|
63
|
+
instead of dispatching. Raises with secondary_event.
|
|
64
|
+
modal_store: the Alpine store the two close actions close. Default
|
|
65
|
+
"modals"; validated as a JS identifier
|
|
66
|
+
(Studio::JsIdentifier), since it is spliced as a name.
|
|
54
67
|
|
|
55
68
|
auto_redirect_url_key: Alpine expression for the redirect URL.
|
|
56
69
|
When set, an internal countdown ticks down
|
|
@@ -78,6 +91,22 @@
|
|
|
78
91
|
# returns the layout's content, so a card rendered from a layout with no
|
|
79
92
|
# block re-emitted the whole page body under its CTA. See Studio::PartialBlock.
|
|
80
93
|
caller_block = Studio::PartialBlock.content(self, yield)
|
|
94
|
+
block_in_message = caller_block && slot_position == :message
|
|
95
|
+
|
|
96
|
+
# THE CLOSE ACTIONS, and the store they close. Each is a third way to wire a
|
|
97
|
+
# button that already has two, so naming two at once is refused rather than
|
|
98
|
+
# resolved by an order nobody reads: a CTA that silently ignores the event the
|
|
99
|
+
# host passed is a card that renders right and does the wrong thing.
|
|
100
|
+
cta_close = !!local_assigns[:cta_close]
|
|
101
|
+
secondary_close = !!local_assigns[:secondary_close]
|
|
102
|
+
if cta_close && (local_assigns[:cta_href_key] || local_assigns[:cta_event])
|
|
103
|
+
raise ArgumentError, "blocks/_success_card: cta_close closes the store; it takes no cta_href_key or cta_event"
|
|
104
|
+
end
|
|
105
|
+
if secondary_close && local_assigns[:secondary_event]
|
|
106
|
+
raise ArgumentError, "blocks/_success_card: secondary_close closes the store; it takes no secondary_event"
|
|
107
|
+
end
|
|
108
|
+
# IDENTIFIER position ($store.<name>.close()) — VALIDATED, never escaped.
|
|
109
|
+
modal_store = Studio::JsIdentifier.validate!(local_assigns.fetch(:modal_store, "modals"), local: :modal_store)
|
|
81
110
|
|
|
82
111
|
data_attr = "{
|
|
83
112
|
_remaining: #{redirect_secs},
|
|
@@ -169,7 +198,12 @@
|
|
|
169
198
|
<p class="<%= title_class %>"><%= title %></p>
|
|
170
199
|
<% end %>
|
|
171
200
|
|
|
172
|
-
|
|
201
|
+
<%# The block IN the message's place (slot_position: :message): a div, not a
|
|
202
|
+
p, because the caller's markup may hold block elements, which a p would
|
|
203
|
+
have the parser split around. Same type and spacing as the message. %>
|
|
204
|
+
<% if block_in_message %>
|
|
205
|
+
<div class="text-sm text-secondary mb-4"><%= caller_block %></div>
|
|
206
|
+
<% elsif local_assigns[:message_key] %>
|
|
173
207
|
<p class="text-sm text-secondary mb-4" x-text="<%= message_key %>"></p>
|
|
174
208
|
<% elsif local_assigns[:message] %>
|
|
175
209
|
<p class="text-sm text-secondary mb-4"><%= message %></p>
|
|
@@ -282,6 +316,8 @@
|
|
|
282
316
|
<a :href="<%= cta_href_key %>" class="btn btn-primary btn-lg w-full"><%= cta_label %></a>
|
|
283
317
|
<% elsif local_assigns[:cta_event] %>
|
|
284
318
|
<button @click="$dispatch('<%= cta_event_js %>')" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
|
|
319
|
+
<% elsif cta_close %>
|
|
320
|
+
<button type="button" @click="$store.<%= modal_store %>.close()" class="btn btn-primary btn-lg w-full"><%= cta_label %></button>
|
|
285
321
|
<% end %>
|
|
286
322
|
<% end %>
|
|
287
323
|
|
|
@@ -290,12 +326,17 @@
|
|
|
290
326
|
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
291
327
|
<%= secondary_label %>
|
|
292
328
|
</button>
|
|
329
|
+
<% elsif local_assigns[:secondary_label] && secondary_close %>
|
|
330
|
+
<button type="button" @click="$store.<%= modal_store %>.close()"
|
|
331
|
+
class="block mx-auto mt-3 text-xs text-secondary hover:text-heading underline underline-offset-2">
|
|
332
|
+
<%= secondary_label %>
|
|
333
|
+
</button>
|
|
293
334
|
<% end %>
|
|
294
335
|
|
|
295
336
|
<%# ...or below it, which stays the DEFAULT: every existing caller renders here
|
|
296
337
|
and none of them asked to move. The block is captured once at the top, and
|
|
297
338
|
exactly one of these two branches renders it. %>
|
|
298
|
-
<% if caller_block && slot_position != :above_cta %>
|
|
339
|
+
<% if caller_block && slot_position != :above_cta && !block_in_message %>
|
|
299
340
|
<div class="mt-3"><%= caller_block %></div>
|
|
300
341
|
<% end %>
|
|
301
342
|
</div>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# The newsletter row's
|
|
1
|
+
<%# The newsletter row's modals, registered into the page's scoped host.
|
|
2
2
|
|
|
3
3
|
Locals: user (required).
|
|
4
4
|
|
|
@@ -14,48 +14,310 @@
|
|
|
14
14
|
has no address, and a newsletter needs somewhere to send. It is written as the
|
|
15
15
|
account email but NOT marked verified — see the controller for why that
|
|
16
16
|
distinction is load-bearing.
|
|
17
|
+
|
|
18
|
+
FOR A HOST THAT MOUNTS THESE ITSELF (turf-monster, on its own $store.modals
|
|
19
|
+
and its own JSON routes). Every local below is optional, and with all of them
|
|
20
|
+
unset the two cards above render byte for byte as they did before the locals
|
|
21
|
+
existed (test/views/newsletter_modals_test.rb holds that).
|
|
22
|
+
|
|
23
|
+
modal_store the Alpine store the cards answer to. Default
|
|
24
|
+
"profileModals"; validated as a JS identifier, since
|
|
25
|
+
it is spliced as a name ($store.<name>.close()).
|
|
26
|
+
submit_mode :form (default) — each card posts the engine form to
|
|
27
|
+
profile_newsletter_path with Turbo off, a full reload.
|
|
28
|
+
:json — each card posts JSON to the host's route
|
|
29
|
+
(studio/newsletter_modal), stays on the page, shows a
|
|
30
|
+
spinner and an inline error line, dispatches
|
|
31
|
+
changed_event and steps to the next card. A card whose
|
|
32
|
+
opener passed props.onSubmit hands it the address and
|
|
33
|
+
posts nothing: the opener's own request takes over.
|
|
34
|
+
subscribe_url :json only, and required there by every card that
|
|
35
|
+
subscribes (email, subscribe). The host's endpoint.
|
|
36
|
+
unsubscribe_url :json only, and required there by the unsubscribe card.
|
|
37
|
+
unsubscribe_method :json only. The verb the unsubscribe card sends.
|
|
38
|
+
Default "DELETE", the engine route's.
|
|
39
|
+
changed_event :json only. The window event a success dispatches,
|
|
40
|
+
with { subscribed, email, response }. Default
|
|
41
|
+
"studio:newsletter-changed".
|
|
42
|
+
fetcher :json only. The host's own request wrapper, by its
|
|
43
|
+
export name in the importmap module
|
|
44
|
+
"studio/host_fetchers", which the host pins to a file
|
|
45
|
+
of its own (Turf Monster: authedFetch, so a 401 opens
|
|
46
|
+
sign-in and a 429 the rate-limit card). Validated as a
|
|
47
|
+
JS identifier; refused under :form, where nothing
|
|
48
|
+
would call it. Unset, the cards send plain fetch.
|
|
49
|
+
The contract is in studio/newsletter_modal.js.
|
|
50
|
+
|
|
51
|
+
THE CARDS. Each is a Hash of its copy, or off. The two above are on by
|
|
52
|
+
default (false turns one off); the other three are off by default (a Hash,
|
|
53
|
+
or true for the engine's copy, turns one on). Every card takes id:, title:,
|
|
54
|
+
subtitle: and header: (more blocks/_card_header locals: size, icon,
|
|
55
|
+
icon_emoji, icon_color), plus the keys listed. An unknown key raises.
|
|
56
|
+
|
|
57
|
+
unsubscribe_card 'newsletter-unsubscribe'. button:, cancel:, saving:.
|
|
58
|
+
:json steps to goodbye_card when it is on.
|
|
59
|
+
email_card 'newsletter-email'. button:, cancel:, saving:.
|
|
60
|
+
:json steps to success_card when it is on.
|
|
61
|
+
subscribe_card 'newsletter-subscribe'. The join, with a consent box
|
|
62
|
+
(ticked to start; Subscribe waits for it) and, for an
|
|
63
|
+
account with no address, the address field. consent:,
|
|
64
|
+
button:, cancel:, saving:. :json steps to success_card.
|
|
65
|
+
success_card 'newsletter-success'. The "Subscribed!" beat. partial:
|
|
66
|
+
and locals: render the host's slot under the header (a
|
|
67
|
+
seeds bar, a next-step button); the card's props, the
|
|
68
|
+
host's JSON answer, are in scope there as `props`. With
|
|
69
|
+
no slot the card ends in a Close button; with one, a
|
|
70
|
+
Close link (cancel:).
|
|
71
|
+
goodbye_card 'newsletter-goodbye'. The beat after an unsubscribe.
|
|
72
|
+
button:.
|
|
73
|
+
%>
|
|
74
|
+
<%
|
|
75
|
+
newsletter_modal_store = Studio::JsIdentifier.validate!(
|
|
76
|
+
local_assigns.fetch(:modal_store, "profileModals"), local: :modal_store)
|
|
77
|
+
newsletter_mode = local_assigns.fetch(:submit_mode, :form).to_sym
|
|
78
|
+
unless %i[form json].include?(newsletter_mode)
|
|
79
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals submit_mode: :form or :json, got #{newsletter_mode.inspect}"
|
|
80
|
+
end
|
|
81
|
+
newsletter_json = newsletter_mode == :json
|
|
82
|
+
|
|
83
|
+
# The host's fetcher, by name (studio/host_fetchers). A name, so it is
|
|
84
|
+
# refused rather than escaped; under :form nothing would ever call it.
|
|
85
|
+
newsletter_fetcher = local_assigns[:fetcher].presence&.to_s
|
|
86
|
+
if newsletter_fetcher
|
|
87
|
+
unless newsletter_json
|
|
88
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals fetcher: needs submit_mode: :json; the form cards send no request of their own"
|
|
89
|
+
end
|
|
90
|
+
unless newsletter_fetcher.match?(Studio::JsIdentifier::PATTERN)
|
|
91
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals fetcher: must be a JS identifier (an export name in studio/host_fetchers); got #{local_assigns[:fetcher].inspect}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each card's defaults; a host's Hash is merged over them. The two cards that
|
|
96
|
+
# shipped first keep their copy and their header exactly.
|
|
97
|
+
newsletter_card_defaults = {
|
|
98
|
+
unsubscribe_card: { on: true, id: "newsletter-unsubscribe",
|
|
99
|
+
title: "Unsubscribe?",
|
|
100
|
+
subtitle: "You'll stop receiving updates. You can join again from this page any time.",
|
|
101
|
+
header: { size: :lg, icon: :error },
|
|
102
|
+
button: "Unsubscribe", cancel: "Cancel", saving: "Unsubscribing…" },
|
|
103
|
+
email_card: { on: true, id: "newsletter-email",
|
|
104
|
+
title: "Where should we send it?",
|
|
105
|
+
subtitle: "There's no email on this account yet. We'll use this for the newsletter.",
|
|
106
|
+
header: { size: :lg, icon_color: :primary },
|
|
107
|
+
button: "Subscribe", cancel: "Cancel", saving: "Subscribing…" },
|
|
108
|
+
subscribe_card: { on: false, id: "newsletter-subscribe",
|
|
109
|
+
title: "Join the newsletter",
|
|
110
|
+
subtitle: "News and updates, now and then. Leave any time.",
|
|
111
|
+
header: { size: :md, icon_emoji: "📬" },
|
|
112
|
+
consent: "Send me the newsletter",
|
|
113
|
+
button: "Subscribe", cancel: "Close", saving: "Subscribing…" },
|
|
114
|
+
success_card: { on: false, id: "newsletter-success",
|
|
115
|
+
title: "Subscribed!",
|
|
116
|
+
subtitle: "You're on the list.",
|
|
117
|
+
header: { size: :lg, icon_emoji: "🎉" },
|
|
118
|
+
partial: nil, locals: {},
|
|
119
|
+
button: "Close", cancel: "Close" },
|
|
120
|
+
goodbye_card: { on: false, id: "newsletter-goodbye",
|
|
121
|
+
title: "See you later",
|
|
122
|
+
subtitle: "You're off the list. You can join again any time.",
|
|
123
|
+
header: { size: :lg, icon_emoji: "👋" },
|
|
124
|
+
button: "Close" }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
newsletter_cards = newsletter_card_defaults.to_h do |name, defaults|
|
|
128
|
+
given = local_assigns.fetch(name) { defaults[:on] }
|
|
129
|
+
card = case given
|
|
130
|
+
when false, nil then nil
|
|
131
|
+
when true then defaults
|
|
132
|
+
when Hash
|
|
133
|
+
unknown = given.keys - (defaults.keys - [:on])
|
|
134
|
+
if unknown.any?
|
|
135
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals #{name}: unknown key #{unknown.map(&:inspect).join(', ')}"
|
|
136
|
+
end
|
|
137
|
+
defaults.merge(given).merge(header: defaults[:header].merge(given[:header] || {}))
|
|
138
|
+
else
|
|
139
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals #{name}: a Hash, true or false, got #{given.inspect}"
|
|
140
|
+
end
|
|
141
|
+
[name, card]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The URL each JSON card posts to, refused at render when missing: a card with
|
|
145
|
+
# no endpoint renders perfectly and fails on the one press that matters.
|
|
146
|
+
newsletter_url = lambda do |key|
|
|
147
|
+
url = local_assigns[key].presence
|
|
148
|
+
raise ArgumentError, "studio/profiles/_newsletter_modals: submit_mode: :json needs #{key}" unless url
|
|
149
|
+
url
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The x-data for one JSON card (studio/newsletter_modal). Every value lands in
|
|
153
|
+
# a JS string, escaped; tag.attributes encodes the double quote.
|
|
154
|
+
newsletter_scope = lambda do |**opts|
|
|
155
|
+
named = { store: newsletter_modal_store }
|
|
156
|
+
named[:fetcher] = newsletter_fetcher if newsletter_fetcher && opts.any?
|
|
157
|
+
pairs = named.merge(opts).map do |key, value|
|
|
158
|
+
literal = (value == true || value == false) ? value.to_s : "'#{Studio::JsLiteral.in_attribute(value.to_s)}'"
|
|
159
|
+
"#{key}: #{literal}"
|
|
160
|
+
end
|
|
161
|
+
"studioNewsletterModal({ #{pairs.join(', ')} })".html_safe
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
newsletter_needs_email = Studio::Newsletter.needs_email?(user)
|
|
165
|
+
newsletter_success_id = newsletter_cards[:success_card]&.dig(:id).to_s
|
|
166
|
+
newsletter_goodbye_id = newsletter_cards[:goodbye_card]&.dig(:id).to_s
|
|
167
|
+
newsletter_changed = local_assigns.fetch(:changed_event, "studio:newsletter-changed")
|
|
17
168
|
%>
|
|
18
169
|
|
|
19
170
|
<%# --- leaving asks --------------------------------------------------------- %>
|
|
20
|
-
|
|
21
|
-
|
|
171
|
+
<% if (card = newsletter_cards[:unsubscribe_card]) %>
|
|
172
|
+
<template x-if="$store.<%= newsletter_modal_store %>.current()?.id === '<%= Studio::JsLiteral.in_attribute(card[:id]) %>'">
|
|
173
|
+
<div<% if newsletter_json %> <%= tag.attributes("x-data": newsletter_scope.call(action: "unsubscribe", url: newsletter_url.call(:unsubscribe_url), method: local_assigns.fetch(:unsubscribe_method, "DELETE"), nextId: newsletter_goodbye_id, event: newsletter_changed)) %><% end %>>
|
|
22
174
|
<%= render "studio/modals/blocks/card_header",
|
|
23
|
-
|
|
24
|
-
icon: :error,
|
|
25
|
-
title: "Unsubscribe?",
|
|
26
|
-
subtitle: "You'll stop receiving updates. You can join again from this page any time." %>
|
|
175
|
+
**card[:header], title: card[:title], subtitle: card[:subtitle] %>
|
|
27
176
|
|
|
177
|
+
<% if newsletter_json %>
|
|
178
|
+
<p x-show="error" x-cloak role="alert" aria-live="assertive" aria-atomic="true"
|
|
179
|
+
x-text="error" class="text-xs mb-3 text-center text-danger-ink"></p>
|
|
180
|
+
<button type="button" @click="submit()" :disabled="!ready"
|
|
181
|
+
class="btn btn-danger btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
|
|
182
|
+
<span x-show="!saving"><%= card[:button] %></span>
|
|
183
|
+
<span x-show="saving" x-cloak class="inline-flex items-center justify-center gap-2">
|
|
184
|
+
<span class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span><%= card[:saving] %>
|
|
185
|
+
</span>
|
|
186
|
+
</button>
|
|
187
|
+
<% else %>
|
|
28
188
|
<%= form_with url: profile_newsletter_path, method: :delete, data: { turbo: false } do %>
|
|
29
|
-
<button type="submit" class="btn btn-danger btn-lg w-full"
|
|
189
|
+
<button type="submit" class="btn btn-danger btn-lg w-full"><%= card[:button] %></button>
|
|
30
190
|
<% end %>
|
|
191
|
+
<% end %>
|
|
31
192
|
|
|
32
|
-
<button type="button" @click="$store
|
|
193
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()"
|
|
33
194
|
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
34
|
-
|
|
195
|
+
<%= card[:cancel] %>
|
|
35
196
|
</button>
|
|
36
197
|
</div>
|
|
37
198
|
</template>
|
|
199
|
+
<% end %>
|
|
38
200
|
|
|
39
201
|
<%# --- joining without an address on file ----------------------------------- %>
|
|
40
|
-
|
|
41
|
-
|
|
202
|
+
<% if (card = newsletter_cards[:email_card]) %>
|
|
203
|
+
<template x-if="$store.<%= newsletter_modal_store %>.current()?.id === '<%= Studio::JsLiteral.in_attribute(card[:id]) %>'">
|
|
204
|
+
<div<% if newsletter_json %> <%= tag.attributes("x-data": newsletter_scope.call(action: "subscribe", url: newsletter_url.call(:subscribe_url), needsEmail: true, nextId: newsletter_success_id, event: newsletter_changed)) %><% end %>>
|
|
205
|
+
<%= render "studio/modals/blocks/card_header",
|
|
206
|
+
**card[:header], title: card[:title], subtitle: card[:subtitle] %>
|
|
207
|
+
|
|
208
|
+
<% if newsletter_json %>
|
|
209
|
+
<input type="email" x-model="email" @keydown.enter.prevent="submit()" required autocomplete="email"
|
|
210
|
+
placeholder="you@example.com" class="input-field w-full mb-3">
|
|
211
|
+
<p x-show="error" x-cloak role="alert" aria-live="assertive" aria-atomic="true"
|
|
212
|
+
x-text="error" class="text-xs mb-3 text-danger-ink"></p>
|
|
213
|
+
<button type="button" @click="submit()" :disabled="!ready"
|
|
214
|
+
class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
|
|
215
|
+
<span x-show="!saving"><%= card[:button] %></span>
|
|
216
|
+
<span x-show="saving" x-cloak class="inline-flex items-center justify-center gap-2">
|
|
217
|
+
<span class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span><%= card[:saving] %>
|
|
218
|
+
</span>
|
|
219
|
+
</button>
|
|
220
|
+
<% else %>
|
|
221
|
+
<%= form_with url: profile_newsletter_path, method: :post, scope: :profile,
|
|
222
|
+
data: { turbo: false } do |f| %>
|
|
223
|
+
<%= f.email_field :email, required: true, autocomplete: "email",
|
|
224
|
+
placeholder: "you@example.com",
|
|
225
|
+
class: "input-field w-full mb-3" %>
|
|
226
|
+
<button type="submit" class="btn btn-primary btn-lg w-full"><%= card[:button] %></button>
|
|
227
|
+
<% end %>
|
|
228
|
+
<% end %>
|
|
229
|
+
|
|
230
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()"
|
|
231
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
232
|
+
<%= card[:cancel] %>
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</template>
|
|
236
|
+
<% end %>
|
|
237
|
+
<%# --- joining, with consent (optional) ------------------------------------ %>
|
|
238
|
+
<% if (card = newsletter_cards[:subscribe_card]) %>
|
|
239
|
+
|
|
240
|
+
<template x-if="$store.<%= newsletter_modal_store %>.current()?.id === '<%= Studio::JsLiteral.in_attribute(card[:id]) %>'">
|
|
241
|
+
<div <%= tag.attributes("x-data": newsletter_scope.call(**(newsletter_json ? { action: "subscribe", url: newsletter_url.call(:subscribe_url), needsEmail: newsletter_needs_email, requiresConsent: true, nextId: newsletter_success_id, event: newsletter_changed } : {}))) %>>
|
|
42
242
|
<%= render "studio/modals/blocks/card_header",
|
|
43
|
-
|
|
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." %>
|
|
243
|
+
**card[:header], title: card[:title], subtitle: card[:subtitle] %>
|
|
47
244
|
|
|
245
|
+
<% if newsletter_json %>
|
|
246
|
+
<% if newsletter_needs_email %>
|
|
247
|
+
<input type="email" x-model="email" @keydown.enter.prevent="submit()" required autocomplete="email"
|
|
248
|
+
placeholder="you@example.com" class="input-field w-full mb-3">
|
|
249
|
+
<% end %>
|
|
250
|
+
<label class="flex items-center gap-2 text-xs text-secondary mb-3 select-none cursor-pointer">
|
|
251
|
+
<input type="checkbox" x-model="consent" class="rounded border-subtle text-primary cursor-pointer">
|
|
252
|
+
<%= card[:consent] %>
|
|
253
|
+
</label>
|
|
254
|
+
<p x-show="error" x-cloak role="alert" aria-live="assertive" aria-atomic="true"
|
|
255
|
+
x-text="error" class="text-xs mb-3 text-danger-ink"></p>
|
|
256
|
+
<button type="button" @click="submit()" :disabled="!ready"
|
|
257
|
+
class="btn btn-primary btn-lg w-full disabled:opacity-50 disabled:cursor-not-allowed">
|
|
258
|
+
<span x-show="!saving"><%= card[:button] %></span>
|
|
259
|
+
<span x-show="saving" x-cloak class="inline-flex items-center justify-center gap-2">
|
|
260
|
+
<span class="spinner" aria-hidden="true" style="--spinner-track: currentColor; --spinner-color: transparent; opacity: 0.85"></span><%= card[:saving] %>
|
|
261
|
+
</span>
|
|
262
|
+
</button>
|
|
263
|
+
<% else %>
|
|
264
|
+
<%# The plain path: the browser holds the submit until the box is ticked. %>
|
|
48
265
|
<%= form_with url: profile_newsletter_path, method: :post, scope: :profile,
|
|
49
266
|
data: { turbo: false } do |f| %>
|
|
267
|
+
<% if newsletter_needs_email %>
|
|
50
268
|
<%= f.email_field :email, required: true, autocomplete: "email",
|
|
51
269
|
placeholder: "you@example.com",
|
|
52
270
|
class: "input-field w-full mb-3" %>
|
|
53
|
-
|
|
271
|
+
<% end %>
|
|
272
|
+
<label class="flex items-center gap-2 text-xs text-secondary mb-3 select-none cursor-pointer">
|
|
273
|
+
<input type="checkbox" required checked class="rounded border-subtle text-primary cursor-pointer">
|
|
274
|
+
<%= card[:consent] %>
|
|
275
|
+
</label>
|
|
276
|
+
<button type="submit" class="btn btn-primary btn-lg w-full"><%= card[:button] %></button>
|
|
54
277
|
<% end %>
|
|
278
|
+
<% end %>
|
|
279
|
+
|
|
280
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()"
|
|
281
|
+
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
282
|
+
<%= card[:cancel] %>
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
</template>
|
|
286
|
+
<% end %>
|
|
287
|
+
<%# --- the "Subscribed!" beat (optional) ------------------------------------ %>
|
|
288
|
+
<% if (card = newsletter_cards[:success_card]) %>
|
|
289
|
+
|
|
290
|
+
<template x-if="$store.<%= newsletter_modal_store %>.current()?.id === '<%= Studio::JsLiteral.in_attribute(card[:id]) %>'">
|
|
291
|
+
<div <%= tag.attributes("x-data": newsletter_scope.call) %>>
|
|
292
|
+
<%= render "studio/modals/blocks/card_header",
|
|
293
|
+
**card[:header], title: card[:title], subtitle: card[:subtitle] %>
|
|
55
294
|
|
|
56
|
-
|
|
295
|
+
<% if card[:partial] %>
|
|
296
|
+
<%= render card[:partial], **(card[:locals] || {}) %>
|
|
297
|
+
|
|
298
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()"
|
|
57
299
|
class="block mx-auto mt-3 text-sm text-secondary hover:text-heading transition">
|
|
58
|
-
|
|
300
|
+
<%= card[:cancel] %>
|
|
301
|
+
</button>
|
|
302
|
+
<% else %>
|
|
303
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()" class="btn btn-primary btn-lg w-full">
|
|
304
|
+
<%= card[:button] %>
|
|
305
|
+
</button>
|
|
306
|
+
<% end %>
|
|
307
|
+
</div>
|
|
308
|
+
</template>
|
|
309
|
+
<% end %>
|
|
310
|
+
<%# --- the goodbye beat (optional) ----------------------------------------- %>
|
|
311
|
+
<% if (card = newsletter_cards[:goodbye_card]) %>
|
|
312
|
+
|
|
313
|
+
<template x-if="$store.<%= newsletter_modal_store %>.current()?.id === '<%= Studio::JsLiteral.in_attribute(card[:id]) %>'">
|
|
314
|
+
<div <%= tag.attributes("x-data": newsletter_scope.call) %>>
|
|
315
|
+
<%= render "studio/modals/blocks/card_header",
|
|
316
|
+
**card[:header], title: card[:title], subtitle: card[:subtitle] %>
|
|
317
|
+
|
|
318
|
+
<button type="button" @click="$store.<%= newsletter_modal_store %>.close()" class="btn btn-primary btn-lg w-full">
|
|
319
|
+
<%= card[:button] %>
|
|
59
320
|
</button>
|
|
60
321
|
</div>
|
|
61
322
|
</template>
|
|
323
|
+
<% end %>
|