studio-engine 0.52.3 → 0.53.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1964a19e1e50b6a7ed315212d926a3c02dc63559927a7976a454215b655dc11
4
- data.tar.gz: a6fba155e470f8b15ef0b9125920397a51f07a98d34b3f3f4ad5b238a1308d92
3
+ metadata.gz: 5ce8edffaa3298d94b27113b1add2064ed036620a6c8372e84e947cfbeefb895
4
+ data.tar.gz: d72221add8e7af3e036cb616779bf59fb9428573b478ecda56ebd804300387f5
5
5
  SHA512:
6
- metadata.gz: 46f6bbc8dbe8350a4b6697a2af4fd25dc13e22d9f886e8912f07f27adbf605adb708a70e9ac56778819efad2fff619485dbdc5b1f9fa1905226a3c2c47e29b2d
7
- data.tar.gz: d4d7d64b08d2f60871d3828ad5f261aa9cba51d1e79e12024677bd84c22b2f3de2dee4195a17501082db8c68f65bdd0c0bc39d3db88dbab7b585b62dcbe74f94
6
+ metadata.gz: f72e5c3f5c5e6c0f03deca8238fff54a8da590b384c0987689becee01ca791c645178ff32d5597664ed5320e5a82408f91d5d6b4cb3b8219be602536f6cb2dd7
7
+ data.tar.gz: d28e41b0a6c6c1a2b58fbcfe66787e2be955e353e101ac567d78d628f3de865dfbf81aeb30b3cf1796809fe087cee5e5bb2ab77317510f4ff951da5bea038d64
data/CHANGELOG.md CHANGED
@@ -4,6 +4,56 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Added
8
+
9
+ - **`Studio.after_newsletter_change`** — a seam so a host can react to a
10
+ subscribe or unsubscribe without the engine knowing what it does.
11
+
12
+ ```ruby
13
+ Studio.after_newsletter_change = lambda do |user, subscribed:, first_join:|
14
+ GrantWelcomeSeeds.call(user) if subscribed && first_join
15
+ end
16
+ ```
17
+
18
+ **Why it exists, concretely:** turf-monster pays a 25-seed welcome bonus
19
+ on-chain, gated on `first_newsletter_join?` — literally
20
+ `joined_email_list_at.nil?`. The engine's subscribe action set that column and
21
+ told nobody, so a subscribe from `/profile` granted no seeds **and** made the
22
+ once-ever bonus unclaimable forever. Turf had to hold the newsletter row off
23
+ its profile page entirely; this is what lets it come back.
24
+
25
+ **`first_join:` is computed BEFORE the write**, because the write is what sets
26
+ the column — asked afterwards it is always false, and no host could ever pay a
27
+ welcome bonus.
28
+
29
+ **A raising callback cannot undo the subscription.** The subscription is the
30
+ durable fact and the reaction is not: turf's grant goes over RPC to a chain
31
+ that is sometimes unreachable, and a failed bonus must not cost someone their
32
+ place on the mailing list, or roll the write back inside `rescue_and_log`. It
33
+ is rescued and logged.
34
+
35
+ Fires on both verbs, so a host that only cares about joins ignores the flag.
36
+ The default is inert.
37
+
38
+
39
+ ### Fixed
40
+
41
+ - **The birthday calendar keeps the side it opened on.** `place()` runs on every
42
+ scroll and resize, and it also **chose** the side — so the popover snapped
43
+ between above and below the moment the available space crossed the threshold.
44
+ Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE, ABOVE, ABOVE, then
45
+ below — a **254px jump between two steps**, mid-scroll, while the reader was
46
+ looking at it.
47
+
48
+ The side is now decided once, by `chooseSide()`, when the popover opens.
49
+ `place()` only moves it.
50
+
51
+ This is a different bug from the height fix that shipped alongside it: that one
52
+ corrected **where** a flipped popover lands, and never asked whether the side
53
+ should be re-decided at all. The flip spec added with it opens and asserts
54
+ once, so a side that changes **later** was invisible to it.
55
+
56
+
7
57
  ### Fixed
8
58
 
9
59
  - **The birthday calendar measures itself instead of guessing, so a flipped
@@ -166,11 +166,17 @@ module Studio
166
166
  current_user.email = value
167
167
  end
168
168
 
169
+ # BEFORE THE WRITE, because the write is what sets the column — asking
170
+ # afterwards always answers false, and "is this their first ever join" is
171
+ # exactly the question a once-ever welcome bonus asks.
172
+ first_join = !Studio::Newsletter.ever_joined?(current_user)
173
+
169
174
  rescue_and_log(target: current_user) do
170
175
  # left_email_list_at is CLEARED rather than left in place. `subscribed?`
171
176
  # compares the two dates, so a stale leave date in the future of the join
172
177
  # would read as unsubscribed the moment the clock disagreed.
173
178
  current_user.update!(joined_email_list_at: Time.current, left_email_list_at: nil)
179
+ notify_newsletter_change(subscribed: true, first_join: first_join)
174
180
  redirect_to profile_path, notice: "You're on the list."
175
181
  end
176
182
  end
@@ -191,12 +197,34 @@ module Studio
191
197
 
192
198
  rescue_and_log(target: current_user) do
193
199
  current_user.update!(left_email_list_at: Time.current)
200
+ notify_newsletter_change(subscribed: false, first_join: false)
194
201
  redirect_to profile_path, notice: "You've been unsubscribed."
195
202
  end
196
203
  end
197
204
 
198
205
  private
199
206
 
207
+ # Tell the host, and NEVER let it break the subscription.
208
+ #
209
+ # The subscription is the durable fact; the reaction is not. turf-monster's
210
+ # callback grants seeds ON-CHAIN over RPC to a node that is sometimes
211
+ # unreachable, and a failed bonus must not cost someone their place on the
212
+ # mailing list — or, worse, roll the write back inside rescue_and_log and
213
+ # leave them looking unsubscribed.
214
+ #
215
+ # Rescued and logged rather than re-raised for that reason. The host's own
216
+ # controller already treats its grant as deferred and backfillable; this
217
+ # keeps that true when the same grant is reached from here.
218
+ def notify_newsletter_change(subscribed:, first_join:)
219
+ callback = Studio.after_newsletter_change
220
+ return unless callback.respond_to?(:call)
221
+
222
+ callback.call(current_user, subscribed: subscribed, first_join: first_join)
223
+ rescue StandardError => e
224
+ Rails.logger.warn("[studio][newsletter] after_newsletter_change raised for " \
225
+ "user=#{current_user&.id}: #{e.class}: #{e.message.to_s[0, 200]}")
226
+ end
227
+
200
228
  # Would the page render this row for this viewer?
201
229
  #
202
230
  # A HIDDEN ROW IS NOT A GUARD. Studio::ProfileSections drops a row this host
@@ -23,6 +23,9 @@
23
23
  window.studioBirthdayPicker = function (initial) {
24
24
  return {
25
25
  open: false,
26
+ // Which side this open chose. Set by chooseSide() when the popover opens and
27
+ // then left alone — see place() for why re-deciding it is the bug.
28
+ flipped: false,
26
29
  value: initial || "",
27
30
  viewYear: 0,
28
31
  viewMonth: 0,
@@ -123,9 +126,13 @@
123
126
 
124
127
  // Placed twice, deliberately. The first call stops the popover appearing
125
128
  // at 0,0 for a frame; the second is the one that can MEASURE, because
126
- // offsetHeight is 0 while x-show still has the element hidden.
129
+ // offsetHeight is 0 while x-show still has the element hidden — and it is
130
+ // also the only place the SIDE is chosen, for the life of this open.
127
131
  this.place();
128
- this.$nextTick(function () { this.place(); }.bind(this));
132
+ this.$nextTick(function () {
133
+ this.chooseSide();
134
+ this.place();
135
+ }.bind(this));
129
136
  },
130
137
 
131
138
  // Placed from the trigger's rect because the popover is fixed. Flips above
@@ -151,16 +158,37 @@
151
158
  var popover = this.$refs.popover;
152
159
  var height = (popover && popover.offsetHeight) || 340;
153
160
  var GAP = 4;
154
- var below = window.innerHeight - r.bottom;
155
161
 
156
162
  this.left = r.left;
157
163
  this.width = r.width;
158
164
 
159
- // Flip only when there is room ABOVE to flip into. Falling back to
160
- // below-placement when neither fits keeps the popover attached to its
161
- // field rather than pinned to the top of the viewport.
162
- var flip = below < height + GAP && r.top > height + GAP;
163
- this.top = flip ? r.top - height - GAP : r.bottom + GAP;
165
+ // THE SIDE IS NOT RE-DECIDED HERE. place() runs on every scroll and
166
+ // resize event, and choosing the side each time makes the popover SNAP
167
+ // between above and below the moment the available space crosses the
168
+ // threshold. Measured on turf-monster at 60px scroll steps: ABOVE, ABOVE,
169
+ // ABOVE, ABOVE, then below a 254px jump between two steps, mid-scroll,
170
+ // while the reader was looking at it. That is what "the date picker
171
+ // freaks out on scroll" was.
172
+ //
173
+ // chooseSide() runs ONCE per open; this only moves the popover.
174
+ this.top = this.flipped ? r.top - height - GAP : r.bottom + GAP;
175
+ },
176
+
177
+ // Which side to open on, decided once and then left alone. Flips above only
178
+ // when there is not room below AND there is room above — with neither, it
179
+ // stays below, attached to its field rather than pinned to the top of the
180
+ // viewport.
181
+ chooseSide() {
182
+ var el = this.$refs.trigger;
183
+ if (!el) return;
184
+
185
+ var r = el.getBoundingClientRect();
186
+ var popover = this.$refs.popover;
187
+ var height = (popover && popover.offsetHeight) || 340;
188
+ var GAP = 4;
189
+ var below = window.innerHeight - r.bottom;
190
+
191
+ this.flipped = below < height + GAP && r.top > height + GAP;
164
192
  }
165
193
  };
166
194
  };
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.52.3"
2
+ VERSION = "0.53.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -29,6 +29,29 @@ module Studio
29
29
  mattr_accessor :registration_params, default: [:name, :email, :password, :password_confirmation]
30
30
  mattr_accessor :configure_new_user, default: ->(user) {}
31
31
  mattr_accessor :configure_sso_user, default: ->(user) {}
32
+
33
+ # Called after a newsletter subscribe or unsubscribe on /profile, so a host can
34
+ # react without the engine knowing anything about what it does.
35
+ #
36
+ # THE REASON THIS EXISTS, stated concretely: turf-monster pays a 25-seed welcome
37
+ # bonus on-chain, gated on `first_newsletter_join?` — literally
38
+ # `joined_email_list_at.nil?`. The engine's subscribe action sets that column
39
+ # and told nobody, so a subscribe from /profile granted no seeds AND made the
40
+ # once-ever bonus unclaimable forever. Turf had to hold the row off its profile
41
+ # page entirely. This is what lets it come back.
42
+ #
43
+ # Studio.after_newsletter_change = lambda do |user, subscribed:, first_join:|
44
+ # GrantWelcomeSeeds.call(user) if subscribed && first_join
45
+ # end
46
+ #
47
+ # `first_join:` is computed BEFORE the write, because the write is what sets the
48
+ # column — asking afterwards always answers false.
49
+ #
50
+ # A raising callback CANNOT undo the subscription: the subscription is the
51
+ # durable fact and the reaction is not. Turf's grant goes over RPC to a chain
52
+ # that is sometimes unreachable, and a failed bonus must not cost someone their
53
+ # place on the mailing list.
54
+ mattr_accessor :after_newsletter_change, default: ->(_user, subscribed:, first_join:) {}
32
55
  mattr_accessor :sso_logo, default: nil
33
56
  mattr_accessor :wallet_address_method, default: nil
34
57
  mattr_accessor :theme_logos, default: []
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.52.3
4
+ version: 0.53.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie