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 +4 -4
- data/app/assets/images/emails/magic-link-background.gif +0 -0
- data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
- data/app/controllers/studio/emails_controller.rb +18 -2
- data/app/mailers/studio/newsletter_mailer.rb +4 -0
- data/app/mailers/user_mailer.rb +7 -0
- data/app/models/studio/email_setting.rb +76 -1
- data/app/services/studio/banner.rb +6 -1
- data/app/services/studio/email_catalog.rb +156 -17
- data/app/views/layouts/branded_mailer.html.erb +32 -0
- data/app/views/studio/emails/_banner_editor.html.erb +7 -0
- data/app/views/studio/emails/_banner_preview.html.erb +62 -0
- data/app/views/studio/emails/_row.html.erb +31 -113
- data/app/views/studio/emails/index.html.erb +5 -5
- data/app/views/studio/emails/show.html.erb +113 -12
- data/app/views/studio/newsletter_mailer/subscribed.html.erb +11 -4
- data/app/views/user_mailer/magic_link.html.erb +39 -11
- data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
- data/lib/studio/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60d9ce89189f6f027c2158888a403269310397f20cb86cddc88519e9ec4644bb
|
|
4
|
+
data.tar.gz: cb5f1ee58d3825e6b3ee705f07f8ede2cd2ebed14e2d86f03ddf22c1debb4b20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d82567b9773871905899b6519699d95d5c05a73c5239914c3535d9a16198f6282c7d30309f07bb7961760d51a114ba031c4f20b83321ae1eb71f2ef7b7dc065
|
|
7
|
+
data.tar.gz: ae096e420cc8e25d8cb34013f6fc5eee1b41b5c102561b626bf67330b64e0115067ba0c237d32ab9b2c96f963181ae6c419f2fbf01f2f909504337980942cd9f
|
|
Binary file
|
|
Binary file
|
|
@@ -125,7 +125,22 @@ module Studio
|
|
|
125
125
|
# two places to press.
|
|
126
126
|
def copy
|
|
127
127
|
rescue_and_log do
|
|
128
|
-
|
|
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)
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
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
|
|
371
|
-
# default_asset sends that picture
|
|
372
|
-
# engine's
|
|
373
|
-
#
|
|
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)&.
|
|
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
|
|
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)&.
|
|
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
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
layered = Studio::EmailCatalog.background_url(entry.key).present?
|
|
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
|
-
<%#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
</
|
|
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:<%=
|
|
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
|
data/lib/studio/version.rb
CHANGED
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.
|
|
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
|