studio-engine 0.40.0 → 0.42.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 +116 -0
- data/Gemfile +6 -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 +136 -6
- data/app/mailers/studio/newsletter_mailer.rb +43 -0
- data/app/mailers/user_mailer.rb +48 -1
- data/app/models/studio/email_setting.rb +131 -0
- data/app/services/studio/banner.rb +186 -0
- data/app/services/studio/email_catalog.rb +248 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +23 -6
- data/app/views/studio/emails/_banner_editor.html.erb +192 -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 +68 -4
- data/app/views/studio/emails/index.html.erb +30 -4
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +364 -31
- 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 +33 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +13 -4
- 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/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- data/studio-engine.gemspec +5 -1
- metadata +20 -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 %>"
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
dirty() {
|
|
116
|
+
return Object.keys(this.initial).some(function (k) {
|
|
117
|
+
return (this.form[k] ?? "") !== (this.initial[k] ?? "");
|
|
118
|
+
}, this);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// The logo has THREE states, and a URL field alone can only express two.
|
|
122
|
+
// "standard" inherits, "custom" uses what this app uploaded, "hidden" is
|
|
123
|
+
// the deliberate no-logo answer that blank cannot say.
|
|
124
|
+
logoMode() {
|
|
125
|
+
if (this.form.hide_logo === true || this.form.hide_logo === "1") return "hidden";
|
|
126
|
+
if (config.uploadedLogo) return "custom";
|
|
127
|
+
return "standard";
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
setLogoMode(mode) {
|
|
131
|
+
this.form.hide_logo = mode === "hidden";
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
currentLogo() {
|
|
135
|
+
if (this.logoMode() === "hidden") return "";
|
|
136
|
+
return config.uploadedLogo || config.inheritedLogo || "";
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// The slider's own fill, as a percentage string for --range-fill.
|
|
140
|
+
scrimFill() {
|
|
141
|
+
var percent = parseInt(this.form.scrim_percent, 10);
|
|
142
|
+
if (isNaN(percent)) percent = config.defaultScrim;
|
|
143
|
+
return Math.min(Math.max(percent, 0), 100) + "%";
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
scrimCss() {
|
|
147
|
+
var percent = parseInt(this.form.scrim_percent, 10);
|
|
148
|
+
if (isNaN(percent)) percent = config.defaultScrim;
|
|
149
|
+
percent = Math.min(Math.max(percent, 0), 100);
|
|
150
|
+
return "rgba(24,16,64," + (percent / 100) + ")";
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
paint() {
|
|
154
|
+
var root = this.$root;
|
|
155
|
+
var header = root.querySelector("[data-banner-header]");
|
|
156
|
+
var subtext = root.querySelector("[data-banner-subtext]");
|
|
157
|
+
var logo = root.querySelector("[data-banner-logo]");
|
|
158
|
+
var scrim = root.querySelector("[data-banner-scrim]");
|
|
159
|
+
|
|
160
|
+
if (header) header.textContent = this.headerText();
|
|
161
|
+
if (subtext) subtext.textContent = this.form.subtext || "";
|
|
162
|
+
if (scrim) scrim.style.backgroundColor = this.scrimCss();
|
|
163
|
+
if (logo) {
|
|
164
|
+
var url = this.currentLogo();
|
|
165
|
+
if (url) {
|
|
166
|
+
if (logo.getAttribute("src") !== url) logo.setAttribute("src", url);
|
|
167
|
+
logo.style.display = "block";
|
|
168
|
+
} else {
|
|
169
|
+
logo.style.display = "none";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// Every visible field is x-model bound and carries no name; the hidden
|
|
175
|
+
// form below mirrors them. One submit writes the whole page.
|
|
176
|
+
save() {
|
|
177
|
+
if (!this.dirty()) return;
|
|
178
|
+
this.$refs.saveForm.requestSubmit();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
window.emailBannerEditor = editor;
|
|
184
|
+
if (typeof Alpine !== "undefined") {
|
|
185
|
+
Alpine.data("emailBannerEditor", editor);
|
|
186
|
+
} else {
|
|
187
|
+
document.addEventListener("alpine:init", function () {
|
|
188
|
+
Alpine.data("emailBannerEditor", editor);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
</script>
|
|
@@ -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>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<%# Repaints EVERY row on the emails list when the example recipient changes.
|
|
2
|
+
|
|
3
|
+
The list shows each email's banner and subject as they would arrive. Both are
|
|
4
|
+
per-recipient — the banner greets by first name and the subject may too — so
|
|
5
|
+
a list rendered for one person is a list of half-truths about everyone else.
|
|
6
|
+
Changing the selection has to repaint all of them.
|
|
7
|
+
|
|
8
|
+
WHY THE TEMPLATES RIDE ON THE ROW
|
|
9
|
+
Each row carries its own header / fallback / subject templates as data
|
|
10
|
+
attributes, and this reads them from the DOM. The alternative was a second
|
|
11
|
+
JSON payload listing every email, which would have to be kept in step with
|
|
12
|
+
the rows themselves — and a row whose payload entry went missing would
|
|
13
|
+
silently stop repainting while still looking correct for whoever was selected
|
|
14
|
+
at page load.
|
|
15
|
+
|
|
16
|
+
The placeholder rule is the same one Studio::Banner applies server-side, and
|
|
17
|
+
the same one the detail page's editor applies. It is small, and it is pinned
|
|
18
|
+
on both sides. If they disagree, the server is right. %>
|
|
19
|
+
<script>
|
|
20
|
+
(function () {
|
|
21
|
+
function recipients(config) {
|
|
22
|
+
return {
|
|
23
|
+
targets: config.targets || [],
|
|
24
|
+
targetId: config.targetId,
|
|
25
|
+
pickerOpen: false,
|
|
26
|
+
|
|
27
|
+
init() {
|
|
28
|
+
this.paint();
|
|
29
|
+
this.$watch("targetId", () => this.paint());
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
target() {
|
|
33
|
+
return this.targets.find((t) => t.id === this.targetId) || this.targets[0] || {};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
firstName() {
|
|
37
|
+
return (this.target().name || "").trim().split(/\s+/)[0] || "";
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
resolve(template, fallback) {
|
|
41
|
+
var first = this.firstName();
|
|
42
|
+
var app = config.appName || "";
|
|
43
|
+
var text = (template || "").replace(/\{app\}/g, app);
|
|
44
|
+
if (first) return text.replace(/\{name\}/g, first);
|
|
45
|
+
if (text.indexOf("{name}") !== -1) return (fallback || "").replace(/\{app\}/g, app);
|
|
46
|
+
return text;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
// A subject has no second field for the nameless case, so an unresolved
|
|
50
|
+
// placeholder is removed along with the punctuation holding it — the
|
|
51
|
+
// same two passes Studio::Banner.interpolate makes, so "Sign in, {name}"
|
|
52
|
+
// reads "Sign in" here exactly as it would in an inbox.
|
|
53
|
+
resolveSubject(template) {
|
|
54
|
+
var app = config.appName || "";
|
|
55
|
+
var text = (template || "").replace(/\{app\}/g, app);
|
|
56
|
+
var first = this.firstName();
|
|
57
|
+
if (first) return text.replace(/\{name\}/g, first);
|
|
58
|
+
return text.replace(/[,;:—-]?\s*\{name\}/g, "")
|
|
59
|
+
.replace(/^\s*[,;:—-]\s*/, "")
|
|
60
|
+
.replace(/\s+/g, " ")
|
|
61
|
+
.trim();
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// A row's banner lives in an IFRAME — the email's table is its own
|
|
65
|
+
// document so it cannot nest rows inside the list. Same-origin srcdoc,
|
|
66
|
+
// so its nodes are reachable; contentDocument is null until the frame
|
|
67
|
+
// has parsed, which is why the load listener is wired as well as the
|
|
68
|
+
// immediate attempt.
|
|
69
|
+
bannerNode(row, selector) {
|
|
70
|
+
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
71
|
+
if (frame) {
|
|
72
|
+
try { return frame.contentDocument && frame.contentDocument.querySelector(selector); }
|
|
73
|
+
catch (_) { return null; }
|
|
74
|
+
}
|
|
75
|
+
return row.querySelector(selector);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
paintRow(row) {
|
|
79
|
+
var header = this.bannerNode(row, "[data-banner-header]");
|
|
80
|
+
var subject = row.querySelector("[data-row-subject]");
|
|
81
|
+
if (header) {
|
|
82
|
+
header.textContent = this.resolve(row.dataset.header, row.dataset.headerFallback);
|
|
83
|
+
}
|
|
84
|
+
if (subject) subject.textContent = this.resolveSubject(row.dataset.subject);
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
paint() {
|
|
88
|
+
var self = this;
|
|
89
|
+
this.$root.querySelectorAll("[data-email-row]").forEach(function (row) {
|
|
90
|
+
self.paintRow(row);
|
|
91
|
+
var frame = row.querySelector("iframe[data-email-banner-preview]");
|
|
92
|
+
if (frame && !frame.dataset.repaintBound) {
|
|
93
|
+
frame.dataset.repaintBound = "1";
|
|
94
|
+
frame.addEventListener("load", function () { self.paintRow(row); });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Anything interactive keeps its own behaviour; everything else opens
|
|
100
|
+
// the email. Without the closest() guard, clicking Upload would both
|
|
101
|
+
// open the cropper and navigate away from it.
|
|
102
|
+
openRow(event, row) {
|
|
103
|
+
// The listener sits on the tbody, so a click can land between rows and
|
|
104
|
+
// arrive with no row at all.
|
|
105
|
+
if (!row) return;
|
|
106
|
+
if (event.target.closest("a, button, input, label, form")) return;
|
|
107
|
+
var path = row.dataset.emailPath;
|
|
108
|
+
if (path) window.location = path;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
window.emailRecipients = recipients;
|
|
114
|
+
if (typeof Alpine !== "undefined") {
|
|
115
|
+
Alpine.data("emailRecipients", recipients);
|
|
116
|
+
} else {
|
|
117
|
+
document.addEventListener("alpine:init", function () {
|
|
118
|
+
Alpine.data("emailRecipients", recipients);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
</script>
|