studio-engine 0.42.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d675146488bc53f26f2ab9df774878f26b8c6fe714f8298fda17fbf85b3632de
4
- data.tar.gz: afb41c11138b07903925c87abe6bab93d82476db7789226b79b2b38872f0ab2a
3
+ metadata.gz: 60d9ce89189f6f027c2158888a403269310397f20cb86cddc88519e9ec4644bb
4
+ data.tar.gz: cb5f1ee58d3825e6b3ee705f07f8ede2cd2ebed14e2d86f03ddf22c1debb4b20
5
5
  SHA512:
6
- metadata.gz: f209b273cfacd2d9f428f43cc886b15e57ff7b1131a6753589d8ddcd17b07c9458c678ae1583e4826038a8d3cd2004757f460695952f60367677fbac7c44fc73
7
- data.tar.gz: 5aa5fe2bf6fa67cfdf30aa6fdfe253f34f6c29715aa42ef56c95d892d61706d049c6b6f137908db54d6d8fda72ebb7e16e7b66e4c2705fc0286cbbcabbdcb9f8
6
+ metadata.gz: 2d82567b9773871905899b6519699d95d5c05a73c5239914c3535d9a16198f6282c7d30309f07bb7961760d51a114ba031c4f20b83321ae1eb71f2ef7b7dc065
7
+ data.tar.gz: ae096e420cc8e25d8cb34013f6fc5eee1b41b5c102561b626bf67330b64e0115067ba0c237d32ab9b2c96f963181ae6c419f2fbf01f2f909504337980942cd9f
@@ -125,7 +125,22 @@ module Studio
125
125
  # two places to press.
126
126
  def copy
127
127
  rescue_and_log do
128
- Studio::EmailSetting.set_copy(@key, copy_params)
128
+ attrs = copy_params
129
+ Studio::EmailSetting.set_copy(@key, attrs)
130
+ # The button's on/off is a boolean, so it is not a COPY_FIELD and needs
131
+ # its own write — the same shape that once made hide_logo a dead control.
132
+ Studio::EmailSetting.set_cta_enabled(@key, attrs[:cta_enabled]) if attrs.key?(:cta_enabled)
133
+ # The footer is SHARED across every email, so it is stored under its own
134
+ # key rather than this one, and the page posts its two inputs on every
135
+ # save. update_footer (not set_footer) is what makes that safe: it writes
136
+ # only on a real change, so blank posts from a page nobody edited cannot
137
+ # wipe the footer every email sends.
138
+ #
139
+ # The form spells the footer's logo `footer_logo_url` because `logo_url`
140
+ # is already this email's OWN banner logo; the model takes it as
141
+ # `logo_url`, so the rename is undone here.
142
+ Studio::EmailSetting.update_footer(discord_url: attrs[:discord_url],
143
+ logo_url: attrs[:footer_logo_url])
129
144
  if params.key?(:scrim_percent)
130
145
  percent = params[:scrim_percent]
131
146
  percent = nil unless percent.present? && Studio::EmailSetting::SCRIM_RANGE.cover?(percent.to_i)
@@ -202,7 +217,8 @@ module Studio
202
217
  # Every model-level logo test passed throughout, because set_copy was never
203
218
  # the broken part. The filter lives here, so the assertion does too.
204
219
  def copy_params
205
- params.permit(*Studio::EmailSetting::COPY_FIELDS, :hide_logo)
220
+ params.permit(*Studio::EmailSetting::COPY_FIELDS, :hide_logo, :cta_enabled,
221
+ :discord_url, :footer_logo_url)
206
222
  .to_h.symbolize_keys
207
223
  end
208
224
 
@@ -35,6 +35,10 @@ module Studio
35
35
  # registered the layout renders the flat <img> instead of nothing.
36
36
  @banner_url = Studio::EmailCatalog.resolved_url(:newsletter_subscribed)
37
37
 
38
+ # No @cta_* here: this email's template renders no button, and setting
39
+ # ivars a view never reads is how a dead control looks like a live one.
40
+ @body = Studio::EmailCatalog.body(:newsletter_subscribed, name: @name)
41
+
38
42
  subject = Studio::EmailCatalog.subject_for(:newsletter_subscribed, name: @name) ||
39
43
  "You're subscribed to #{@app_name}"
40
44
  mail(to: email, subject: subject)
@@ -50,6 +50,13 @@ class UserMailer < ApplicationMailer
50
50
  # Layered is opt-in, never a migration.
51
51
  @banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
52
52
  @banner_alt = "Your #{@app_name} sign-in link"
53
+ # THE COPY BELOW THE BANNER is the operator's too, on the same terms as the
54
+ # banner's words: the registry carries what this file used to hard-code, so
55
+ # an app that never opens /admin/emails sends exactly what it sent before.
56
+ @body = Studio::EmailCatalog.body(:magic_link, name: name)
57
+ @cta_text = Studio::EmailCatalog.cta_text(:magic_link, name: name) if Studio::EmailCatalog.cta_enabled?(:magic_link)
58
+ @cta_color = Studio::EmailCatalog.cta_color(:magic_link)
59
+
53
60
  # The operator's subject when they have set one, the hard-coded line
54
61
  # otherwise — so an app that never visits /admin/emails is unchanged.
55
62
  subject = Studio::EmailCatalog.subject_for(:magic_link, name: name) ||
@@ -21,7 +21,13 @@ module Studio
21
21
 
22
22
  # The banner's words and logo. Each is nil until the operator sets it, and
23
23
  # nil means INHERIT — never "empty".
24
- COPY_FIELDS = %i[header header_fallback subtext logo_url subject].freeze
24
+ COPY_FIELDS = %i[header header_fallback subtext logo_url subject body cta_text cta_color].freeze
25
+
26
+ # The footer is shared by every email this app sends, so it is stored once
27
+ # under a reserved key rather than copied onto each row. Underscored so it
28
+ # cannot collide with a registry key, which is always a plain identifier.
29
+ FOOTER_KEY = "_footer".freeze
30
+ FOOTER_FIELDS = %i[discord_url logo_url].freeze
25
31
 
26
32
  class << self
27
33
  # The saved scrim for this email as a 0.0-1.0 fraction, or nil when the
@@ -69,6 +75,75 @@ module Studio
69
75
  key.nil? ? cache.clear : cache.delete(key.to_s)
70
76
  end
71
77
 
78
+ # The shared footer, as a plain hash. Reads through the same per-request
79
+ # memo as everything else, so rendering it on every email in a list costs
80
+ # one query rather than one per email.
81
+ # The saved footer, or NIL when the operator has never touched it.
82
+ #
83
+ # nil and {} are different answers and the distinction is load-bearing: no
84
+ # row means "apply the defaults", while a row whose fields are blank means
85
+ # "I cleared these on purpose". Returning {} for both made clearing the
86
+ # logo hand the default straight back, so the field could not be emptied.
87
+ def footer
88
+ return nil unless table_ready?
89
+
90
+ row = for_key(FOOTER_KEY)
91
+ return nil if row.nil?
92
+
93
+ FOOTER_FIELDS.index_with { |field| row.public_send(field).presence }
94
+ end
95
+
96
+ # Write the footer ONLY when it actually changes.
97
+ #
98
+ # There is one Save for the whole page, so every save posts the footer
99
+ # inputs — blank ones included, from a page where the operator only touched
100
+ # the subject. Writing those blanks created a row of nils, which reads the
101
+ # same as "cleared", so the shared footer vanished from every email the app
102
+ # sends and could not be recovered without retyping it.
103
+ #
104
+ # Comparing against what is stored keeps both meanings: blanks matching an
105
+ # untouched footer write nothing, blanks replacing a stored value clear it.
106
+ def update_footer(discord_url: nil, logo_url: nil)
107
+ posted = { discord_url: discord_url.presence, logo_url: logo_url.presence }
108
+ stored = footer
109
+
110
+ return nil if stored.nil? && posted.values.all?(&:nil?)
111
+ return nil if stored.present? && stored.slice(:discord_url, :logo_url) == posted
112
+
113
+ set_footer(posted)
114
+ end
115
+
116
+ def set_footer(attrs)
117
+ return nil unless table_ready?
118
+
119
+ record = find_or_initialize_by(email_key: FOOTER_KEY)
120
+ FOOTER_FIELDS.each do |field|
121
+ next unless attrs.key?(field) || attrs.key?(field.to_s)
122
+
123
+ record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence)
124
+ end
125
+ record.save!
126
+ forget!(FOOTER_KEY)
127
+ record
128
+ end
129
+
130
+ # nil when the operator has not decided — the registry then answers.
131
+ def cta_enabled_for(key)
132
+ return nil unless table_ready?
133
+
134
+ for_key(key)&.cta_enabled
135
+ end
136
+
137
+ def set_cta_enabled(key, value)
138
+ return nil unless table_ready?
139
+
140
+ record = find_or_initialize_by(email_key: key.to_s)
141
+ record.cta_enabled = value.nil? ? nil : ActiveModel::Type::Boolean.new.cast(value)
142
+ record.save!
143
+ forget!(key)
144
+ record
145
+ end
146
+
72
147
  # True when the operator has explicitly hidden the logo — which is a
73
148
  # different answer from "no logo url saved" (that one inherits).
74
149
  def hide_logo?(key)
@@ -43,8 +43,13 @@ module Studio
43
43
  end
44
44
 
45
45
  # The email card is 600px wide; the banner fills it.
46
+ #
47
+ # 300, not 200. It was cut to 200 to take out vertical dead space, which the
48
+ # proportional type below then closed on its own — so the shorter box was
49
+ # buying nothing and costing the artwork half its sky. Everything in the
50
+ # partial scales from this number, which is what makes the change one line.
46
51
  DEFAULT_WIDTH = 600
47
- DEFAULT_HEIGHT = 200
52
+ DEFAULT_HEIGHT = 300
48
53
 
49
54
  # A wash between the artwork and the text. Not decoration: background art is
50
55
  # chosen for looks, not contrast, and white text over a pale sky is
@@ -80,14 +80,22 @@ module Studio
80
80
  # type — :transactional or :marketing.
81
81
  # preview — callable returning a Mail, or nil.
82
82
  Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
83
- :default_origin, :aspect_ratio, :background, :logo, :scrim,
84
- :header, :header_fallback, :subtext, :subject,
83
+ :default_origin, :background_origin, :aspect_ratio, :background, :logo, :scrim,
84
+ :header, :header_fallback, :subtext, :subject, :body, :cta_text, :cta_color, :cta_enabled, :supports_cta,
85
85
  keyword_init: true) do
86
86
  def to_s = key
87
87
  def previewable? = preview.respond_to?(:call)
88
88
  # nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
89
89
  def marketing? = type.to_s == "marketing"
90
90
 
91
+ # Can this email render a button AT ALL? An email's view has to have
92
+ # somewhere to send the reader — the sign-in link has a token URL, a
93
+ # "you're subscribed" note has nothing. OPT-IN, because an entry that has
94
+ # not said yes cannot be assumed to have a destination, and a Call-to-action
95
+ # card offered for an email whose template ignores it is a dead control:
96
+ # the operator ticks the box, saves, and no button ever appears.
97
+ def supports_cta? = supports_cta == true
98
+
91
99
  # WHOSE artwork default_asset names. Recorded at registration rather than
92
100
  # inferred later: by the time the page asks, a resolved asset path looks
93
101
  # identical whether the file came from the gem or from the host, and
@@ -96,6 +104,24 @@ module Studio
96
104
  # passes default_asset for is that host's own.
97
105
  def engine_artwork? = default_origin.to_s != "app"
98
106
 
107
+ # Does THIS APP's artwork layer?
108
+ #
109
+ # True when the app registered a background itself — that is the host
110
+ # saying "layer this email", whatever else it registered. Also true when
111
+ # the app has registered no artwork at all, so an app that touches nothing
112
+ # keeps the engine's layered banner.
113
+ #
114
+ # False only in the case this exists for: the app registered its OWN flat
115
+ # asset and merely INHERITED a background it never asked for. turf-monster
116
+ # is exactly that — its magic_link carries a baked-in .jpg and inherits the
117
+ # engine's island art — and drawing live text over a picture it never sends
118
+ # is the failure this whole feature exists to prevent.
119
+ def layered?
120
+ return true if background_origin.to_s == "app"
121
+
122
+ engine_artwork?
123
+ end
124
+
99
125
  # The shape of THIS email's banner — the box the page draws and the ratio
100
126
  # the upload cropper enforces. Per-entry because the engine's own artwork
101
127
  # is 3:1 while turf-monster's eight banners are 2:1; one global constant
@@ -111,7 +137,7 @@ module Studio
111
137
  label: "Magic-link sign-in",
112
138
  description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
113
139
  default_asset: "emails/magic-link.gif",
114
- aspect_ratio: 3.0,
140
+ aspect_ratio: 2.0,
115
141
  # Layered artwork: the background animates, the greeting is live HTML on
116
142
  # top. default_asset above stays the flat <img> for a mailer that has not
117
143
  # adopted the layered banner.
@@ -122,13 +148,18 @@ module Studio
122
148
  header: "Welcome {name}!",
123
149
  header_fallback: "Your Magic Link",
124
150
  subtext: "your sign-in link is below",
125
- subject: "Your {app} sign-in link"
151
+ subject: "Your {app} sign-in link",
152
+ body: "Tap the button below to sign in to {app} — no password needed. " \
153
+ "If you don't have an account yet, we'll create one for you.",
154
+ # The token URL is the destination, so this email really does have a button.
155
+ supports_cta: true,
156
+ cta_text: "Sign in to {app}"
126
157
  },
127
158
  {
128
159
  key: "newsletter_subscribed",
129
160
  label: "Newsletter subscribed",
130
161
  description: "Welcomes someone who has just joined the mailing list.",
131
- aspect_ratio: 3.0,
162
+ aspect_ratio: 2.0,
132
163
  # LAYERED-NATIVE: no default_asset. A flat asset is the pre-layered
133
164
  # fallback — artwork with the words baked in, for a mailer that only
134
165
  # knows how to render an <img>. Studio::NewsletterMailer has known how to
@@ -140,6 +171,11 @@ module Studio
140
171
  header_fallback: "You're subscribed!",
141
172
  subtext: "you're on the list",
142
173
  subject: "You're subscribed to {app}",
174
+ body: "Thanks for subscribing to {app}. We'll send the occasional note " \
175
+ "about what we're building — no more often than it's worth your time.",
176
+ # NO BUTTON, and no card offering one: a new subscriber has nowhere to be
177
+ # sent, so subscribed.html.erb renders none and never will.
178
+ supports_cta: false,
143
179
  # The engine ships this email's preview because it can: the mailer takes
144
180
  # a bare address, so no host sample data is involved. Every other entry's
145
181
  # builder needs records only the host has — this one does not, and an
@@ -152,12 +188,33 @@ module Studio
152
188
  # The FALLBACK shape, for an email that states none. 2:1 because that is what
153
189
  # turf-monster's eight banners are, and changing it would recrop all of them.
154
190
  #
155
- # It is NOT what the engine's own emails use: both STANDARD entries declare
156
- # aspect_ratio: 3.0 and both shipped backgrounds are 1200x400. The ratio is
157
- # per-entry precisely so those two answers can differ.
191
+ # The engine's own two entries now declare 2.0 as well, and their backgrounds
192
+ # are 1200x600 but the ratio stays per-entry, because that is what lets a
193
+ # host register artwork of a different shape without recropping everyone's.
158
194
  ASPECT_RATIO = 2.0
159
195
  MAX_WIDTH = 1200
160
196
 
197
+ # NO DEFAULT LOGO, deliberately.
198
+ #
199
+ # It was emails/logo-horizontal.png — the white "McRITCHIE STUDIO" wordmark —
200
+ # and every consumer inherits this layout without defining its own. That put
201
+ # Studio branding into turf-monster's entire player-facing mail set, which
202
+ # today carries none, plus moms-app, mcritchie-industries, acquisition-studio
203
+ # and rolio, with no opt-in and no host-side change.
204
+ #
205
+ # It also contradicted the rule at the top of this file: the url / resolved_url
206
+ # split exists so the engine cannot swap a host's artwork for the engine's in
207
+ # live email. A footer logo is the same swap by another route.
208
+ #
209
+ # Each app opts in on /admin/emails by pasting its own mark.
210
+
211
+ # The footer band. Dark on purpose: it closes the white card, and a light
212
+ # sign-off floating under body copy reads as part of the message rather than
213
+ # the end of it. Hard-coded rather than derived from the theme because a
214
+ # host's primary can be light, and white-on-light is unreadable — the one
215
+ # thing this band must never be.
216
+ FOOTER_BACKGROUND = "#1A1535".freeze
217
+
161
218
  module_function
162
219
 
163
220
  # --- Registry ----------------------------------------------------------
@@ -169,7 +226,8 @@ module Studio
169
226
  # attach a preview builder to it, without restating its artwork.
170
227
  def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
171
228
  aspect_ratio: nil, background: nil, logo: nil, scrim: nil,
172
- header: nil, header_fallback: nil, subtext: nil, subject: nil)
229
+ header: nil, header_fallback: nil, subtext: nil, subject: nil,
230
+ body: nil, cta_text: nil, cta_color: nil, cta_enabled: nil, supports_cta: nil)
173
231
  key = key.to_s
174
232
  existing = registry[key]
175
233
  registry[key] = Entry.new(
@@ -184,6 +242,11 @@ module Studio
184
242
  # had, so a host relabelling an inherited email does not accidentally
185
243
  # claim the engine's picture as its own.
186
244
  default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
245
+ # WHOSE background this is, recorded the same way and for the same
246
+ # reason: by the time anyone asks, an inherited background and a
247
+ # host-registered one are indistinguishable, and guessing is what this
248
+ # records to avoid. A host passing background: is ASKING to layer.
249
+ background_origin: background.nil? ? (existing&.background_origin || :engine) : :app,
187
250
  aspect_ratio: aspect_ratio || existing&.aspect_ratio,
188
251
  background: background.nil? ? existing&.background : background.presence,
189
252
  logo: logo.nil? ? existing&.logo : logo.presence,
@@ -191,7 +254,12 @@ module Studio
191
254
  header: header.nil? ? existing&.header : header.presence,
192
255
  header_fallback: header_fallback.nil? ? existing&.header_fallback : header_fallback.presence,
193
256
  subtext: subtext.nil? ? existing&.subtext : subtext.presence,
194
- subject: subject.nil? ? existing&.subject : subject.presence
257
+ subject: subject.nil? ? existing&.subject : subject.presence,
258
+ body: body.nil? ? existing&.body : body.presence,
259
+ cta_text: cta_text.nil? ? existing&.cta_text : cta_text.presence,
260
+ cta_color: cta_color.nil? ? existing&.cta_color : cta_color.presence,
261
+ cta_enabled: cta_enabled.nil? ? existing&.cta_enabled : cta_enabled,
262
+ supports_cta: supports_cta.nil? ? existing&.supports_cta : supports_cta
195
263
  )
196
264
  key
197
265
  end
@@ -322,6 +390,64 @@ module Studio
322
390
  saved(key, :subtext) || entry(key)&.subtext
323
391
  end
324
392
 
393
+ # --- the email below the banner ------------------------------------------
394
+ #
395
+ # Same resolution as the banner's words: operator > registry > default. A
396
+ # mailer reads these instead of hard-coding copy, which is what makes the
397
+ # cards on /admin/emails real rather than decorative.
398
+
399
+ def body(key, name: nil)
400
+ template = saved(key, :body) || entry(key)&.body
401
+ return nil if template.blank?
402
+
403
+ Studio::Banner.interpolate(template, name).presence
404
+ end
405
+
406
+ def cta_text(key, name: nil)
407
+ template = saved(key, :cta_text) || entry(key)&.cta_text
408
+ return nil if template.blank?
409
+
410
+ Studio::Banner.interpolate(template, name).presence
411
+ end
412
+
413
+ # The app's primary unless this email says otherwise — a button that matches
414
+ # the banner above it by default, and can be made to stand out per email.
415
+ def cta_color(key)
416
+ saved(key, :cta_color) || entry(key)&.cta_color || Studio.theme_primary
417
+ end
418
+
419
+ # Shown unless someone said no. Defaults to TRUE for an email that has CTA
420
+ # text, because the button is the point of a transactional email; an email
421
+ # with no text has nothing to render either way.
422
+ def cta_enabled?(key)
423
+ # An email whose template cannot render a button is never "enabled", no
424
+ # matter what is stored — otherwise a value saved before the capability was
425
+ # declared keeps claiming a button that cannot appear.
426
+ return false unless entry(key)&.supports_cta?
427
+
428
+ operator = begin
429
+ Studio::EmailSetting.cta_enabled_for(key)
430
+ rescue StandardError
431
+ nil
432
+ end
433
+ return operator unless operator.nil?
434
+
435
+ registered = entry(key)&.cta_enabled
436
+ return registered unless registered.nil?
437
+
438
+ true
439
+ end
440
+
441
+ # The shared footer — the same on every email this app sends.
442
+ #
443
+ # The operator's footer, or nothing. An app that has not set one renders no
444
+ # band at all — the engine ships no branding of its own into a host's email.
445
+ def footer
446
+ (Studio::EmailSetting.footer || {}).compact
447
+ rescue StandardError
448
+ {}
449
+ end
450
+
325
451
  # The subject line, resolved the same way and supporting the same {name}
326
452
  # placeholder. A mailer calls this instead of hard-coding a string, which is
327
453
  # what makes the field on /admin/emails real rather than decorative.
@@ -367,12 +493,16 @@ module Studio
367
493
  # flipped to "Uploaded here", and the email kept sending the gem's artwork
368
494
  # because the layered banner never looked at the row.
369
495
  #
370
- # NIL WHEN THIS APP OWNS THE ARTWORK. A host registering its own flat
371
- # default_asset sends that picture; the background it also inherited is the
372
- # engine's and nothing sends it. The list row carried this guard alone, so
373
- # the detail page still layered live text over artwork no inbox receives.
496
+ # NIL UNLESS THIS EMAIL LAYERS. A host registering its own flat
497
+ # default_asset sends that picture, and the background it merely INHERITED is
498
+ # the engine's nothing sends it. But a host that registers a background of
499
+ # its OWN is asking to layer, and layered? is what tells the two apart.
500
+ #
501
+ # This is the ONE place that decision is made. Every reader asks this method
502
+ # rather than re-deriving it: the list row used to carry its own copy of the
503
+ # guard, and the copy went stale the moment the guard moved here.
374
504
  def background_url(key)
375
- return nil unless entry(key)&.engine_artwork?
505
+ return nil unless entry(key)&.layered?
376
506
 
377
507
  url(key) || absolute_asset_url(entry(key)&.background)
378
508
  end
@@ -519,10 +649,19 @@ module Studio
519
649
  # The flat asset stays the fallback, for a host still on the engine's own
520
650
  # unlayered UserMailer — there, the baked-text banner IS what arrives.
521
651
  #
522
- # Unless THIS APP owns the artwork, in which case it previews exactly what
652
+ # Unless this email does not LAYER, in which case it previews exactly what
523
653
  # the flat resolution sends — same method, so the two cannot disagree.
654
+ #
655
+ # THE SAME QUESTION background_url ASKS, so it must ask it the same way.
656
+ # This guard read engine_artwork? while background_url read layered?, and the
657
+ # two answer differently for a host that registers its own background: the
658
+ # mailer sent the layered banner while /admin/email_images and the detail
659
+ # page's "Artwork" frame both drew the flat asset. That frame is where
660
+ # "Modify image" lives, and an upload writes the row background_url reads
661
+ # FIRST — so the operator was shown one picture and told it was the one the
662
+ # button would replace.
524
663
  def preview_asset_path(key)
525
- return default_asset_path(key) unless entry(key)&.engine_artwork?
664
+ return default_asset_path(key) unless entry(key)&.layered?
526
665
 
527
666
  asset_path(entry(key)&.background.presence || entry(key)&.default_asset)
528
667
  end
@@ -42,6 +42,38 @@
42
42
  <%= yield %>
43
43
  </td>
44
44
  </tr>
45
+
46
+ <%# THE SHARED FOOTER — the same on every email this app sends, which is
47
+ why it lives in the layout and is stored once rather than per email.
48
+
49
+ FULL-BLEED AND DARK. The band closes the white card: a light sign-off
50
+ floating under the body reads as part of the message rather than the
51
+ end of it. bgcolor as well as the CSS, because Outlook renders through
52
+ Word and drops background-color on a td often enough that the
53
+ attribute is the only reliable half.
54
+
55
+ Renders nothing when the operator has cleared both fields. %>
56
+ <% footer = Studio::EmailCatalog.footer %>
57
+ <% if footer[:logo_url].present? || footer[:discord_url].present? %>
58
+ <tr>
59
+ <td align="center" bgcolor="<%= Studio::EmailCatalog::FOOTER_BACKGROUND %>"
60
+ <%# color as well as the background: with images blocked, the alt
61
+ text is all that is left, and a client's near-black default on
62
+ a near-black band is invisible. %>
63
+ style="background-color:<%= Studio::EmailCatalog::FOOTER_BACKGROUND %>;padding:28px 36px;color:#F5F3FF;">
64
+ <% if footer[:logo_url].present? %>
65
+ <%# height as well as width: Outlook collapses an alt placeholder
66
+ that has no height, so a blocked image leaves nothing at all. %>
67
+ <img src="<%= footer[:logo_url] %>" width="132" height="32" alt="<%= Studio.app_name %>"
68
+ style="display:block;margin:0 auto 14px;width:132px;height:auto;border:0;color:#F5F3FF;" />
69
+ <% end %>
70
+ <% if footer[:discord_url].present? %>
71
+ <a href="<%= footer[:discord_url] %>"
72
+ style="font-size:13px;color:#C9C2FF;text-decoration:none;">Join us on Discord</a>
73
+ <% end %>
74
+ </td>
75
+ </tr>
76
+ <% end %>
45
77
  </table>
46
78
  </td>
47
79
  </tr>
@@ -112,6 +112,13 @@
112
112
 
113
113
  // Field by field rather than JSON.stringify, whose output depends on key
114
114
  // order — two equal objects can stringify differently.
115
+ //
116
+ // No type coercion here, deliberately. An earlier version normalised
117
+ // booleans on the theory that a checkbox holds true while the server
118
+ // echoes "1"; a mutation run showed removing it changed nothing, because
119
+ // the payload sends real booleans for the two checkboxes and strings for
120
+ // everything else. Unproven defensive code in a comparison that decides
121
+ // whether a Save button appears is worse than none.
115
122
  dirty() {
116
123
  return Object.keys(this.initial).some(function (k) {
117
124
  return (this.form[k] ?? "") !== (this.initial[k] ?? "");
@@ -0,0 +1,62 @@
1
+ <%# THE banner preview — one component, every size.
2
+
3
+ locals:
4
+ banner: a Studio::Banner (required)
5
+ ratio: the frame's aspect ratio (default: the banner's own)
6
+ max_width: cap the frame in px, or nil to fill its column
7
+ isolate: render inside an iframe (default true)
8
+
9
+ WHY IT IS A COMPONENT
10
+ There were two copies of this — the list row and the email's own page — and
11
+ they had already drifted: one carried the "does this app actually send a
12
+ layered banner" guard and the other did not, so the detail page previewed
13
+ live text over artwork no inbox receives. One component means a fix lands
14
+ everywhere, which is the same argument as rendering through the mailer's own
15
+ partial rather than a lookalike.
16
+
17
+ WHY THE IFRAME
18
+ The banner is the EMAIL's markup — a <table>. Dropping one into a list row
19
+ nests rows inside rows, and every host asserting "one row per email" counted
20
+ three per layered email. A separate document keeps the host page's markup
21
+ contract intact. Pass isolate: false where nesting is not a concern and a
22
+ plain preview is cheaper.
23
+
24
+ WHY THE RESET
25
+ A srcdoc document is a fresh page with the browser's default stylesheet, so
26
+ it inherits `body { margin: 8px }` — which showed as a band of padding around
27
+ every thumbnail. The email itself never sees this: the reset lives here, not
28
+ in the partial the mailer renders.
29
+ %>
30
+ <%
31
+ banner = local_assigns.fetch(:banner)
32
+ ratio = local_assigns.fetch(:ratio, banner.width.to_f / banner.height)
33
+ max_width = local_assigns.fetch(:max_width, Studio::Banner::DEFAULT_WIDTH)
34
+ isolate = local_assigns.fetch(:isolate, true)
35
+
36
+ markup = render("studio/mailers/layered_banner", banner: banner, preview: true)
37
+ document = <<~HTML
38
+ <!doctype html><html><head><meta charset="utf-8">
39
+ <style>html,body{margin:0;padding:0;background:transparent;}</style>
40
+ </head><body>#{markup}</body></html>
41
+ HTML
42
+ frame_style = ["aspect-ratio: #{ratio}"]
43
+ frame_style << "max-width: #{max_width}px" if max_width
44
+ %>
45
+ <div class="rounded-lg overflow-hidden border border-subtle" data-email-banner-frame
46
+ style="<%= frame_style.join("; ") %>;">
47
+ <% if isolate %>
48
+ <iframe class="pointer-events-none block" scrolling="no" tabindex="-1" loading="lazy"
49
+ data-email-banner-preview data-banner-width="<%= banner.width %>"
50
+ title="<%= banner.header.presence || "Email banner" %>"
51
+ style="width:<%= banner.width %>px;height:<%= banner.height %>px;border:0;display:block;transform-origin:top left;"
52
+ <%# ESCAPED EXPLICITLY: render returns an html_safe buffer, so a bare
53
+ output tag injects it RAW and closes the iframe at the banner's
54
+ first quote. to_str drops the safe flag so escaping happens. %>
55
+ srcdoc="<%= ERB::Util.html_escape(document.to_str) %>"></iframe>
56
+ <% else %>
57
+ <div data-email-banner-preview data-banner-width="<%= banner.width %>"
58
+ style="width:<%= banner.width %>px;transform-origin:top left;">
59
+ <%= markup %>
60
+ </div>
61
+ <% end %>
62
+ </div>
@@ -28,12 +28,12 @@
28
28
  # untouched — the same reason the email preview lower down the detail page is
29
29
  # one.
30
30
  #
31
- # And it only layers when THIS APP's artwork is layered. Whether an email
32
- # layers belongs to its mailer, which the catalogue cannot see; the closest
33
- # honest proxy is whose artwork is registered. turf-monster re-registers
34
- # magic_link with its own flat banner and inherits the engine's background, so
35
- # a naive check drew a layered picture turf-monster never sends.
36
- layered = Studio::EmailCatalog.background_url(entry.key).present? && entry.engine_artwork?
31
+ # And it only layers when THIS APP's artwork is layered a question
32
+ # background_url now answers by itself (it returns nil unless entry.layered?).
33
+ # The row used to carry a SECOND copy of that guard; when the guard moved down
34
+ # and grew the host-registered-background case, the stale copy started
35
+ # disagreeing with the mailer. One guard, one place, so they cannot drift.
36
+ layered = Studio::EmailCatalog.background_url(entry.key).present?
37
37
  banner = layered ? Studio::Banner.for(entry.key, name: name) : nil
38
38
  subject = Studio::EmailCatalog.subject_for(entry.key, name: name)
39
39
 
@@ -43,86 +43,41 @@
43
43
  header_fallback = Studio::EmailCatalog.header_fallback(entry.key)
44
44
  subject_template = Studio::EmailSetting.copy_for(entry.key, :subject) || entry.subject
45
45
 
46
- # `badge` is a shape-only utility in engine.css — the state color comes from
47
- # theme-token utilities so it follows each app's palette in light and dark.
48
- #
49
- # Four states, because three of them used to read as one. An app that ships
50
- # its own committed artwork was told that artwork "ships with the engine",
51
- # which is the wrong answer to the only question this column exists to answer.
52
- badge_class, badge_label, badge_note =
53
- case source
54
- when :app
55
- ["badge bg-success/10 text-success border-success/30", "Uploaded here",
56
- "Stored for #{Studio.app_name} — this upload is what ships."]
57
- when :app_asset
58
- ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork",
59
- "Committed in this app's own assets. Upload to replace it."]
60
- when :engine_default
61
- ["badge bg-inset text-muted border-subtle", "Studio default",
62
- "Shared artwork that ships with the engine. Upload to make it #{Studio.app_name}'s."]
63
- else
64
- ["badge bg-warning/10 text-warning border-warning/30", "No image",
65
- "This email sends without a banner."]
66
- end
67
46
  %>
68
- <%# The uploader host is attached ONLY when this app can actually store an
69
- override. On a read-only app the cropper assets are not loaded either, so an
70
- unconditional x-data would reference an undefined factory and Alpine would
71
- throw on every row. %>
72
- <%# THE WHOLE ROW OPENS THE EMAIL. Kept as a click handler on the row with the
73
- name still a real <a>: wrapping a table row in a link is invalid HTML, and
74
- making every cell a link would break the Upload button inside one of them.
75
- The handler ignores clicks that landed on a control, so Upload, Revert and
76
- the name link keep their own behaviour. %>
47
+ <%# THE WHOLE ROW OPENS THE EMAIL, and now that is the only thing it does. The
48
+ provenance badge and the upload button lived here and both moved to the
49
+ email's own page one place where changes are made, rather than a row that
50
+ is half a control panel.
51
+
52
+ A click handler rather than a wrapping link: an anchor around a <tr> is
53
+ invalid HTML. The name stays a real <a> so it keeps middle-click and
54
+ open-in-new-tab. %>
77
55
  <tr class="border-b border-subtle last:border-0 cursor-pointer hover:bg-inset/60" data-email-row
78
56
  data-email-path="<%= admin_email_path(entry.key) %>"
79
57
  data-header="<%= header_template %>"
80
58
  data-header-fallback="<%= header_fallback %>"
81
59
  data-subject="<%= subject_template %>"
82
- <% if uploads_available %>
83
- x-data="imageUploadHost({
84
- store: 'emailModals',
85
- aspectRatio: <%= entry.ratio %>,
86
- maxWidth: <%= max_width %>,
87
- transparent: false,
88
- <%# Same answer as the email's own page. Without this the SAME button
89
- refused an animated GIF here and accepted it there. %>
90
- allowGifs: true,
91
- filename: '<%= entry.key %>.png',
92
- saving: 'Saving banner…',
93
- success: 'Banner updated',
94
- successMessage: '<%= j entry.label %> now uses this app\'s own image.',
95
- failure: 'Couldn\'t save the banner'
96
- })"
97
- @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
98
- <% end %>>
60
+ >
99
61
 
100
62
  <td class="px-4 py-4">
101
- <div class="rounded-lg overflow-hidden border border-subtle w-56" data-email-banner-frame
102
- style="aspect-ratio: <%= entry.ratio %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
103
- <% if banner %>
104
- <%# srcdoc, so the email's table lives in its own document. Root-relative
105
- asset paths inside it resolve against this page's origin, which is
106
- what the admin preview wants. %>
107
- <iframe class="pointer-events-none" scrolling="no" tabindex="-1" loading="lazy"
108
- data-email-banner-preview data-banner-width="<%= card_width %>"
109
- title="<%= entry.label %> banner"
110
- style="width:<%= card_width %>px;height:<%= (card_width / entry.ratio).round %>px;border:0;transform-origin:top left;display:block;"
111
- <%# ESCAPED EXPLICITLY. render returns an html_safe buffer, so a bare
112
- output tag injects it RAW into the attribute and closes the iframe
113
- at the banner's first quote — the row's markup collapses and the
114
- email's name disappears from the page. to_str drops the safe flag
115
- so the escaping actually happens. %>
116
- srcdoc="<%= ERB::Util.html_escape(render("studio/mailers/layered_banner", banner: banner, preview: true).to_str) %>"></iframe>
117
- <% elsif banner_url %>
63
+ <% if banner %>
64
+ <%= render "studio/emails/banner_preview", banner: banner, ratio: entry.ratio, max_width: 224 %>
65
+ <% elsif banner_url %>
66
+ <div class="rounded-lg overflow-hidden border border-subtle"
67
+ style="aspect-ratio: <%= entry.ratio %>; max-width: 224px;">
118
68
  <%= image_tag banner_url, class: "w-full h-full object-cover",
119
69
  alt: "#{entry.label} email banner", loading: "lazy" %>
120
- <% else %>
121
- <div class="w-full h-full flex items-center justify-center text-2xs text-white/80 text-center px-3">
122
- No banner
123
- </div>
124
- <% end %>
125
- </div>
70
+ </div>
71
+ <% else %>
72
+ <div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-2xs text-white/80 text-center px-3"
73
+ style="aspect-ratio: <%= entry.ratio %>; max-width: 224px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
74
+ No banner
75
+ </div>
76
+ <% end %>
77
+ </td>
78
+
79
+ <td class="px-4 py-4">
80
+ <p class="text-sm text-heading" data-row-subject><%= subject %></p>
126
81
  </td>
127
82
 
128
83
  <td class="px-4 py-4">
@@ -138,41 +93,4 @@
138
93
  <p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
139
94
  </td>
140
95
 
141
- <td class="px-4 py-4">
142
- <p class="text-sm text-heading" data-row-subject><%= subject %></p>
143
- </td>
144
-
145
- <%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
146
- wrap inside a bordered badge, which reads as a broken box next to the
147
- short "Inherited default". %>
148
- <td class="px-4 py-4">
149
- <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
150
- <p class="text-2xs text-muted mt-1.5 max-w-[12rem]"><%= badge_note %></p>
151
- </td>
152
-
153
- <% if uploads_available %>
154
- <td class="px-4 py-4 text-right whitespace-nowrap">
155
- <button type="button" @click="open()" class="btn btn-outline btn-sm inline-flex items-center gap-1.5">
156
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
157
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
158
- 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"/>
159
- </svg>
160
- <%= source == :app ? "Replace" : "Upload" %>
161
- </button>
162
-
163
- <%# Only an APP-OWNED image can be reverted — there is nothing to drop when
164
- the row is already showing the inherited default. %>
165
- <% if source == :app %>
166
- <%= button_to "Revert", admin_email_path(entry.key), method: :delete,
167
- class: "btn btn-neutral btn-sm mt-1.5 w-full",
168
- form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{entry.label} banner and go back to the shared default?" } } %>
169
- <% end %>
170
-
171
- <%# The cropper drops its Blob into this hidden input and submits. %>
172
- <%= form_with url: admin_email_path(entry.key), method: :patch,
173
- html: { multipart: true, id: form_id, "x-ref": "form" } do %>
174
- <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
175
- <% end %>
176
- </td>
177
- <% end %>
178
96
  </tr>
@@ -101,13 +101,13 @@
101
101
  <table class="w-full text-left align-middle">
102
102
  <thead>
103
103
  <tr class="border-b border-subtle">
104
+ <%# Banner, then what an inbox shows: the subject line, then the
105
+ email itself. Provenance and the upload button used to sit here;
106
+ both belong on the email's own page, which is one click away and
107
+ is where every other change is made. %>
104
108
  <th class="label-upper px-4 py-3 w-64">Banner</th>
109
+ <th class="label-upper px-4 py-3 w-64">Subject</th>
105
110
  <th class="label-upper px-4 py-3">Email</th>
106
- <th class="label-upper px-4 py-3 w-56">Subject</th>
107
- <th class="label-upper px-4 py-3 w-52">Image</th>
108
- <% if @uploads_available %>
109
- <th class="label-upper px-4 py-3 w-40 text-right">Actions</th>
110
- <% end %>
111
111
  </tr>
112
112
  </thead>
113
113
  <tbody @click="openRow($event, $event.target.closest('[data-email-row]'))">
@@ -29,6 +29,15 @@
29
29
  subtext: Studio::EmailCatalog.subtext(@entry.key).to_s,
30
30
  subject: (Studio::EmailSetting.copy_for(@entry.key, :subject) || @entry.subject).to_s,
31
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,
32
41
  hide_logo: Studio::EmailSetting.hide_logo?(@entry.key),
33
42
  scrim_percent: Studio::EmailCatalog.scrim_percent(@entry.key).to_s
34
43
  }
@@ -49,12 +58,25 @@
49
58
  end
50
59
  }
51
60
 
52
- badge_class, badge_label =
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 =
53
67
  case source
54
- when :app then ["badge bg-success/10 text-success border-success/30", "Uploaded here"]
55
- when :app_asset then ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork"]
56
- when :engine_default then ["badge bg-inset text-muted border-subtle", "Studio default"]
57
- 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."]
58
80
  end
59
81
  %>
60
82
  <div class="max-w-5xl mx-auto px-4 pb-16"
@@ -193,13 +215,7 @@
193
215
  <% end %>>
194
216
  <p class="label-upper mb-3">Banner</p>
195
217
  <% if @banner %>
196
- <div class="rounded-lg overflow-hidden border border-subtle"
197
- style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;" data-email-banner-frame>
198
- <div data-email-banner-preview data-banner-width="<%= card_width %>"
199
- style="width:<%= card_width %>px;transform-origin:top left;">
200
- <%= render "studio/mailers/layered_banner", banner: @banner, preview: true %>
201
- </div>
202
- </div>
218
+ <%= render "studio/emails/banner_preview", banner: @banner, ratio: aspect, max_width: card_width %>
203
219
  <% else %>
204
220
  <div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-xs text-muted text-center px-4"
205
221
  style="aspect-ratio: <%= aspect %>; max-width: <%= card_width %>px;">
@@ -244,6 +260,14 @@
244
260
  <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
245
261
  <% end %>
246
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 %>
247
271
  <p class="text-2xs text-muted mt-2">
248
272
  <% if @uploads_available %>
249
273
  GIFs keep their animation. Displayed at <%= card_width %>x<%= (card_width / aspect).round %>.
@@ -338,6 +362,77 @@
338
362
  </section>
339
363
  </div>
340
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.
433
+ </p>
434
+ </section>
435
+
341
436
  <%# The email itself. An iframe because the response IS an email document — its
342
437
  own document shell, its own table layout, and no business inheriting the
343
438
  admin page's stylesheet. %>
@@ -374,6 +469,12 @@
374
469
  <input type="hidden" name="logo_url" :value="form.logo_url">
375
470
  <input type="hidden" name="hide_logo" :value="form.hide_logo ? '1' : ''">
376
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">
377
478
  <% end %>
378
479
 
379
480
  <%= render "studio/emails/banner_scale" %>
@@ -3,10 +3,17 @@
3
3
  emails read as coming from the same place. %>
4
4
  <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">You're on the list</h1>
5
5
 
6
- <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
7
- Thanks for subscribing to <strong style="color:#1f2a1c;"><%= @app_name %></strong>. We'll send
8
- the occasional note about what we're building no more often than it's worth your time.
9
- </p>
6
+ <%# From the catalogue, so /admin/emails owns this copy.
7
+
8
+ sanitize: true strips scripts and event handlers; it leaves a, strong and img
9
+ standing. The author is an admin, so that is a reasonable line — but it is not
10
+ "no HTML", and describing it that way would misstate the guard. %>
11
+ <%# Resolved here as well as in the mailer, for the same reason the sign-in view
12
+ does it: a host may render this view from its own mailer. %>
13
+ <% body = @body.presence || Studio::EmailCatalog.body(:newsletter_subscribed) %>
14
+ <% if body.present? %>
15
+ <%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
16
+ <% end %>
10
17
 
11
18
  <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
12
19
  Nothing else to do. The next one lands in this inbox.
@@ -1,22 +1,50 @@
1
1
  <%# Body only — branded_mailer supplies the banner + card.
2
2
 
3
- The button is theme PRIMARY, not success. Success is the "it worked" green;
3
+ The button and its copy-and-paste fallback share ONE colour cta_color,
4
+ which the operator can change. They were pinned to different sources, so a
5
+ yellow button left a purple fallback underneath it and the pair the comment
6
+ below promises to keep together came apart.
7
+
8
+ The button is theme PRIMARY by default, not success. Success is the "it worked" green;
4
9
  signing in has not happened yet, and the brand colour is what ties the button
5
10
  to the banner above it. The copy-and-paste link is the SAME colour on purpose
6
11
  — it is the button's fallback, and a green link under a purple button reads
7
12
  as a mistake. %>
8
13
  <h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your sign-in link</h1>
9
14
 
10
- <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
11
- Tap the button below to sign in to <strong style="color:#1f2a1c;"><%= @app_name %></strong> — no password needed.
12
- If you don't have an account yet, we'll create one for you.
13
- </p>
15
+ <%# BODY AND BUTTON COME FROM THE CATALOGUE, not from this file. An operator
16
+ edits them on /admin/emails; the strings that used to live here are the
17
+ registry defaults, so an app that never opens that page sends what it always
18
+ sent.
19
+
20
+ simple_format(sanitize: true) turns blank lines into paragraphs and strips
21
+ scripts and event handlers — it does NOT strip all HTML: a, strong and img
22
+ survive. That is acceptable because the author is an admin, but the guard is
23
+ narrower than "no HTML" and the comment should not claim otherwise.
24
+
25
+ THE VIEW RESOLVES THEM ITSELF when the mailer did not. A host that defines
26
+ its own UserMailer — turf-monster does on main; McRitchie Studio does on its
27
+ adoption branch — renders THIS
28
+ view without setting the new ivars, so depending on them alone shipped an
29
+ email with no body and no button to exactly the apps that had customised
30
+ their mail. The mailer's values still win when present, which is what carries
31
+ the recipient's name into the copy. %>
32
+ <%
33
+ body = @body.presence || Studio::EmailCatalog.body(:magic_link)
34
+ cta_text = @cta_text.presence || (Studio::EmailCatalog.cta_enabled?(:magic_link) ? Studio::EmailCatalog.cta_text(:magic_link) : nil)
35
+ cta_color = @cta_color.presence || Studio::EmailCatalog.cta_color(:magic_link)
36
+ %>
37
+ <% if body.present? %>
38
+ <%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
39
+ <% end %>
14
40
 
15
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
16
- <tr><td align="center" bgcolor="<%= Studio.theme_primary %>" style="border-radius:10px;">
17
- <a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">Sign in to <%= @app_name %> 🪄</a>
18
- </td></tr>
19
- </table>
41
+ <% if cta_text.present? %>
42
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
43
+ <tr><td align="center" bgcolor="<%= cta_color %>" style="border-radius:10px;">
44
+ <a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"><%= cta_text %> 🪄</a>
45
+ </td></tr>
46
+ </table>
47
+ <% end %>
20
48
 
21
49
  <p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
22
50
  This link is for <strong style="color:#2f3a2c;"><%= @email %></strong> and expires in <%= Studio.magic_link_ttl.in_minutes.round %> minutes — it can only be used once.
@@ -28,7 +56,7 @@
28
56
  button fails, so it has to be readable enough to select by hand. It stays
29
57
  a step under the 12px label above it — the label explains, the URL is the
30
58
  thing you drag across. %>
31
- <a href="<%= @magic_url %>" style="font-size:11px;line-height:1.5;color:<%= Studio.theme_primary %>;word-break:break-all;"><%= @magic_url %></a>
59
+ <a href="<%= @magic_url %>" style="font-size:11px;line-height:1.5;color:<%= cta_color %>;word-break:break-all;"><%= @magic_url %></a>
32
60
  </p>
33
61
 
34
62
  <p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
@@ -0,0 +1,32 @@
1
+ # The email BELOW the banner: its body copy, its call to action, and the footer
2
+ # every email shares.
3
+ #
4
+ # Same rule as the banner's words — nil means INHERIT the registry default, so
5
+ # an app that never opens /admin/emails sends exactly what it sent before.
6
+ #
7
+ # The FOOTER is app-wide rather than per-email, and it lives in this same table
8
+ # under a reserved key (Studio::EmailSetting::FOOTER_KEY). A separate table for
9
+ # two strings would cost a migration, a model and a join to say the same thing;
10
+ # a reserved key keeps one place to look for "what has the operator changed".
11
+ class AddBodyCtaFooterToStudioEmailSettings < ActiveRecord::Migration[7.2]
12
+ def change
13
+ change_table :studio_email_settings, bulk: true do |t|
14
+ # The paragraph(s) under the header. Rendered through
15
+ # simple_format(sanitize: true), which makes blank lines into paragraphs and
16
+ # strips scripts and event handlers — inline tags like a and strong survive.
17
+ # The author is an admin, so that line is deliberate rather than an oversight.
18
+ t.text :body
19
+
20
+ t.string :cta_text
21
+ # Hex, defaulting to the app's primary. Stored rather than derived so an
22
+ # operator can make one email's button stand out.
23
+ t.string :cta_color
24
+ # NULL, not false: three states again. nil inherits the registry's answer,
25
+ # true and false are the operator's.
26
+ t.boolean :cta_enabled
27
+
28
+ # Footer, on the reserved row.
29
+ t.string :discord_url
30
+ end
31
+ end
32
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.42.0"
2
+ VERSION = "0.43.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.42.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -285,6 +285,7 @@ files:
285
285
  - app/views/studio/board/_column.html.erb
286
286
  - app/views/studio/email_images/index.html.erb
287
287
  - app/views/studio/emails/_banner_editor.html.erb
288
+ - app/views/studio/emails/_banner_preview.html.erb
288
289
  - app/views/studio/emails/_banner_scale.html.erb
289
290
  - app/views/studio/emails/_recipient_picker.html.erb
290
291
  - app/views/studio/emails/_recipient_repaint.html.erb
@@ -354,6 +355,7 @@ files:
354
355
  - db/migrate/20260812000000_create_studio_email_settings.rb
355
356
  - db/migrate/20260812210000_add_copy_to_studio_email_settings.rb
356
357
  - db/migrate/20260812220000_add_subject_to_studio_email_settings.rb
358
+ - db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
357
359
  - lib/studio-engine.rb
358
360
  - lib/studio.rb
359
361
  - lib/studio/cable.rb