studio-engine 0.51.0 → 0.52.1

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.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The newsletter subscription's rules. Pure Ruby and duck-typed, like
4
+ # Studio::OauthIdentity and Studio::ProfileImage — it loads without Rails, takes
5
+ # any object that answers the readers, and the unit suite exercises it without
6
+ # booting the dummy app.
7
+ #
8
+ # TWO TIMESTAMPS, NOT A BOOLEAN, and this is the decision the rest of the file
9
+ # follows from. `joined_email_list_at` and `left_email_list_at` are lifted from
10
+ # turf-monster, which has run this shape in production, and the pair carries
11
+ # three states a boolean cannot:
12
+ #
13
+ # never asked both nil — distinct from "unsubscribed"
14
+ # subscribed joined after left — including a REJOIN, where both are set
15
+ # unsubscribed left after joined — and the leave date survives the rejoin
16
+ #
17
+ # The third row is why turf keeps them: its 25-seed welcome bonus is once-ever,
18
+ # guarded on-chain, and "have they EVER joined" is a different question from "are
19
+ # they on the list today". A boolean answers neither. None of turf's seed
20
+ # machinery belongs in the engine — but the state shape does, because a consumer
21
+ # that adopts this and later wants that rule must not have to migrate away from a
22
+ # flag first.
23
+ module Studio
24
+ module Newsletter
25
+ module_function
26
+
27
+ # The columns a host must have for any of this to mean anything. The profile
28
+ # row declares the same pair in `requires:`, so a host without them silently
29
+ # gets no newsletter card rather than a NoMethodError on every page load.
30
+ COLUMNS = %i[joined_email_list_at left_email_list_at].freeze
31
+
32
+ def serves?(user)
33
+ return false if user.nil?
34
+
35
+ COLUMNS.all? { |column| user.respond_to?(column) }
36
+ end
37
+
38
+ # On the list right now.
39
+ #
40
+ # `joined > left` rather than `left.nil?`, because a REJOIN leaves both set
41
+ # and the later date wins. Reading only `left_email_list_at.nil?` would call
42
+ # every rejoined account unsubscribed forever.
43
+ def subscribed?(user)
44
+ return false unless serves?(user)
45
+
46
+ joined = user.joined_email_list_at
47
+ return false if joined.nil?
48
+
49
+ left = user.left_email_list_at
50
+ left.nil? || joined > left
51
+ end
52
+
53
+ # Has this account EVER been on the list? Distinct from `subscribed?`, and the
54
+ # question a once-ever welcome bonus asks. The engine does not pay bonuses; it
55
+ # answers the question so a consumer that does never has to reach past this
56
+ # module into the columns.
57
+ def ever_joined?(user)
58
+ serves?(user) && !user.joined_email_list_at.nil?
59
+ end
60
+
61
+ # A newsletter needs somewhere to send. An account with no address on file —
62
+ # a wallet-only sign-in, which turf-monster has plenty of — has to supply one
63
+ # before it can subscribe, so the UI asks rather than failing the submit.
64
+ def needs_email?(user)
65
+ return false unless serves?(user)
66
+
67
+ !user.respond_to?(:email) || user.email.to_s.strip.empty?
68
+ end
69
+ end
70
+ end
@@ -1,5 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # REQUIRED HERE, not left to lib/studio.rb's ordering. DEFAULTS names
4
+ # Studio::Newsletter::COLUMNS at LOAD time, and profile_sections is required
5
+ # BEFORE newsletter in that file — so without this line the constant is undefined
6
+ # and the engine fails to boot. Declaring the dependency where it is used makes
7
+ # the pair immune to a future reorder of that require list.
8
+ require_relative "newsletter"
9
+
3
10
  # Resolves Studio.profile_sections — the rows that make up the shared /profile
4
11
  # page — for a given view context. Pure Ruby (no Rails dependency) so the unit
5
12
  # suite exercises the resolution rules without booting the dummy app.
@@ -33,12 +40,19 @@
33
40
  # AND `auth_methods = %i[magic_link]` with no omniauth gem at all,
34
41
  # so it would have rendered a "Link Google Account" button leading
35
42
  # nowhere. Having a column is not the same as offering the feature.
36
- # modals — the row opens the shared crop/saving modals. The PAGE mounts them
37
- # once (studio/modals/_scoped_host on a "profileModals" store) when
38
- # any resolved row asks for them, so a row never has to know whether
39
- # the host app renders a modal host of its own. Two of the five
40
- # consumers render none at all, and the two that do ship a FORK of
41
- # the engine's shared host that would shadow it.
43
+ # modals — a partial of modal registrations this row needs, or nil. The PAGE
44
+ # mounts studio/modals/_scoped_host on a "profileModals" store when
45
+ # ANY resolved row declares one, and renders each declared partial
46
+ # inside it so a row never has to know whether the host app renders
47
+ # a modal host of its own. Two of the five consumers render none at
48
+ # all, and the two that do ship a FORK of the engine's shared host
49
+ # that would shadow it.
50
+ #
51
+ # A PATH, not a boolean, and not derived from `key`. A host row
52
+ # declaring modals keeps its partial in the host's own app, so any
53
+ # convention like "studio/profiles/#{key}_modals" would resolve to a
54
+ # path that does not exist there. Saying it outright costs one string
55
+ # and works for everyone.
42
56
  #
43
57
  # ON `requires` — this is the whole reason the registry exists rather than a
44
58
  # hardcoded page. The consuming apps do NOT agree on their users table:
@@ -73,6 +87,19 @@ module Studio
73
87
  partial: "studio/profiles/google_section", requires: %i[provider uid],
74
88
  if: -> { Studio.auth_method?(:google) } },
75
89
 
90
+ # The mailing list. READ-level for the same reason Google is: it is a state
91
+ # you look at and occasionally flip, not a field you type into.
92
+ #
93
+ # `modals: true` is what makes the page mount a host — leaving asks for
94
+ # confirmation, and an account with no address on file is asked for one.
95
+ # This is the FIRST row to declare the flag, which the registry has
96
+ # documented since it shipped; before this the read page mounted no host at
97
+ # all, correctly, because nothing asked.
98
+ { key: :newsletter, title: "Newsletter", page: :show,
99
+ partial: "studio/profiles/newsletter_section",
100
+ requires: Studio::Newsletter::COLUMNS,
101
+ modals: "studio/profiles/newsletter_modals" },
102
+
76
103
  # --- the edit page ------------------------------------------------------
77
104
  # These are FIELDS in one form with one Save, so they carry no buttons of
78
105
  # their own. Email included: it needed a separate action only while it was
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.51.0"
2
+ VERSION = "0.52.1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -10,6 +10,7 @@ require "studio/sidebar_sections"
10
10
  require "studio/profile_sections"
11
11
  require "studio/profile_image"
12
12
  require "studio/oauth_identity"
13
+ require "studio/newsletter"
13
14
  require "studio/username_generator"
14
15
  require "studio/s3"
15
16
  require "studio/image_cache"
@@ -690,6 +691,14 @@ module Studio
690
691
  # here: it is OmniAuth's own /auth/:provider, which the middleware owns.
691
692
  delete "profile/google", to: "studio/profiles#unlink_google",
692
693
  as: :profile_unlink_google
694
+
695
+ # POST joins, DELETE leaves — the verbs the two actions actually are, on
696
+ # one path. Not a PATCH on the profile: subscribing is its own decision
697
+ # with its own confirmation, and folding it into the bulk field save would
698
+ # mean every name change re-asserted a mailing-list preference.
699
+ post "profile/newsletter", to: "studio/profiles#subscribe_newsletter",
700
+ as: :profile_newsletter
701
+ delete "profile/newsletter", to: "studio/profiles#unsubscribe_newsletter"
693
702
  end
694
703
 
695
704
  resources :error_logs, only: [:index, :show]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.51.0
4
+ version: 0.52.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -175,6 +175,7 @@ files:
175
175
  - app/assets/images/emails/newsletter-subscribed-background.gif
176
176
  - app/assets/images/resend-favicon.png
177
177
  - app/assets/images/ses-favicon.png
178
+ - app/assets/javascripts/studio/alpine.js
178
179
  - app/assets/javascripts/studio/canvas_confetti.js
179
180
  - app/assets/javascripts/studio/sortable.js
180
181
  - app/assets/javascripts/studio/sticky_table_header.js
@@ -339,6 +340,7 @@ files:
339
340
  - app/views/studio/profile_mailer/email_change_notification.html.erb
340
341
  - app/views/studio/profile_mailer/email_change_notification.text.erb
341
342
  - app/views/studio/profiles/_birthday_fields.html.erb
343
+ - app/views/studio/profiles/_birthday_picker_script.html.erb
342
344
  - app/views/studio/profiles/_editable_identity.html.erb
343
345
  - app/views/studio/profiles/_email_fields.html.erb
344
346
  - app/views/studio/profiles/_form_script.html.erb
@@ -348,6 +350,8 @@ files:
348
350
  - app/views/studio/profiles/_identity_mini.html.erb
349
351
  - app/views/studio/profiles/_identity_styles.html.erb
350
352
  - app/views/studio/profiles/_name_fields.html.erb
353
+ - app/views/studio/profiles/_newsletter_modals.html.erb
354
+ - app/views/studio/profiles/_newsletter_section.html.erb
351
355
  - app/views/studio/profiles/_pencil_icon.html.erb
352
356
  - app/views/studio/profiles/_save_bar.html.erb
353
357
  - app/views/studio/profiles/edit.html.erb
@@ -392,6 +396,7 @@ files:
392
396
  - lib/studio/link_token.rb
393
397
  - lib/studio/log_rotation.rb
394
398
  - lib/studio/mail_transport.rb
399
+ - lib/studio/newsletter.rb
395
400
  - lib/studio/oauth_identity.rb
396
401
  - lib/studio/profile_image.rb
397
402
  - lib/studio/profile_sections.rb