studio-engine 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +55 -1
- data/app/assets/images/emails/logo-horizontal.png +0 -0
- 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 +152 -6
- data/app/mailers/studio/newsletter_mailer.rb +47 -0
- data/app/mailers/user_mailer.rb +55 -1
- data/app/models/studio/email_setting.rb +206 -0
- data/app/services/studio/banner.rb +191 -0
- data/app/services/studio/email_catalog.rb +387 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +55 -6
- data/app/views/studio/emails/_banner_editor.html.erb +199 -0
- data/app/views/studio/emails/_banner_preview.html.erb +62 -0
- data/app/views/studio/emails/_banner_scale.html.erb +56 -0
- data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
- data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
- data/app/views/studio/emails/_row.html.erb +64 -82
- data/app/views/studio/emails/index.html.erb +34 -8
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +468 -34
- data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
- data/app/views/studio/modals/_image_upload.html.erb +55 -3
- data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +49 -12
- data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
- data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
- data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
- data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- metadata +22 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# WHO the email manager previews an email as.
|
|
3
|
+
#
|
|
4
|
+
# The banner's header is per-recipient ("Welcome Mason!"), so "what does this
|
|
5
|
+
# email look like" has no answer until you say who is receiving it. The page
|
|
6
|
+
# offers a small set of real people to stand in, and every variable on the page
|
|
7
|
+
# — first name, email — comes from the chosen one.
|
|
8
|
+
#
|
|
9
|
+
# ## Why real records rather than made-up samples
|
|
10
|
+
#
|
|
11
|
+
# A fabricated "Sample User <sample@example.com>" previews a person who cannot
|
|
12
|
+
# receive mail, and it hides the failures that actually happen: an account with
|
|
13
|
+
# no display name, a name that is one word, a name long enough to wrap the
|
|
14
|
+
# banner. Reading the host's own users puts those in front of the operator.
|
|
15
|
+
#
|
|
16
|
+
# ## Why an ADMIN and a MEMBER
|
|
17
|
+
#
|
|
18
|
+
# They differ in the ways that break email. An admin is usually the operator
|
|
19
|
+
# themselves — a full name on file, an internal address. A member is whoever
|
|
20
|
+
# signed up: often no name at all, which is the case that renders "Welcome !"
|
|
21
|
+
# if the fallback header is wrong. Being able to flip between them is how that
|
|
22
|
+
# gets caught here rather than in someone's inbox.
|
|
23
|
+
#
|
|
24
|
+
# Nil-safe throughout. The host's User model may be absent, may not respond to
|
|
25
|
+
# `admin?`, may have no rows. The preview degrades to a synthetic stand-in
|
|
26
|
+
# rather than taking the manager down — this is a page for looking at pictures.
|
|
27
|
+
class EmailPreviewTarget
|
|
28
|
+
ATTRIBUTES = %i[id label name email admin avatar_url avatar_color].freeze
|
|
29
|
+
attr_reader(*ATTRIBUTES)
|
|
30
|
+
|
|
31
|
+
def initialize(id:, label:, name: nil, email: nil, admin: false,
|
|
32
|
+
avatar_url: nil, avatar_color: nil, initials: nil)
|
|
33
|
+
@id = id.to_s
|
|
34
|
+
@label = label
|
|
35
|
+
@name = name.presence
|
|
36
|
+
@email = email.presence
|
|
37
|
+
@admin = admin
|
|
38
|
+
@avatar_url = avatar_url.presence
|
|
39
|
+
@avatar_color = avatar_color.presence || DEFAULT_AVATAR_COLOR
|
|
40
|
+
@initials = initials.presence
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The HOST's own initials when it has them, so this circle matches the one in
|
|
44
|
+
# the navbar rather than being a second opinion about the same person.
|
|
45
|
+
# Falls back to the address when there is no name, because "no name on file"
|
|
46
|
+
# is the ordinary case for a member and an empty circle identifies nobody.
|
|
47
|
+
def initials
|
|
48
|
+
return @initials if @initials
|
|
49
|
+
|
|
50
|
+
source = name.presence || email.to_s.split("@").first.to_s
|
|
51
|
+
parts = source.split(/[\s._-]+/).reject(&:empty?)
|
|
52
|
+
return "?" if parts.empty?
|
|
53
|
+
|
|
54
|
+
(parts.length == 1 ? parts.first[0, 2] : parts.first(2).map { |part| part[0] }.join).upcase
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
DEFAULT_AVATAR_COLOR = "#6b7280".freeze
|
|
58
|
+
|
|
59
|
+
def admin? = !!@admin
|
|
60
|
+
def first_name = name.to_s.strip.split.first
|
|
61
|
+
def to_param = id
|
|
62
|
+
|
|
63
|
+
# A stand-in used only when the host has no matching user. Named as a sample
|
|
64
|
+
# so nobody mistakes the preview for a real account.
|
|
65
|
+
def sample? = id.start_with?("sample-")
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
# Every target the page can offer, admin first.
|
|
69
|
+
#
|
|
70
|
+
# The NAMELESS sample is always offered, even when a real member exists.
|
|
71
|
+
# It is the only way to see what the name-free fallback header sends, and
|
|
72
|
+
# the accounts an app actually parks tend to be real people with real names
|
|
73
|
+
# — McRitchie Studio's member is Mack McRitchie. The alternative was
|
|
74
|
+
# stripping a real person's name to make a preview reachable, which trades
|
|
75
|
+
# a worse app for a better test.
|
|
76
|
+
def all
|
|
77
|
+
[find_admin, find_member, sample_member].compact.uniq(&:id)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The one to preview as. Falls back to the first available rather than
|
|
81
|
+
# erroring on a stale id from a bookmarked URL.
|
|
82
|
+
def resolve(id)
|
|
83
|
+
list = all
|
|
84
|
+
list.find { |target| target.id == id.to_s } || list.first
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def find_admin
|
|
90
|
+
record = first_user { |scope| admins(scope) }
|
|
91
|
+
return sample_admin if record.nil?
|
|
92
|
+
|
|
93
|
+
from_record(record, id_prefix: "admin", label: "Admin", admin: true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def find_member
|
|
97
|
+
record = first_user { |scope| members(scope) }
|
|
98
|
+
return nil if record.nil?
|
|
99
|
+
|
|
100
|
+
from_record(record, id_prefix: "member", label: "Member", admin: false)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The host's User model, or nil. `safe_constantize` rather than defined?()
|
|
104
|
+
# so an app without one simply has no records to offer.
|
|
105
|
+
def user_model
|
|
106
|
+
model = "User".safe_constantize
|
|
107
|
+
return nil unless model.respond_to?(:all) && model.respond_to?(:column_names)
|
|
108
|
+
|
|
109
|
+
model
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def first_user
|
|
113
|
+
model = user_model
|
|
114
|
+
return nil if model.nil?
|
|
115
|
+
|
|
116
|
+
yield(model)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
# A missing table, a mid-migration column, a host User that is not an
|
|
119
|
+
# ActiveRecord at all. None of them should cost more than the dropdown.
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# `admin` may be a column, a method, or absent entirely. Only the column
|
|
124
|
+
# can be queried; anything else is filtered in Ruby over a bounded slice,
|
|
125
|
+
# because a preview must not table-scan a production users table.
|
|
126
|
+
def admins(model)
|
|
127
|
+
return model.where(admin: true).first if model.column_names.include?("admin")
|
|
128
|
+
|
|
129
|
+
model.limit(50).detect { |user| user.try(:admin?) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def members(model)
|
|
133
|
+
return model.where(admin: [false, nil]).first if model.column_names.include?("admin")
|
|
134
|
+
|
|
135
|
+
model.limit(50).detect { |user| !user.try(:admin?) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def from_record(record, id_prefix:, label:, admin:)
|
|
139
|
+
new(id: "#{id_prefix}-#{record.id}", label: label, admin: admin,
|
|
140
|
+
name: display_name_for(record), email: record.try(:email),
|
|
141
|
+
avatar_url: avatar_url_for(record), avatar_color: record.try(:avatar_color),
|
|
142
|
+
initials: record.try(:avatar_initials))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ActiveStorage, defensively. A host may not attach avatars at all, the blob
|
|
146
|
+
# may be missing, and url helpers need a host that a background job does not
|
|
147
|
+
# have — none of which should cost more than a plain initials circle.
|
|
148
|
+
def avatar_url_for(record)
|
|
149
|
+
avatar = record.try(:avatar)
|
|
150
|
+
return nil unless avatar.respond_to?(:attached?) && avatar.attached?
|
|
151
|
+
|
|
152
|
+
Rails.application.routes.url_helpers.rails_blob_path(avatar, only_path: true)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# "Is there a REAL name on file", not "what should we call this person".
|
|
158
|
+
#
|
|
159
|
+
# display_name is asked LAST and only when the record has no name attribute
|
|
160
|
+
# at all. It is a presentation helper: McRitchie Studio's synthesises one
|
|
161
|
+
# from the email address, so member@… came back as "Member" and the
|
|
162
|
+
# nameless member — the entire reason that option exists — previewed as
|
|
163
|
+
# somebody with a name. A blank `name` is the honest nil.
|
|
164
|
+
def display_name_for(record)
|
|
165
|
+
return record.name.presence if record.respond_to?(:name)
|
|
166
|
+
return record.full_name.presence if record.respond_to?(:full_name)
|
|
167
|
+
|
|
168
|
+
record.try(:display_name).presence
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The app's own domain, read off the configured from-address rather than
|
|
172
|
+
# a new config knob — a sample address on some other domain would look
|
|
173
|
+
# like a real account somewhere else.
|
|
174
|
+
def sample_domain
|
|
175
|
+
Studio.mailer_from.to_s[/@([^\s>]+)/, 1].presence || "example.com"
|
|
176
|
+
rescue StandardError
|
|
177
|
+
"example.com"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def sample_admin
|
|
181
|
+
new(id: "sample-admin", label: "Admin (sample)", admin: true,
|
|
182
|
+
name: "Alex McRitchie", email: "alex@#{sample_domain}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Deliberately NAMELESS, and always present. This is the only recipient that
|
|
186
|
+
# exercises the name-free fallback header — the case a magic link hits every
|
|
187
|
+
# time it reaches someone with no account yet, and the one nobody thinks to
|
|
188
|
+
# check because whoever is previewing has a name on file.
|
|
189
|
+
def sample_member
|
|
190
|
+
new(id: "sample-member", label: "No name on file", admin: false,
|
|
191
|
+
name: nil, email: "someone@#{sample_domain}")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
<%#
|
|
2
|
-
Shared branded transactional email shell. A full-bleed banner
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
shares one branded look. An app can override by defining its own
|
|
2
|
+
Shared branded transactional email shell. A full-bleed banner sits flush at the
|
|
3
|
+
top and sets the 600px width; each email view supplies the body via yield.
|
|
4
|
+
Bannerless is fine — the card still renders. Lifted from turf-monster so every
|
|
5
|
+
Studio app shares one branded look. An app can override by defining its own
|
|
7
6
|
layouts/branded_mailer.html.erb.
|
|
7
|
+
|
|
8
|
+
TWO banner shapes, in priority order:
|
|
9
|
+
|
|
10
|
+
@banner — a Studio::Banner, built by Studio::Banner.for and drawn by the
|
|
11
|
+
studio/mailers/layered_banner partial. LAYERED: a
|
|
12
|
+
background image with the header, sub-text and logo as live HTML on top.
|
|
13
|
+
This is the shape that supports an ANIMATED background together with
|
|
14
|
+
per-recipient text, because the text is never drawn into the picture.
|
|
15
|
+
|
|
16
|
+
@banner_url — a plain image URL, rendered as an <img>. The original shape,
|
|
17
|
+
UNCHANGED: every mailer already shipping sets this and renders exactly as
|
|
18
|
+
before. A layered banner is opt-in, never a migration.
|
|
8
19
|
%>
|
|
9
20
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0;padding:24px 0;background:#f4f5f7;">
|
|
10
21
|
<tr>
|
|
11
22
|
<td align="center" style="padding:0 12px;">
|
|
12
23
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 1px 4px rgba(15,23,42,0.08);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
13
24
|
|
|
14
|
-
<% if @
|
|
25
|
+
<% if @banner.respond_to?(:renderable?) && @banner.renderable? %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td style="padding:0;line-height:0;">
|
|
28
|
+
<%= render "studio/mailers/layered_banner", banner: @banner %>
|
|
29
|
+
</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<% elsif @banner_url.present? %>
|
|
15
32
|
<tr>
|
|
16
33
|
<td style="padding:0;line-height:0;">
|
|
17
34
|
<img src="<%= @banner_url %>" width="600" alt="<%= @banner_alt || Studio.app_name %>"
|
|
@@ -25,6 +42,38 @@
|
|
|
25
42
|
<%= yield %>
|
|
26
43
|
</td>
|
|
27
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 %>
|
|
28
77
|
</table>
|
|
29
78
|
</td>
|
|
30
79
|
</tr>
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<%# The live banner editor: typing repaints the banner, and ONE Save button in
|
|
2
|
+
the header reports whether there is anything to save.
|
|
3
|
+
|
|
4
|
+
WHY THE PREVIEW IS PAINTED IN THE BROWSER AND NOT RE-RENDERED
|
|
5
|
+
The banner is already server-rendered by the mailer's own partial, which is
|
|
6
|
+
what makes it trustworthy. This does not replace it — it edits the text
|
|
7
|
+
nodes, the logo and the tint of that same rendered banner in place. The
|
|
8
|
+
shape, the typography and the scaling stay the server's. Re-rendering a
|
|
9
|
+
second banner in JavaScript would be a second implementation of the email,
|
|
10
|
+
and it would drift.
|
|
11
|
+
|
|
12
|
+
WHY THE PLACEHOLDER RULE IS REPEATED HERE
|
|
13
|
+
Studio::Banner fills {name} and {app} server-side. Repeating that in script
|
|
14
|
+
is a real duplication and worth naming: the alternative is a round trip per
|
|
15
|
+
keystroke. The rule is small and pinned on both sides — banner_copy_test.rb
|
|
16
|
+
for Ruby, e2e/email_banner_editor.spec.js for the browser. If they ever
|
|
17
|
+
disagree, the server is right.
|
|
18
|
+
|
|
19
|
+
TOUCHED VS DIRTY — they are different questions and the button answers both.
|
|
20
|
+
`touched` is "has anyone typed here", and it decides whether the button is
|
|
21
|
+
SHOWN. `dirty` is "is anything actually different from what is saved", and it
|
|
22
|
+
decides whether the button is ENABLED. Typing "A" into the subject and
|
|
23
|
+
deleting it again leaves touched true and dirty false: the button stays
|
|
24
|
+
visible and goes back to disabled, which is what tells the operator their
|
|
25
|
+
edit was undone rather than silently swallowed.
|
|
26
|
+
%>
|
|
27
|
+
<style>
|
|
28
|
+
/* The tint slider, in the app's own palette.
|
|
29
|
+
|
|
30
|
+
appearance:none and explicit track/thumb rules rather than `accent-color`:
|
|
31
|
+
accent-color tints a native slider but leaves its proportions to the
|
|
32
|
+
browser, so the control looked like a system widget dropped into the page —
|
|
33
|
+
a blue rail in a purple app. These rules give it the same rail, radius and
|
|
34
|
+
primary colour as everything else on the card.
|
|
35
|
+
|
|
36
|
+
The FILLED portion is a gradient driven by --range-fill, which the component
|
|
37
|
+
sets from the current value. Firefox has ::-moz-range-progress and WebKit
|
|
38
|
+
has nothing equivalent, so one gradient serves both rather than two
|
|
39
|
+
divergent implementations. */
|
|
40
|
+
.studio-range {
|
|
41
|
+
-webkit-appearance: none;
|
|
42
|
+
appearance: none;
|
|
43
|
+
width: 100%;
|
|
44
|
+
height: 6px;
|
|
45
|
+
border-radius: 999px;
|
|
46
|
+
background: linear-gradient(to right,
|
|
47
|
+
var(--color-primary, #8E82FE) var(--range-fill, 40%),
|
|
48
|
+
var(--color-inset, rgba(148, 163, 184, 0.35)) var(--range-fill, 40%));
|
|
49
|
+
outline: none;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
.studio-range::-webkit-slider-thumb {
|
|
53
|
+
-webkit-appearance: none;
|
|
54
|
+
appearance: none;
|
|
55
|
+
width: 16px;
|
|
56
|
+
height: 16px;
|
|
57
|
+
border-radius: 999px;
|
|
58
|
+
background: var(--color-primary, #8E82FE);
|
|
59
|
+
border: 2px solid var(--color-surface, #ffffff);
|
|
60
|
+
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
|
|
61
|
+
}
|
|
62
|
+
.studio-range::-moz-range-thumb {
|
|
63
|
+
width: 16px;
|
|
64
|
+
height: 16px;
|
|
65
|
+
border-radius: 999px;
|
|
66
|
+
background: var(--color-primary, #8E82FE);
|
|
67
|
+
border: 2px solid var(--color-surface, #ffffff);
|
|
68
|
+
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.35);
|
|
69
|
+
}
|
|
70
|
+
.studio-range::-moz-range-track { height: 6px; border-radius: 999px; background: transparent; }
|
|
71
|
+
.studio-range:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary, #8E82FE) 35%, transparent); }
|
|
72
|
+
</style>
|
|
73
|
+
<script>
|
|
74
|
+
(function () {
|
|
75
|
+
function editor(config) {
|
|
76
|
+
return {
|
|
77
|
+
form: Object.assign({}, config.values),
|
|
78
|
+
initial: Object.assign({}, config.values),
|
|
79
|
+
targets: config.targets || [],
|
|
80
|
+
targetId: config.targetId,
|
|
81
|
+
touched: false,
|
|
82
|
+
pickerOpen: false,
|
|
83
|
+
|
|
84
|
+
init() {
|
|
85
|
+
this.paint();
|
|
86
|
+
this.$watch("form", () => { this.touched = true; this.paint(); });
|
|
87
|
+
this.$watch("targetId", () => this.paint());
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
target() {
|
|
91
|
+
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
firstName() {
|
|
95
|
+
return (this.target().name || "").trim().split(/\s+/)[0] || "";
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// {app} is substituted on both paths because it does not depend on the
|
|
99
|
+
// recipient — a header reading "Welcome to {app}" must not show a raw
|
|
100
|
+
// brace here while the inbox shows the app's name.
|
|
101
|
+
resolve(template, fallback) {
|
|
102
|
+
var first = this.firstName();
|
|
103
|
+
var app = config.appName || "";
|
|
104
|
+
var text = (template || "").replace(/\{app\}/g, app);
|
|
105
|
+
if (first) return text.replace(/\{name\}/g, first);
|
|
106
|
+
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
|
|
107
|
+
return text;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
headerText() { return this.resolve(this.form.header, this.form.header_fallback); },
|
|
111
|
+
subjectText() { return this.resolve(this.form.subject, this.form.subject); },
|
|
112
|
+
|
|
113
|
+
// Field by field rather than JSON.stringify, whose output depends on key
|
|
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.
|
|
122
|
+
dirty() {
|
|
123
|
+
return Object.keys(this.initial).some(function (k) {
|
|
124
|
+
return (this.form[k] ?? "") !== (this.initial[k] ?? "");
|
|
125
|
+
}, this);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// The logo has THREE states, and a URL field alone can only express two.
|
|
129
|
+
// "standard" inherits, "custom" uses what this app uploaded, "hidden" is
|
|
130
|
+
// the deliberate no-logo answer that blank cannot say.
|
|
131
|
+
logoMode() {
|
|
132
|
+
if (this.form.hide_logo === true || this.form.hide_logo === "1") return "hidden";
|
|
133
|
+
if (config.uploadedLogo) return "custom";
|
|
134
|
+
return "standard";
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
setLogoMode(mode) {
|
|
138
|
+
this.form.hide_logo = mode === "hidden";
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
currentLogo() {
|
|
142
|
+
if (this.logoMode() === "hidden") return "";
|
|
143
|
+
return config.uploadedLogo || config.inheritedLogo || "";
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// The slider's own fill, as a percentage string for --range-fill.
|
|
147
|
+
scrimFill() {
|
|
148
|
+
var percent = parseInt(this.form.scrim_percent, 10);
|
|
149
|
+
if (isNaN(percent)) percent = config.defaultScrim;
|
|
150
|
+
return Math.min(Math.max(percent, 0), 100) + "%";
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
scrimCss() {
|
|
154
|
+
var percent = parseInt(this.form.scrim_percent, 10);
|
|
155
|
+
if (isNaN(percent)) percent = config.defaultScrim;
|
|
156
|
+
percent = Math.min(Math.max(percent, 0), 100);
|
|
157
|
+
return "rgba(24,16,64," + (percent / 100) + ")";
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
paint() {
|
|
161
|
+
var root = this.$root;
|
|
162
|
+
var header = root.querySelector("[data-banner-header]");
|
|
163
|
+
var subtext = root.querySelector("[data-banner-subtext]");
|
|
164
|
+
var logo = root.querySelector("[data-banner-logo]");
|
|
165
|
+
var scrim = root.querySelector("[data-banner-scrim]");
|
|
166
|
+
|
|
167
|
+
if (header) header.textContent = this.headerText();
|
|
168
|
+
if (subtext) subtext.textContent = this.form.subtext || "";
|
|
169
|
+
if (scrim) scrim.style.backgroundColor = this.scrimCss();
|
|
170
|
+
if (logo) {
|
|
171
|
+
var url = this.currentLogo();
|
|
172
|
+
if (url) {
|
|
173
|
+
if (logo.getAttribute("src") !== url) logo.setAttribute("src", url);
|
|
174
|
+
logo.style.display = "block";
|
|
175
|
+
} else {
|
|
176
|
+
logo.style.display = "none";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
// Every visible field is x-model bound and carries no name; the hidden
|
|
182
|
+
// form below mirrors them. One submit writes the whole page.
|
|
183
|
+
save() {
|
|
184
|
+
if (!this.dirty()) return;
|
|
185
|
+
this.$refs.saveForm.requestSubmit();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
window.emailBannerEditor = editor;
|
|
191
|
+
if (typeof Alpine !== "undefined") {
|
|
192
|
+
Alpine.data("emailBannerEditor", editor);
|
|
193
|
+
} else {
|
|
194
|
+
document.addEventListener("alpine:init", function () {
|
|
195
|
+
Alpine.data("emailBannerEditor", editor);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
})();
|
|
199
|
+
</script>
|
|
@@ -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>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<%# Scales the 600px email banner down to whatever width its frame has.
|
|
2
|
+
|
|
3
|
+
Why this needs script at all: the banner is the EMAIL's own markup, a fixed
|
|
4
|
+
600px table, and it must stay that way — an inbox has no responsive layout,
|
|
5
|
+
so making it fluid here would mean previewing markup no one receives. The
|
|
6
|
+
frame beside it is fluid. Nothing in CSS converts a fixed-width box into a
|
|
7
|
+
fluid one without knowing the container's width, so the width is read and a
|
|
8
|
+
transform applied.
|
|
9
|
+
|
|
10
|
+
What it does NOT do: move anything. It sets a scale on a box whose height is
|
|
11
|
+
already reserved by `aspect-ratio`, so no reflow follows and there is no
|
|
12
|
+
paint-time jump — the failure mode of measuring layout in script.
|
|
13
|
+
|
|
14
|
+
Degrades to an unscaled, clipped preview if script never runs. That is the
|
|
15
|
+
behaviour this replaced, so a failure here costs the improvement, not the page.
|
|
16
|
+
%>
|
|
17
|
+
<script>
|
|
18
|
+
(function () {
|
|
19
|
+
function fit(preview) {
|
|
20
|
+
var frame = preview.parentElement;
|
|
21
|
+
if (!frame) return;
|
|
22
|
+
var natural = parseFloat(preview.dataset.bannerWidth) || 600;
|
|
23
|
+
var available = frame.clientWidth;
|
|
24
|
+
if (!available) return;
|
|
25
|
+
// Never scale UP past the real size: at 1:1 this preview is exactly the
|
|
26
|
+
// 600px the recipient sees, and blowing it past that would show the
|
|
27
|
+
// operator a banner larger than any inbox renders.
|
|
28
|
+
var scale = Math.min(available / natural, 1);
|
|
29
|
+
preview.style.transform = "scale(" + scale + ")";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fitAll() {
|
|
33
|
+
document.querySelectorAll("[data-email-banner-preview]").forEach(fit);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ResizeObserver rather than a resize listener: the frame changes width when
|
|
37
|
+
// a sidebar opens or the grid reflows, neither of which resizes the window.
|
|
38
|
+
var observer = typeof ResizeObserver === "function" ? new ResizeObserver(fitAll) : null;
|
|
39
|
+
|
|
40
|
+
function start() {
|
|
41
|
+
fitAll();
|
|
42
|
+
if (!observer) return;
|
|
43
|
+
observer.disconnect();
|
|
44
|
+
document.querySelectorAll("[data-email-banner-frame]").forEach(function (frame) {
|
|
45
|
+
observer.observe(frame);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// turbo:load as well as DOMContentLoaded — a Turbo visit swaps the body
|
|
50
|
+
// without firing DOMContentLoaded, which would leave the preview at its
|
|
51
|
+
// unscaled 600px until a full reload.
|
|
52
|
+
document.addEventListener("DOMContentLoaded", start);
|
|
53
|
+
document.addEventListener("turbo:load", start);
|
|
54
|
+
if (document.readyState !== "loading") start();
|
|
55
|
+
})();
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<%# locals: (targets:, target:) — the "preview as" control, shared by the emails
|
|
2
|
+
list and one email's own page.
|
|
3
|
+
|
|
4
|
+
Extracted rather than copied. The list repaints EVERY row's banner and
|
|
5
|
+
subject from this one selection; the detail page repaints one. Two copies of
|
|
6
|
+
an avatar listbox would be two places for the fallback circle to rot, and the
|
|
7
|
+
fallback is the branch that renders for a member with no avatar — the common
|
|
8
|
+
case.
|
|
9
|
+
|
|
10
|
+
A custom listbox rather than a select, because a native option cannot carry
|
|
11
|
+
an image, and the avatar is the whole point: it is how you tell at a glance
|
|
12
|
+
which account you are looking at. %>
|
|
13
|
+
<div class="relative" @keydown.escape="pickerOpen = false" @click.outside="pickerOpen = false">
|
|
14
|
+
<button type="button" @click="pickerOpen = !pickerOpen" data-preview-target
|
|
15
|
+
class="input-field w-full py-2 flex items-center gap-3 text-left"
|
|
16
|
+
:aria-expanded="pickerOpen" aria-haspopup="listbox">
|
|
17
|
+
<template x-if="target().avatar_url">
|
|
18
|
+
<img :src="target().avatar_url" alt="" data-target-avatar
|
|
19
|
+
class="w-8 h-8 rounded-full object-cover shrink-0">
|
|
20
|
+
</template>
|
|
21
|
+
<template x-if="!target().avatar_url">
|
|
22
|
+
<span data-target-initials
|
|
23
|
+
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
|
|
24
|
+
:style="'background-color: ' + (target().avatar_color || '#6b7280')"
|
|
25
|
+
x-text="target().initials"></span>
|
|
26
|
+
</template>
|
|
27
|
+
<span class="min-w-0">
|
|
28
|
+
<span class="block text-heading truncate" x-text="target().name || target().label"></span>
|
|
29
|
+
<span class="block text-2xs text-muted truncate" x-text="target().email"></span>
|
|
30
|
+
</span>
|
|
31
|
+
<svg class="w-4 h-4 text-muted ml-auto shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
32
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
33
|
+
</svg>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
36
|
+
<ul x-show="pickerOpen" x-cloak role="listbox"
|
|
37
|
+
class="absolute z-20 mt-1 w-full rounded-lg border border-subtle bg-surface shadow-lg overflow-hidden">
|
|
38
|
+
<template x-for="option in targets" :key="option.id">
|
|
39
|
+
<li>
|
|
40
|
+
<button type="button" role="option" data-preview-option :data-option-id="option.id"
|
|
41
|
+
@click="targetId = option.id; pickerOpen = false"
|
|
42
|
+
:aria-selected="option.id === targetId"
|
|
43
|
+
class="w-full px-3 py-2 flex items-center gap-3 text-left hover:bg-inset"
|
|
44
|
+
:class="option.id === targetId ? 'bg-inset' : ''">
|
|
45
|
+
<template x-if="option.avatar_url">
|
|
46
|
+
<img :src="option.avatar_url" alt="" data-option-avatar
|
|
47
|
+
class="w-8 h-8 rounded-full object-cover shrink-0">
|
|
48
|
+
</template>
|
|
49
|
+
<template x-if="!option.avatar_url">
|
|
50
|
+
<span data-option-initials
|
|
51
|
+
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0"
|
|
52
|
+
:style="'background-color: ' + (option.avatar_color || '#6b7280')"
|
|
53
|
+
x-text="option.initials"></span>
|
|
54
|
+
</template>
|
|
55
|
+
<span class="min-w-0">
|
|
56
|
+
<span class="block text-heading truncate" x-text="option.name || option.label"></span>
|
|
57
|
+
<span class="block text-2xs text-muted truncate" x-text="option.email"></span>
|
|
58
|
+
</span>
|
|
59
|
+
</button>
|
|
60
|
+
</li>
|
|
61
|
+
</template>
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|