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