studio-engine 0.41.0 → 0.43.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +55 -1
  4. data/app/assets/images/emails/logo-horizontal.png +0 -0
  5. data/app/assets/images/emails/magic-link-background.gif +0 -0
  6. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  7. data/app/controllers/studio/emails_controller.rb +152 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +47 -0
  9. data/app/mailers/user_mailer.rb +55 -1
  10. data/app/models/studio/email_setting.rb +206 -0
  11. data/app/services/studio/banner.rb +191 -0
  12. data/app/services/studio/email_catalog.rb +387 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +55 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +199 -0
  16. data/app/views/studio/emails/_banner_preview.html.erb +62 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +64 -82
  21. data/app/views/studio/emails/index.html.erb +34 -8
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +468 -34
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +49 -12
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +12 -0
  35. metadata +22 -2
@@ -2,28 +2,105 @@
2
2
 
3
3
  A bare content wrapper like the index, so it renders inside each host's
4
4
  layout. Lifted from turf-monster's admin/emails/show (the prior art this
5
- work folds into the engine) and given the banner half: the same iframe over
6
- the real rendered email, plus what artwork is riding on top of it and where
7
- that artwork came from. %>
5
+ work folds into the engine) and given the banner half.
6
+
7
+ LAYOUT, AND WHY IT IS PAIRED
8
+ Two rows of two. Row one is WHO and WHAT SHOWS IN AN INBOX LIST: the subject
9
+ beside the person receiving it. Row two is WHAT THEY SEE: the two pictures
10
+ stacked on the left, the words that ride on them on the right. Every control
11
+ sits beside the thing it changes, and typing in one repaints the other with
12
+ no round trip.
13
+
14
+ NOTE: never write an ERB delimiter inside a comment like this one, in any
15
+ form. A comment ends at the first closing delimiter it contains, escaped or
16
+ not, and the remainder lands on the page as visible text. That shipped here
17
+ once. Name tags in prose instead of typing them. %>
8
18
  <% content_for(:title) { @entry.label } %>
9
19
  <%
10
20
  source = Studio::EmailCatalog.source(@entry.key)
11
21
  banner_url = Studio::EmailCatalog.preview_url(@entry.key)
12
22
  aspect = @entry.ratio
23
+ max_width = Studio::EmailCatalog::MAX_WIDTH
24
+ card_width = Studio::Banner::DEFAULT_WIDTH
13
25
 
14
- badge_class, badge_label =
26
+ saved = {
27
+ header: Studio::EmailCatalog.header_template(@entry.key).to_s,
28
+ header_fallback: Studio::EmailCatalog.header_fallback(@entry.key).to_s,
29
+ subtext: Studio::EmailCatalog.subtext(@entry.key).to_s,
30
+ subject: (Studio::EmailSetting.copy_for(@entry.key, :subject) || @entry.subject).to_s,
31
+ logo_url: Studio::EmailSetting.copy_for(@entry.key, :logo_url).to_s,
32
+ body: (Studio::EmailSetting.copy_for(@entry.key, :body) || @entry.body).to_s,
33
+ cta_text: (Studio::EmailSetting.copy_for(@entry.key, :cta_text) || @entry.cta_text).to_s,
34
+ cta_color: Studio::EmailCatalog.cta_color(@entry.key).to_s,
35
+ cta_enabled: Studio::EmailCatalog.cta_enabled?(@entry.key),
36
+ # The SAVED footer, not the resolved one: the form shows what the operator
37
+ # actually stored, so a blank field reads as blank rather than echoing the
38
+ # default back and making "clear this" impossible to express.
39
+ discord_url: (Studio::EmailSetting.footer || {})[:discord_url].to_s,
40
+ footer_logo_url: (Studio::EmailSetting.footer || {})[:logo_url].to_s,
41
+ hide_logo: Studio::EmailSetting.hide_logo?(@entry.key),
42
+ scrim_percent: Studio::EmailCatalog.scrim_percent(@entry.key).to_s
43
+ }
44
+
45
+ editor_config = {
46
+ values: saved,
47
+ targetId: @target&.id,
48
+ inheritedLogo: Studio::EmailCatalog.logo_url(@entry.key).to_s,
49
+ uploadedLogo: Studio::EmailCatalog.uploaded_logo_url(@entry.key).to_s,
50
+ defaultScrim: (Studio::Banner::DEFAULT_SCRIM * 100).round,
51
+ appName: Studio.app_name.to_s,
52
+ # The avatar fields are part of this payload, not an afterthought: the picker
53
+ # renders entirely from it, so a field omitted here is a grey circle with no
54
+ # letter in it — which is exactly what shipped.
55
+ targets: (@targets || []).map do |t|
56
+ { id: t.id, label: t.label, name: t.name, email: t.email, admin: t.admin?,
57
+ avatar_url: t.avatar_url, avatar_color: t.avatar_color, initials: t.initials }
58
+ end
59
+ }
60
+
61
+ # Provenance moved here from the list, along with the upload and the revert —
62
+ # the list is now a list, and every change to an email is made on its own page.
63
+ # Four states, because three of them once read as one: an app that ships its
64
+ # OWN committed artwork was told that artwork "ships with the engine", which is
65
+ # the wrong answer to the only question this label exists to answer.
66
+ badge_class, badge_label, badge_note =
15
67
  case source
16
- when :app then ["badge bg-success/10 text-success border-success/30", "Uploaded here"]
17
- when :app_asset then ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork"]
18
- when :engine_default then ["badge bg-inset text-muted border-subtle", "Studio default"]
19
- else ["badge bg-warning/10 text-warning border-warning/30", "No image"]
68
+ when :app
69
+ ["badge bg-success/10 text-success border-success/30", "Uploaded here",
70
+ "Stored for #{Studio.app_name} this upload is what ships."]
71
+ when :app_asset
72
+ ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork",
73
+ "Committed in this app's own assets. Upload to replace it."]
74
+ when :engine_default
75
+ ["badge bg-inset text-muted border-subtle", "Studio default",
76
+ "Shared artwork that ships with the engine. Upload to make it #{Studio.app_name}'s."]
77
+ else
78
+ ["badge bg-warning/10 text-warning border-warning/30", "No image",
79
+ "This email sends without a banner."]
20
80
  end
21
81
  %>
22
- <div class="max-w-5xl mx-auto px-4 pb-16">
82
+ <div class="max-w-5xl mx-auto px-4 pb-16"
83
+ x-data="emailBannerEditor(<%= editor_config.to_json %>)">
23
84
  <header class="pt-8 pb-5">
24
85
  <%= link_to "← All emails", admin_emails_path,
25
86
  class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
26
- <h1 class="text-3xl font-bold text-heading mt-2"><%= @entry.label %></h1>
87
+
88
+ <%# ONE save for the whole page, on the title's own row so it reads as
89
+ acting on this email rather than on the back-link above it. Shown only
90
+ once something has been typed, and disabled whenever the form matches
91
+ what is saved — so typing "A" and deleting it leaves a visible, disabled
92
+ button rather than an enabled one that would write nothing. %>
93
+ <div class="flex items-center justify-between gap-4 mt-2">
94
+ <h1 class="text-3xl font-bold text-heading"><%= @entry.label %></h1>
95
+ <div class="flex items-center gap-3 shrink-0">
96
+ <span class="text-2xs text-muted" x-show="touched && !dirty()" x-cloak>No changes</span>
97
+ <button type="button" @click="save()" x-show="touched" x-cloak
98
+ :disabled="!dirty()" data-save-all
99
+ :class="dirty() ? 'btn btn-primary btn-sm' : 'btn btn-primary btn-sm opacity-50 cursor-not-allowed'">
100
+ Save changes
101
+ </button>
102
+ </div>
103
+ </div>
27
104
 
28
105
  <div class="flex flex-wrap items-center gap-2 mt-3">
29
106
  <span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
@@ -36,41 +113,329 @@
36
113
  <% if @entry.description.present? %>
37
114
  <p class="text-body max-w-2xl mt-3"><%= @entry.description %></p>
38
115
  <% end %>
116
+ </header>
39
117
 
40
- <% if @subject.present? %>
41
- <p class="text-sm text-muted mt-2">
42
- Subject: <strong class="text-heading"><%= @subject %></strong>
118
+ <%# ROW ONE — the subject line, and who is receiving it. %>
119
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
120
+ <section class="card p-4">
121
+ <p class="label-upper mb-3">Example</p>
122
+ <%# The rendered subject sits with the person it is rendered FOR. It moved
123
+ out of the Subject card because what it shows depends on who is
124
+ selected below, not on the template above. %>
125
+ <p class="text-sm text-muted mb-3">
126
+ Sends as: <strong class="text-heading" x-text="subjectText()"></strong>
43
127
  </p>
44
- <% end %>
45
- </header>
128
+ <% if editor_config[:targets].any? %>
129
+ <%# A custom listbox rather than a <select>: a native option cannot carry
130
+ an image, and the avatar is the whole reason this exists — it is how
131
+ you tell at a glance which account you are looking at. Keyboard and
132
+ click-away are handled; the value lives in Alpine state, and the
133
+ hidden form carries it. %>
134
+ <div class="relative" @keydown.escape="pickerOpen = false" @click.outside="pickerOpen = false">
135
+ <button type="button" @click="pickerOpen = !pickerOpen" data-preview-target
136
+ class="input-field w-full py-2 flex items-center gap-3 text-left"
137
+ :aria-expanded="pickerOpen" aria-haspopup="listbox">
138
+ <template x-if="target().avatar_url">
139
+ <img :src="target().avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
140
+ </template>
141
+ <template x-if="!target().avatar_url">
142
+ <span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
143
+ :style="'background-color: ' + (target().avatar_color || '#6b7280')"
144
+ x-text="target().initials"></span>
145
+ </template>
146
+ <span class="min-w-0">
147
+ <span class="block text-heading truncate" x-text="target().name || target().label"></span>
148
+ <span class="block text-2xs text-muted truncate" x-text="target().email"></span>
149
+ </span>
150
+ <svg class="w-4 h-4 text-muted ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
151
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
152
+ </svg>
153
+ </button>
46
154
 
47
- <%# The banner riding on top of this email, and where it came from. %>
48
- <section class="card p-4 mb-6">
49
- <p class="label-upper mb-3">Banner</p>
50
- <div class="rounded-lg overflow-hidden border border-subtle max-w-md"
51
- style="aspect-ratio: <%= aspect %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
52
- <% if banner_url %>
53
- <%= image_tag banner_url, class: "w-full h-full object-cover",
54
- alt: "#{@entry.label} email banner" %>
55
- <% else %>
56
- <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
57
- This email sends without a banner.
155
+ <ul x-show="pickerOpen" x-cloak role="listbox"
156
+ class="absolute z-20 mt-1 w-full rounded-lg border border-subtle bg-surface shadow-lg overflow-hidden">
157
+ <template x-for="option in targets" :key="option.id">
158
+ <li>
159
+ <button type="button" role="option" data-preview-option
160
+ @click="targetId = option.id; pickerOpen = false"
161
+ :aria-selected="option.id === targetId"
162
+ class="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-inset"
163
+ :class="option.id === targetId ? 'bg-inset' : ''">
164
+ <template x-if="option.avatar_url">
165
+ <img :src="option.avatar_url" alt="" class="w-8 h-8 rounded-full object-cover shrink-0">
166
+ </template>
167
+ <template x-if="!option.avatar_url">
168
+ <span class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
169
+ :style="'background-color: ' + (option.avatar_color || '#6b7280')"
170
+ x-text="option.initials"></span>
171
+ </template>
172
+ <span class="min-w-0">
173
+ <span class="block text-heading truncate" x-text="option.name || option.label"></span>
174
+ <span class="block text-2xs text-muted truncate" x-text="option.email"></span>
175
+ </span>
176
+ </button>
177
+ </li>
178
+ </template>
179
+ </ul>
58
180
  </div>
181
+ <% else %>
182
+ <p class="text-sm text-muted">This app has no users to preview as yet.</p>
59
183
  <% end %>
60
- </div>
61
- <p class="text-sm text-muted mt-3">
184
+ </section>
185
+
186
+ <section class="card p-4">
187
+ <p class="label-upper mb-2">Subject</p>
188
+ <p class="text-sm text-body mb-3">
189
+ Variables:
190
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{name}</code>
191
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{app}</code>
192
+ </p>
193
+ <%= text_field_tag :subject, saved[:subject], id: "subject", name: nil,
194
+ "x-model": "form.subject", class: "input-field w-full py-2" %>
195
+ </section>
196
+ </div>
197
+
198
+ <%# ROW TWO — the pictures, and the words that ride on them. %>
199
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
200
+ <section class="card p-4"
62
201
  <% if @uploads_available %>
63
- Change it on the <%= link_to "emails list", admin_emails_path, class: "underline underline-offset-2" %>.
202
+ x-data="imageUploadHost({
203
+ store: 'emailModals',
204
+ aspectRatio: <%= aspect %>,
205
+ maxWidth: <%= max_width %>,
206
+ transparent: false,
207
+ allowGifs: true,
208
+ filename: '<%= @entry.key %>.png',
209
+ saving: 'Saving banner…',
210
+ success: 'Banner updated',
211
+ successMessage: '<%= j @entry.label %> now uses this app\'s own image.',
212
+ failure: 'Couldn\'t save the banner'
213
+ })"
214
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
215
+ <% end %>>
216
+ <p class="label-upper mb-3">Banner</p>
217
+ <% if @banner %>
218
+ <%= render "studio/emails/banner_preview", banner: @banner, ratio: aspect, max_width: card_width %>
64
219
  <% else %>
65
- <%= Studio.app_name %> has no object storage configured, so this image can be
66
- viewed but not replaced yet.
220
+ <div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-xs text-muted text-center px-4"
221
+ style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;">
222
+ This email has no layered banner, so it sends the artwork as a plain image.
223
+ </div>
67
224
  <% end %>
225
+
226
+ <p class="label-upper mb-3 mt-6">Artwork</p>
227
+ <%# The picture ALONE — the file an upload replaces. Hovering reveals the way
228
+ to change it, so the control sits on the thing it changes instead of in a
229
+ sentence pointing at another page. %>
230
+ <div class="relative group rounded-lg overflow-hidden border border-subtle" data-email-artwork-frame
231
+ style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
232
+ <% if banner_url %>
233
+ <%= image_tag banner_url, class: "w-full h-full object-cover",
234
+ alt: "#{@entry.label} email artwork" %>
235
+ <% else %>
236
+ <div class="w-full h-full flex items-center justify-center text-xs text-white/80 text-center px-4">
237
+ This email sends without a banner.
238
+ </div>
239
+ <% end %>
240
+ <% if @uploads_available %>
241
+ <button type="button" @click="open()" data-modify-artwork
242
+ class="absolute inset-0 w-full h-full flex items-center justify-center gap-2
243
+ bg-black/55 text-white text-sm font-semibold opacity-0
244
+ group-hover:opacity-100 focus:opacity-100 transition-opacity duration-150">
245
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
246
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
247
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
248
+ </svg>
249
+ Modify image
250
+ </button>
251
+ <% end %>
252
+ </div>
253
+ <% if @uploads_available %>
254
+ <%# The upload host writes the chosen file into THIS input and submits it.
255
+ Without the pair, imageUploadHost has no $refs.fileInput and the whole
256
+ flow dies at "Cannot set properties of undefined" — after the modal has
257
+ already closed, so it reads as a silent no-op. %>
258
+ <%= form_with url: admin_email_path(@entry.key), method: :patch,
259
+ html: { multipart: true, id: "email-banner-form-#{@entry.key.tr("_", "-")}", "x-ref": "form" } do %>
260
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
261
+ <% end %>
262
+ <% end %>
263
+ <p class="text-sm text-body mt-3"><%= badge_note %></p>
264
+ <% if @uploads_available && source == :app %>
265
+ <%# Only an APP-OWNED image can be reverted — there is nothing to drop
266
+ when this email is already showing the inherited default. %>
267
+ <%= button_to "Revert to the shared default", admin_email_path(@entry.key), method: :delete,
268
+ class: "btn btn-outline btn-sm mt-2",
269
+ form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{@entry.label} banner and go back to the shared default?" } } %>
270
+ <% end %>
271
+ <p class="text-2xs text-muted mt-2">
272
+ <% if @uploads_available %>
273
+ GIFs keep their animation. Displayed at <%= card_width %>x<%= (card_width / aspect).round %>.
274
+ <% else %>
275
+ <%= Studio.app_name %> has no object storage configured, so this image can be
276
+ viewed but not replaced yet.
277
+ <% end %>
278
+ </p>
279
+
280
+ <%# The same artwork with the tint, the words and the logo on it — rendered
281
+ by the mailer's own partial, so it cannot drift from the email. The email
282
+ table is a fixed 600px because an inbox has no responsive layout; it is
283
+ scaled to this frame, and both frames are capped at 600 so they match at
284
+ every width. %>
285
+ </section>
286
+ <section class="card p-4">
287
+ <%# No name attributes and no form here. Every field is Alpine state, and
288
+ the hidden form at the bottom of the page mirrors all of it — one Save
289
+ writes the subject, the words, the logo and the tint together. %>
290
+ <div class="space-y-4">
291
+ <div>
292
+ <label class="text-sm text-muted block mb-1" for="header">Header</label>
293
+ <%= text_field_tag :header, saved[:header], id: "header", name: nil,
294
+ "x-model": "form.header", class: "input-field w-full py-2" %>
295
+ </div>
296
+ <div>
297
+ <label class="text-sm text-muted block mb-1" for="header_fallback">
298
+ Header for someone with no name on file
299
+ </label>
300
+ <%= text_field_tag :header_fallback, saved[:header_fallback], id: "header_fallback", name: nil,
301
+ "x-model": "form.header_fallback", class: "input-field w-full py-2" %>
302
+ <p class="text-2xs text-muted mt-1">
303
+ Used instead of the header above when we hold no name — a magic link is
304
+ often the first thing a stranger receives, and "Welcome !" is the
305
+ alternative.
306
+ </p>
307
+ </div>
308
+ <div>
309
+ <label class="text-sm text-muted block mb-1" for="subtext">Sub-text</label>
310
+ <%= text_field_tag :subtext, saved[:subtext], id: "subtext", name: nil,
311
+ "x-model": "form.subtext", class: "input-field w-full py-2" %>
312
+ </div>
313
+
314
+ <%# THE LOGO — three states, because a URL box can only express two.
315
+ Standard inherits the shared mark, Custom is what this app uploaded,
316
+ Hidden is the deliberate "no logo" that blank cannot say. %>
317
+ <div>
318
+ <span class="text-sm text-muted block mb-1">Logo</span>
319
+ <div class="flex flex-wrap items-center gap-2">
320
+ <button type="button" @click="setLogoMode('standard')" data-logo-standard
321
+ :class="logoMode() === 'standard' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
322
+ Standard
323
+ </button>
324
+ <% if @uploads_available %>
325
+ <button type="button" @click="$dispatch('open-logo-upload')" data-logo-upload
326
+ :class="logoMode() === 'custom' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
327
+ Upload new
328
+ </button>
329
+ <% end %>
330
+ <button type="button" @click="setLogoMode('hidden')" data-logo-hidden
331
+ :class="logoMode() === 'hidden' ? 'btn btn-primary btn-sm' : 'btn btn-outline btn-sm'">
332
+ None
333
+ </button>
334
+ </div>
335
+ <% if Studio::EmailCatalog.uploaded_logo_url(@entry.key).present? %>
336
+ <%= button_to "Back to the standard logo", admin_email_logo_path(@entry.key),
337
+ method: :patch, params: { remove: "1" },
338
+ class: "text-2xs text-muted underline underline-offset-2 mt-2" %>
339
+ <% end %>
340
+ </div>
341
+
342
+ <%# THE TINT lives here, beside the words it makes legible, and repaints
343
+ the banner as it changes. It was its own card with its own Save
344
+ button; that put the one control that trades legibility against
345
+ picture in a different place from the text it is protecting. %>
346
+ <div>
347
+ <label class="text-sm text-muted block mb-1" for="scrim_percent">
348
+ Tint — how far the artwork is darkened behind the text
349
+ </label>
350
+ <div class="flex items-center gap-3">
351
+ <input type="range" min="0" max="100" step="1" id="scrim_percent" data-scrim-range
352
+ x-model="form.scrim_percent" class="studio-range flex-1"
353
+ :style="'--range-fill: ' + scrimFill()">
354
+ <span class="font-mono text-2xs text-heading w-12 text-right"
355
+ x-text="(form.scrim_percent === '' ? <%= (Studio::Banner::DEFAULT_SCRIM * 100).round %> : form.scrim_percent) + '%'"></span>
356
+ </div>
357
+ <p class="text-2xs text-muted mt-1">
358
+ More tint means a more legible greeting; less means a brighter picture.
359
+ </p>
360
+ </div>
361
+ </div>
362
+ </section>
363
+ </div>
364
+
365
+ <%# BELOW THE BANNER — the words, the button, and the footer every email
366
+ shares. Each field reaches a real send: the mailers read these from the
367
+ catalogue instead of hard-coding copy, so a card here is not a control that
368
+ changes nothing. %>
369
+ <div class="grid gap-6 md:grid-cols-2 mb-6">
370
+ <section class="card p-4">
371
+ <p class="label-upper mb-2">Body</p>
372
+ <p class="text-sm text-body mb-3">
373
+ The paragraph under the header. Blank lines make new paragraphs, and
374
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{name}</code>
375
+ <code class="font-mono text-2xs px-1 py-0.5 rounded bg-inset">{app}</code> apply here too.
376
+ </p>
377
+ <%= text_area_tag :body, saved[:body], id: "body", name: nil, rows: 7,
378
+ "x-model": "form.body", class: "input-field w-full py-2" %>
379
+ </section>
380
+
381
+ <%# Offered ONLY when this email's template can render a button. A card for
382
+ an email that cannot is a dead control: the operator ticks the box, saves,
383
+ and no button ever appears. %>
384
+ <% if @entry.supports_cta? %>
385
+ <section class="card p-4">
386
+ <p class="label-upper mb-2">Call to action</p>
387
+ <div class="space-y-4">
388
+ <label class="flex items-center gap-2 text-sm text-body">
389
+ <%= check_box_tag :cta_enabled, "1", saved[:cta_enabled],
390
+ "x-model": "form.cta_enabled", class: "rounded border-subtle" %>
391
+ Show the button
392
+ </label>
393
+ <div :class="form.cta_enabled ? '' : 'opacity-50 pointer-events-none'">
394
+ <label class="text-sm text-muted block mb-1" for="cta_text">Button text</label>
395
+ <%= text_field_tag :cta_text, saved[:cta_text], id: "cta_text", name: nil,
396
+ "x-model": "form.cta_text", class: "input-field w-full py-2" %>
397
+ <label class="text-sm text-muted block mb-1 mt-3" for="cta_color">Button colour</label>
398
+ <div class="flex items-center gap-3">
399
+ <%= color_field_tag :cta_color, saved[:cta_color], id: "cta_color", name: nil,
400
+ "x-model": "form.cta_color", class: "h-9 w-14 rounded border border-subtle bg-transparent" %>
401
+ <span class="font-mono text-2xs text-muted" x-text="form.cta_color"></span>
402
+ </div>
403
+ <p class="text-2xs text-muted mt-2">Defaults to this app's primary colour.</p>
404
+ </div>
405
+ </div>
406
+ </section>
407
+ <% end %>
408
+ </div>
409
+
410
+ <%# THE FOOTER IS SHARED. Stored once for the whole app rather than per email,
411
+ because it is the same block on every one — so the label says out loud that
412
+ editing it here changes every email this app sends. %>
413
+ <section class="card p-4 mb-6">
414
+ <p class="label-upper mb-2">Footer</p>
415
+ <p class="text-sm text-body mb-3">Shared by every email <%= Studio.app_name %> sends.</p>
416
+ <div class="grid gap-4 md:grid-cols-2">
417
+ <div>
418
+ <label class="text-sm text-muted block mb-1" for="discord_url">Discord invite</label>
419
+ <%= text_field_tag :discord_url, saved[:discord_url], id: "discord_url", name: nil,
420
+ "x-model": "form.discord_url", placeholder: "https://discord.gg/…",
421
+ class: "input-field w-full py-2 font-mono text-2xs" %>
422
+ </div>
423
+ <div>
424
+ <label class="text-sm text-muted block mb-1" for="footer_logo_url">Application logo</label>
425
+ <%= text_field_tag :footer_logo_url, saved[:footer_logo_url], id: "footer_logo_url", name: nil,
426
+ "x-model": "form.footer_logo_url",
427
+ class: "input-field w-full py-2 font-mono text-2xs" %>
428
+ </div>
429
+ </div>
430
+ <p class="text-2xs text-muted mt-2">
431
+ Leave both blank and no footer renders at all. Clearing them later removes it
432
+ from every email.
68
433
  </p>
69
434
  </section>
70
435
 
71
- <%# The email itself. An iframe because the response IS an email document —
72
- its own <html>, its own table layout, and no business inheriting the admin
73
- page's stylesheet. %>
436
+ <%# The email itself. An iframe because the response IS an email document — its
437
+ own document shell, its own table layout, and no business inheriting the
438
+ admin page's stylesheet. %>
74
439
  <section class="card p-3 bg-inset">
75
440
  <p class="label-upper mb-3 px-1">Preview</p>
76
441
  <% if @entry.previewable? %>
@@ -89,4 +454,73 @@
89
454
  <p class="text-2xs text-danger mt-2 px-1 font-mono break-all"><%= @preview_error %></p>
90
455
  <% end %>
91
456
  </section>
457
+
458
+ <%# THE ONE WRITE. Every visible control above is Alpine state with no name
459
+ attribute; these hidden inputs mirror that state and this is the form the
460
+ header's Save submits. Done this way because a single <form> cannot wrap
461
+ the page — the artwork uploader is itself a form, and nesting forms is
462
+ invalid HTML that browsers silently repair by dropping one. %>
463
+ <%= form_with url: admin_email_copy_path(@entry.key), method: :patch,
464
+ html: { "x-ref": "saveForm", class: "hidden" } do %>
465
+ <input type="hidden" name="subject" :value="form.subject">
466
+ <input type="hidden" name="header" :value="form.header">
467
+ <input type="hidden" name="header_fallback" :value="form.header_fallback">
468
+ <input type="hidden" name="subtext" :value="form.subtext">
469
+ <input type="hidden" name="logo_url" :value="form.logo_url">
470
+ <input type="hidden" name="hide_logo" :value="form.hide_logo ? '1' : ''">
471
+ <input type="hidden" name="scrim_percent" :value="form.scrim_percent">
472
+ <input type="hidden" name="body" :value="form.body">
473
+ <input type="hidden" name="cta_text" :value="form.cta_text">
474
+ <input type="hidden" name="cta_color" :value="form.cta_color">
475
+ <input type="hidden" name="cta_enabled" :value="form.cta_enabled ? '1' : '0'">
476
+ <input type="hidden" name="discord_url" :value="form.discord_url">
477
+ <input type="hidden" name="footer_logo_url" :value="form.footer_logo_url">
478
+ <% end %>
479
+
480
+ <%= render "studio/emails/banner_scale" %>
481
+ <%= render "studio/emails/banner_editor" %>
482
+
483
+ <% if @uploads_available %>
484
+ <%# Cropper + the page-scoped modal host, exactly as the index mounts them:
485
+ scoped_host rather than modals/host, because mcritchie-studio and
486
+ turf-monster both ship app copies of modals/host that shadow the engine's
487
+ in this non-isolated engine.
488
+
489
+ Optional chaining on current() is load-bearing: the outer template
490
+ unmounts one tick AFTER the stack empties, so a bare .id throws on every
491
+ modal close. %>
492
+ <%# The LOGO uploader — a second host, listening for its own event. Separate
493
+ from the artwork host because the two want different crops: artwork is
494
+ 3:1 and may be an animated GIF, a logo is a small transparent mark that
495
+ should keep its alpha and be cropped freely. %>
496
+ <div x-data="imageUploadHost({
497
+ store: 'emailModals',
498
+ aspectRatio: 0,
499
+ maxWidth: 600,
500
+ transparent: true,
501
+ allowGifs: false,
502
+ filename: '<%= @entry.key %>-logo.png',
503
+ saving: 'Saving logo…',
504
+ success: 'Logo updated',
505
+ successMessage: 'This email now uses its own logo.',
506
+ failure: 'Couldn\'t save the logo'
507
+ })"
508
+ @open-logo-upload.window="open()"
509
+ @crop-photo-confirmed.window="onCropConfirmed($event.detail)">
510
+ <%= form_with url: admin_email_logo_path(@entry.key), method: :patch,
511
+ html: { multipart: true, "x-ref": "form", class: "hidden" } do %>
512
+ <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
513
+ <% end %>
514
+ </div>
515
+
516
+ <%= render "studio/cropper_assets" %>
517
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
518
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
519
+ <div><%= render "studio/modals/crop_photo", store: "emailModals" %></div>
520
+ </template>
521
+ <template x-if="$store.emailModals.current()?.id === 'saving'">
522
+ <div><%= render "studio/modals/saving", store: "emailModals" %></div>
523
+ </template>
524
+ <% end %>
525
+ <% end %>
92
526
  </div>