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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +55 -1
  4. data/app/assets/images/emails/logo-horizontal.png +0 -0
  5. data/app/assets/images/emails/magic-link-background.gif +0 -0
  6. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  7. data/app/controllers/studio/emails_controller.rb +152 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +47 -0
  9. data/app/mailers/user_mailer.rb +55 -1
  10. data/app/models/studio/email_setting.rb +206 -0
  11. data/app/services/studio/banner.rb +191 -0
  12. data/app/services/studio/email_catalog.rb +387 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +55 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +199 -0
  16. data/app/views/studio/emails/_banner_preview.html.erb +62 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +64 -82
  21. data/app/views/studio/emails/index.html.erb +34 -8
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +468 -34
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +49 -12
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +12 -0
  35. metadata +22 -2
@@ -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>
@@ -1,4 +1,4 @@
1
- <%# locals: (entry:, uploads_available:, max_width:)
1
+ <%# locals: (entry:, uploads_available:, max_width:, preview_name: nil)
2
2
  One registered email: its live banner, its name, and where that banner comes
3
3
  from. The whole row is the imageUploadHost x-data so the thumbnail, the Edit
4
4
  button, and the hidden multipart form the cropper submits all sit together —
@@ -8,61 +8,76 @@
8
8
  source = Studio::EmailCatalog.source(entry.key)
9
9
  banner_url = Studio::EmailCatalog.preview_url(entry.key)
10
10
  form_id = "email-banner-form-#{entry.key.dasherize}"
11
+ card_width = Studio::Banner::DEFAULT_WIDTH
11
12
 
12
- # `badge` is a shape-only utility in engine.css the state color comes from
13
- # theme-token utilities so it follows each app's palette in light and dark.
13
+ # The banner AS IT ARRIVES, not the bare artwork. Same partial the mailer
14
+ # renders, in preview mode so the text nodes carry handles the row repaints
15
+ # when the example recipient changes.
16
+ # Defaulted rather than required: the row is rendered by tests and by any host
17
+ # that has not passed a recipient, and "nobody selected" is a real state — it
18
+ # renders the name-free header, which is what a stranger receives.
19
+ name = local_assigns.fetch(:preview_name, nil)
20
+ # THE BANNER AS IT ARRIVES — for a layered email, artwork with live text on it.
14
21
  #
15
- # Four states, because three of them used to read as one. An app that ships
16
- # its own committed artwork was told that artwork "ships with the engine",
17
- # which is the wrong answer to the only question this column exists to answer.
18
- badge_class, badge_label, badge_note =
19
- case source
20
- when :app
21
- ["badge bg-success/10 text-success border-success/30", "Uploaded here",
22
- "Stored for #{Studio.app_name} — this upload is what ships."]
23
- when :app_asset
24
- ["badge bg-success/10 text-success border-success/30", "#{Studio.app_name}'s artwork",
25
- "Committed in this app's own assets. Upload to replace it."]
26
- when :engine_default
27
- ["badge bg-inset text-muted border-subtle", "Studio default",
28
- "Shared artwork that ships with the engine. Upload to make it #{Studio.app_name}'s."]
29
- else
30
- ["badge bg-warning/10 text-warning border-warning/30", "No image",
31
- "This email sends without a banner."]
32
- end
22
+ # Two conditions, and both were learned the hard way.
23
+ #
24
+ # It renders in an IFRAME. The banner is the email's own <table>, and putting
25
+ # one directly in a list row nests rows inside rows: every host asserting "one
26
+ # row per email" counted three per layered email, and two consumer suites went
27
+ # red. An iframe is a separate document, so the list's markup contract is
28
+ # untouched the same reason the email preview lower down the detail page is
29
+ # one.
30
+ #
31
+ # And it only layers when THIS APP's artwork is layered — a question
32
+ # background_url now answers by itself (it returns nil unless entry.layered?).
33
+ # The row used to carry a SECOND copy of that guard; when the guard moved down
34
+ # and grew the host-registered-background case, the stale copy started
35
+ # disagreeing with the mailer. One guard, one place, so they cannot drift.
36
+ layered = Studio::EmailCatalog.background_url(entry.key).present?
37
+ banner = layered ? Studio::Banner.for(entry.key, name: name) : nil
38
+ subject = Studio::EmailCatalog.subject_for(entry.key, name: name)
39
+
40
+ # The TEMPLATES ride on the row so the repaint script can re-resolve them for
41
+ # whoever is selected, without a second payload to keep in step.
42
+ header_template = Studio::EmailCatalog.header_template(entry.key)
43
+ header_fallback = Studio::EmailCatalog.header_fallback(entry.key)
44
+ subject_template = Studio::EmailSetting.copy_for(entry.key, :subject) || entry.subject
45
+
33
46
  %>
34
- <%# The uploader host is attached ONLY when this app can actually store an
35
- override. On a read-only app the cropper assets are not loaded either, so an
36
- unconditional x-data would reference an undefined factory and Alpine would
37
- throw on every row. %>
38
- <tr class="border-b border-subtle last:border-0"
39
- <% if uploads_available %>
40
- x-data="imageUploadHost({
41
- store: 'emailModals',
42
- aspectRatio: <%= entry.ratio %>,
43
- maxWidth: <%= max_width %>,
44
- transparent: false,
45
- filename: '<%= entry.key %>.png',
46
- saving: 'Saving banner…',
47
- success: 'Banner updated',
48
- successMessage: '<%= j entry.label %> now uses this app\'s own image.',
49
- failure: 'Couldn\'t save the banner'
50
- })"
51
- @crop-photo-confirmed.window="onCropConfirmed($event.detail)"
52
- <% end %>>
47
+ <%# THE WHOLE ROW OPENS THE EMAIL, and now that is the only thing it does. The
48
+ provenance badge and the upload button lived here and both moved to the
49
+ email's own page one place where changes are made, rather than a row that
50
+ is half a control panel.
51
+
52
+ A click handler rather than a wrapping link: an anchor around a <tr> is
53
+ invalid HTML. The name stays a real <a> so it keeps middle-click and
54
+ open-in-new-tab. %>
55
+ <tr class="border-b border-subtle last:border-0 cursor-pointer hover:bg-inset/60" data-email-row
56
+ data-email-path="<%= admin_email_path(entry.key) %>"
57
+ data-header="<%= header_template %>"
58
+ data-header-fallback="<%= header_fallback %>"
59
+ data-subject="<%= subject_template %>"
60
+ >
53
61
 
54
62
  <td class="px-4 py-4">
55
- <div class="rounded-lg overflow-hidden border border-subtle w-40"
56
- style="aspect-ratio: <%= entry.ratio %>; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
57
- <% if banner_url %>
63
+ <% if banner %>
64
+ <%= render "studio/emails/banner_preview", banner: banner, ratio: entry.ratio, max_width: 224 %>
65
+ <% elsif banner_url %>
66
+ <div class="rounded-lg overflow-hidden border border-subtle"
67
+ style="aspect-ratio: <%= entry.ratio %>; max-width: 224px;">
58
68
  <%= image_tag banner_url, class: "w-full h-full object-cover",
59
69
  alt: "#{entry.label} email banner", loading: "lazy" %>
60
- <% else %>
61
- <div class="w-full h-full flex items-center justify-center text-2xs text-white/80 text-center px-3">
62
- No banner
63
- </div>
64
- <% end %>
65
- </div>
70
+ </div>
71
+ <% else %>
72
+ <div class="rounded-lg overflow-hidden border border-subtle flex items-center justify-center text-2xs text-white/80 text-center px-3"
73
+ style="aspect-ratio: <%= entry.ratio %>; max-width: 224px; background: linear-gradient(135deg, var(--color-primary-700), var(--color-primary-900));">
74
+ No banner
75
+ </div>
76
+ <% end %>
77
+ </td>
78
+
79
+ <td class="px-4 py-4">
80
+ <p class="text-sm text-heading" data-row-subject><%= subject %></p>
66
81
  </td>
67
82
 
68
83
  <td class="px-4 py-4">
@@ -78,37 +93,4 @@
78
93
  <p class="font-mono text-2xs text-muted mt-1"><%= entry.key %></p>
79
94
  </td>
80
95
 
81
- <%# whitespace-nowrap keeps the pill a pill — "<App>'s own" is long enough to
82
- wrap inside a bordered badge, which reads as a broken box next to the
83
- short "Inherited default". %>
84
- <td class="px-4 py-4">
85
- <span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
86
- <p class="text-2xs text-muted mt-1.5 max-w-[12rem]"><%= badge_note %></p>
87
- </td>
88
-
89
- <% if uploads_available %>
90
- <td class="px-4 py-4 text-right whitespace-nowrap">
91
- <button type="button" @click="open()" class="btn btn-outline btn-sm inline-flex items-center gap-1.5">
92
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
94
- 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"/>
95
- </svg>
96
- <%= source == :app ? "Replace" : "Upload" %>
97
- </button>
98
-
99
- <%# Only an APP-OWNED image can be reverted — there is nothing to drop when
100
- the row is already showing the inherited default. %>
101
- <% if source == :app %>
102
- <%= button_to "Revert", admin_email_path(entry.key), method: :delete,
103
- class: "btn btn-neutral btn-sm mt-1.5 w-full",
104
- form: { data: { turbo_confirm: "Drop #{Studio.app_name}'s own #{entry.label} banner and go back to the shared default?" } } %>
105
- <% end %>
106
-
107
- <%# The cropper drops its Blob into this hidden input and submits. %>
108
- <%= form_with url: admin_email_path(entry.key), method: :patch,
109
- html: { multipart: true, id: form_id, "x-ref": "form" } do %>
110
- <%= file_field_tag :image, class: "hidden", "x-ref": "fileInput" %>
111
- <% end %>
112
- </td>
113
- <% end %>
114
96
  </tr>
@@ -24,10 +24,22 @@
24
24
  # Counts the app's OWN artwork either way — uploaded here or committed in this
25
25
  # app's assets. Counting only uploads made turf-monster, whose eight banners
26
26
  # all ship from its own repo, report "all inheriting the default artwork".
27
+ # The picker renders entirely from this payload — a field omitted here is a
28
+ # grey circle with no letter in it, which is exactly what shipped once.
29
+ recipients_config = {
30
+ targets: (@targets || []).map do |t|
31
+ { id: t.id, label: t.label, name: t.name, email: t.email, admin: t.admin?,
32
+ avatar_url: t.avatar_url, avatar_color: t.avatar_color, initials: t.initials }
33
+ end,
34
+ targetId: @target&.id,
35
+ appName: Studio.app_name.to_s
36
+ }
37
+
27
38
  app_artwork = @entries.count { |entry| Studio::EmailCatalog.app_artwork?(entry.key) }
28
39
  bannerless = @entries.count { |entry| Studio::EmailCatalog.source(entry.key) == :none }
29
40
  %>
30
- <div class="max-w-5xl mx-auto px-4 pb-16">
41
+ <div class="max-w-5xl mx-auto px-4 pb-16"
42
+ x-data="emailRecipients(<%= recipients_config.to_json %>)">
31
43
  <header class="space-y-2 pt-8 pb-6">
32
44
  <p class="label-upper">studio-engine &middot; v<%= Studio::VERSION %></p>
33
45
  <h1 class="text-3xl font-bold text-heading">Emails</h1>
@@ -74,30 +86,44 @@
74
86
  </div>
75
87
  <% end %>
76
88
 
89
+ <%# WHO the list is rendered for. Every banner greets by name and a subject may
90
+ too, so a list rendered for one person is a list of half-truths about
91
+ everyone else — the control belongs above the table, not on each row. %>
92
+ <% if (@targets || []).any? %>
93
+ <div class="mb-4 max-w-sm">
94
+ <p class="label-upper mb-2">Example</p>
95
+ <%= render "studio/emails/recipient_picker" %>
96
+ </div>
97
+ <% end %>
98
+
77
99
  <div class="card overflow-hidden">
78
100
  <div class="overflow-x-auto">
79
101
  <table class="w-full text-left align-middle">
80
102
  <thead>
81
103
  <tr class="border-b border-subtle">
82
- <th class="label-upper px-4 py-3 w-48">Banner</th>
104
+ <%# Banner, then what an inbox shows: the subject line, then the
105
+ email itself. Provenance and the upload button used to sit here;
106
+ both belong on the email's own page, which is one click away and
107
+ is where every other change is made. %>
108
+ <th class="label-upper px-4 py-3 w-64">Banner</th>
109
+ <th class="label-upper px-4 py-3 w-64">Subject</th>
83
110
  <th class="label-upper px-4 py-3">Email</th>
84
- <th class="label-upper px-4 py-3 w-52">Image</th>
85
- <% if @uploads_available %>
86
- <th class="label-upper px-4 py-3 w-40 text-right">Actions</th>
87
- <% end %>
88
111
  </tr>
89
112
  </thead>
90
- <tbody>
113
+ <tbody @click="openRow($event, $event.target.closest('[data-email-row]'))">
91
114
  <% @entries.each do |entry| %>
92
115
  <%= render "studio/emails/row", entry: entry,
93
116
  uploads_available: @uploads_available,
94
- max_width: max_width %>
117
+ max_width: max_width, preview_name: @preview_name %>
95
118
  <% end %>
96
119
  </tbody>
97
120
  </table>
98
121
  </div>
99
122
  </div>
100
123
 
124
+ <%= render "studio/emails/banner_scale" %>
125
+ <%= render "studio/emails/recipient_repaint" %>
126
+
101
127
  <p class="text-xs text-muted mt-4">
102
128
  Banners render full-bleed at 600px wide inside the email card. The crop is
103
129
  saved at up to <%= max_width %>px, which is retina-sharp in an inbox. Each
@@ -0,0 +1,45 @@
1
+ <%# admin/emails/:key for an ORPHAN — an email this app uploaded artwork for,
2
+ which has since left the registry.
3
+
4
+ It exists so the operator has a way out. Without the page, that upload is a
5
+ live ImageCache row and a paid-for S3 object with no revert button anywhere,
6
+ because the only control that could remove it lived on a page that 404s the
7
+ moment the key is unregistered.
8
+
9
+ Deliberately minimal: there is no banner to preview, no copy to edit and no
10
+ recipient to render as. The one honest affordance is "drop it". %>
11
+ <% content_for(:title) { "Unregistered email" } %>
12
+ <div class="max-w-5xl mx-auto px-4 pb-16">
13
+ <header class="pt-8 pb-5">
14
+ <%= link_to "← All emails", admin_emails_path,
15
+ class: "text-sm text-muted hover:text-heading underline underline-offset-2" %>
16
+ <h1 class="text-3xl font-bold text-heading mt-2">Unregistered email</h1>
17
+ <div class="flex flex-wrap items-center gap-2 mt-3">
18
+ <span class="badge bg-warning/10 text-warning border-warning/30 whitespace-nowrap">Not registered</span>
19
+ <span class="font-mono text-2xs text-muted"><%= @key %></span>
20
+ </div>
21
+ </header>
22
+
23
+ <section class="card p-4 mb-6">
24
+ <p class="text-body max-w-2xl">
25
+ <%= Studio.app_name %> uploaded artwork for <code class="font-mono text-2xs bg-inset px-1.5 py-0.5 rounded"><%= @key %></code>,
26
+ but no email is registered under that key any more — so nothing sends it. The
27
+ image is still stored, and this page is here so you can drop it.
28
+ </p>
29
+
30
+ <% if (record = Studio::EmailCatalog.record(@key)) %>
31
+ <div class="rounded-lg overflow-hidden border border-subtle w-56 mt-4">
32
+ <%= image_tag record.url, class: "w-full h-auto", alt: "Stored artwork for #{@key}" %>
33
+ </div>
34
+ <p class="font-mono text-2xs text-muted mt-2 break-all"><%= record.s3_key %></p>
35
+ <% end %>
36
+
37
+ <% if @uploads_available %>
38
+ <div class="mt-4">
39
+ <%= button_to "Drop this image", admin_email_path(@key), method: :delete,
40
+ class: "btn btn-outline btn-sm",
41
+ form: { data: { turbo_confirm: "Delete the stored image for #{@key}? Nothing sends it." } } %>
42
+ </div>
43
+ <% end %>
44
+ </section>
45
+ </div>