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.
@@ -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
- username_locals = local_assigns.except(:current_username).reverse_merge(
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 :above_cta — where the block
39
- renders. See the note on the slot itself.
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
- <% if local_assigns[:message_key] %>
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 two modals, registered into the page's scoped host.
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
- <template x-if="$store.profileModals.current()?.id === 'newsletter-unsubscribe'">
21
- <div>
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
- size: :lg,
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">Unsubscribe</button>
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.profileModals.close()"
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
- Cancel
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
- <template x-if="$store.profileModals.current()?.id === 'newsletter-email'">
41
- <div>
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
- 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." %>
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
- <button type="submit" class="btn btn-primary btn-lg w-full">Subscribe</button>
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
- <button type="button" @click="$store.profileModals.close()"
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
- Cancel
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 %>