studio-engine 0.45.0 → 0.46.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 +77 -0
- data/app/controllers/studio/onboarding_controller.rb +77 -0
- data/app/services/studio/email_preview_target.rb +0 -8
- data/app/views/studio/modals/onboarding/_first_name.html.erb +138 -0
- data/app/views/style/_modals.html.erb +45 -0
- data/db/migrate/20260813220000_add_standard_user_profile_columns.rb +51 -0
- data/lib/studio/ip_locations.rb +118 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +99 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 026c018bce499134d15e23ca2f9101e05da752380be210e5c6136babbac97a5a
|
|
4
|
+
data.tar.gz: ff33bee4cbe01664a9f2385716107d0a7319e0b267cc9a1f6c4a47e0555c4c5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25dedda1d9c8f5c7cc2f485cfcea80e94ce3446952029015d5e53ab1dd41fdbfd014dc1c1251f9c637ab7fbd3605b7aa50e0593e4d4e85b1a81c6349536dd7ea
|
|
7
|
+
data.tar.gz: 8f71ee45ddf77e12851991857aff2a72bfe3082719b7505c16b8fc7cd08d17c5bf502c07af2393aa315c22146067ef63af1d275d9566d327193d9fa935a94a72
|
data/CHANGELOG.md
CHANGED
|
@@ -35,6 +35,83 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
35
35
|
|
|
36
36
|
### Added
|
|
37
37
|
|
|
38
|
+
- **The standard user profile columns — and the engine's first migration against
|
|
39
|
+
a host-owned table.** Every other engine migration creates a `studio_*` table
|
|
40
|
+
the engine owns outright; `users` belongs to the host. That boundary is crossed
|
|
41
|
+
deliberately, on the operator's call, because the alternative was three
|
|
42
|
+
hand-written migrations kept identical by discipline alone — and column drift
|
|
43
|
+
across apps is the exact thing a standard prevents.
|
|
44
|
+
|
|
45
|
+
| Column | Type | Why |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `first_name` | string | The shared onboarding ask. MS and TM already had it; MI did not. |
|
|
48
|
+
| `birth_day` / `birth_month` / `birth_year` | integer | Age needs the year plus month/day for the boundary case; "whose birthday is today" needs month + day and no year at all. |
|
|
49
|
+
| `ip_locations` | jsonb, default `[]` | The distinct places an account has been seen from. |
|
|
50
|
+
|
|
51
|
+
Three integers rather than one date is the point: **no single date-of-birth
|
|
52
|
+
column is stored.** turf-monster's `AgePolicy` composes a `Date` from the trio.
|
|
53
|
+
turf already had `birth_year` as an integer, so it matches exactly.
|
|
54
|
+
|
|
55
|
+
**Every add is `if_not_exists: true`, and the migration no-ops when the app has
|
|
56
|
+
no `users` table.** Both matter: the apps disagree today (MS and TM already have
|
|
57
|
+
`first_name`, turf already has `birth_year`), and an unguarded add would mean
|
|
58
|
+
the engine breaking consumers that did nothing wrong. Migrations are
|
|
59
|
+
install-copied (`bin/rails studio_engine:install:migrations`), so each app still
|
|
60
|
+
adopts deliberately.
|
|
61
|
+
|
|
62
|
+
`Studio::IpLocations` owns the analytics shape so it cannot drift: string keys
|
|
63
|
+
and ISO-8601 times (it round-trips through jsonb), dedupe on the **place**
|
|
64
|
+
rather than the address — two sign-ins from Austin are one entry however many
|
|
65
|
+
IPs they arrive from — a 50-entry cap, and loopback/private ranges skipped so
|
|
66
|
+
dev sessions don't crowd out real ones. `Studio.record_ip_location!(user, ip:,
|
|
67
|
+
country:, region:, city:)` writes only when the place is **new**: this sits on
|
|
68
|
+
the request path, and refreshing a counter per hit would be a database write
|
|
69
|
+
per page view. The host resolves the location — turf already has Geocoder wired
|
|
70
|
+
in `ApplicationController#detect_geo_state` — and an app that has not run the
|
|
71
|
+
migration records nothing rather than raising.
|
|
72
|
+
|
|
73
|
+
`ip_locations` holds IP-derived location data, which is personal data in most
|
|
74
|
+
jurisdictions. The cap bounds it, and no app writes to it until it opts in.
|
|
75
|
+
|
|
76
|
+
- **First-name capture as a shared onboarding step.** Every Studio app addresses
|
|
77
|
+
people by name in email, and all three were going to write the same modal and
|
|
78
|
+
the same controller. Ported out of turf-monster, where it shipped first:
|
|
79
|
+
|
|
80
|
+
| Piece | What it is |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `studio/modals/onboarding/_first_name` | The step's UI. Every endpoint and label is a local with a default, so a host mounting it elsewhere does not fork the partial. Previewable at `/admin/style#modals`. |
|
|
83
|
+
| `Studio::OnboardingController` | The two writes — `#first_name` and `#skip_first_name`, JSON only. |
|
|
84
|
+
| `Studio.first_name_outstanding?(user, session)` | The one rule every app agrees on: blank field, not skipped this session. |
|
|
85
|
+
| `Studio.onboarding_steps_resolver` | How a host declares what comes NEXT. |
|
|
86
|
+
|
|
87
|
+
**The engine owns ONE STEP; the host owns the SEQUENCE.** turf walks
|
|
88
|
+
welcome → first name → age → wallet, which means nothing in a hub app. The
|
|
89
|
+
partial never opens another modal — it reports the remaining steps upward and
|
|
90
|
+
closes — and the controller's `next` array is whatever the host's resolver
|
|
91
|
+
returns. The default is empty, which is the right answer for an app whose only
|
|
92
|
+
ask is the name: opt in, and it works with no further configuration.
|
|
93
|
+
|
|
94
|
+
Skipping is **session-scoped on purpose**. It means "not now", not "never" —
|
|
95
|
+
the column stays blank, so a later session may ask again. That is the whole
|
|
96
|
+
reason it is not a users column.
|
|
97
|
+
|
|
98
|
+
**The endpoints are OPT-IN (`config.draw_onboarding_routes = true`), and they
|
|
99
|
+
have to be** — the same hard constraint as `/admin/emails`. turf-monster owns
|
|
100
|
+
`onboarding_first_name_path` and `onboarding_skip_first_name_path` TODAY, and
|
|
101
|
+
drawing them there raises `Invalid route name, already in use` while that app's
|
|
102
|
+
routes load, taking down its entire route set. Consumer CI runs each consumer's
|
|
103
|
+
default branch, so this cannot be fixed from inside the engine. Each app's
|
|
104
|
+
adoption task flips the flag as it deletes its local copy.
|
|
105
|
+
|
|
106
|
+
Two consumer notes:
|
|
107
|
+
|
|
108
|
+
- **turf-monster** deletes `OnboardingController`, its two routes, and points
|
|
109
|
+
its `OnboardingFlow` at `Studio.first_name_outstanding?`. Net deletion.
|
|
110
|
+
- **mcritchie-industries** has **no `users.first_name` column**. The rule
|
|
111
|
+
tolerates that — an app that has not run the migration is simply never asked,
|
|
112
|
+
rather than raising on every signed-in request — so the gem can land there
|
|
113
|
+
before the migration does.
|
|
114
|
+
|
|
38
115
|
- **`Studio::NewsletterMailer` — a sendable "you're on the list" email.**
|
|
39
116
|
Namespaced under `Studio::` on purpose: a host that defines its own top-level
|
|
40
117
|
`UserMailer` (McRitchie Studio does) SHADOWS the engine's outright, so an
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The first-name onboarding step's two writes — capture a name, or record that
|
|
5
|
+
# the account skipped it. Ported from turf-monster (2026-08), where this ran as
|
|
6
|
+
# a local OnboardingController, because the same ask is wanted in every Studio
|
|
7
|
+
# app: McRitchie Studio, McRitchie Industries, and turf-monster all address
|
|
8
|
+
# people by name in email, and all three were going to write this controller.
|
|
9
|
+
#
|
|
10
|
+
# What the ENGINE owns here is ONE STEP. What each HOST still owns is the
|
|
11
|
+
# SEQUENCE around it — turf walks welcome → first name → age → wallet; a hub app
|
|
12
|
+
# may ask nothing else. Hosts declare that through
|
|
13
|
+
# Studio.onboarding_steps_resolver, whose value this returns as `next` so the
|
|
14
|
+
# client keeps walking without a second round trip.
|
|
15
|
+
#
|
|
16
|
+
# A plain host-inherited controller (same shape as StyleController and
|
|
17
|
+
# Studio::EmailsController): it renders no layout of its own, answers JSON only,
|
|
18
|
+
# and picks up the host's authentication from ApplicationController.
|
|
19
|
+
#
|
|
20
|
+
# Routes are OPT-IN via Studio.draw_onboarding_routes — turf-monster owns these
|
|
21
|
+
# helper names until its adoption task deletes the local pair.
|
|
22
|
+
class OnboardingController < ::ApplicationController
|
|
23
|
+
# Both actions are for the freshly signed-in user, so authentication is the
|
|
24
|
+
# host's default require_authentication — no skip_before_action here.
|
|
25
|
+
|
|
26
|
+
MAX_FIRST_NAME = 40
|
|
27
|
+
|
|
28
|
+
# POST /onboarding/first_name
|
|
29
|
+
#
|
|
30
|
+
# Writes users.first_name, and backfills `name` when it is blank so the
|
|
31
|
+
# display-name chain has something better than an email prefix to show.
|
|
32
|
+
def first_name
|
|
33
|
+
value = params[:first_name].to_s.strip.gsub(/\s+/, " ").first(MAX_FIRST_NAME)
|
|
34
|
+
|
|
35
|
+
if value.blank?
|
|
36
|
+
return render json: { ok: false, error: "Enter your first name, or skip for now." },
|
|
37
|
+
status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rescue_and_log(target: current_user) do
|
|
41
|
+
# update_columns, not update!: this runs seconds after signup, on an
|
|
42
|
+
# account that may be mid-onboarding, and a validation failure elsewhere
|
|
43
|
+
# on the record (a grandfathered reserved username, say) must not block a
|
|
44
|
+
# first name. It also steps around any host before_save that DERIVES
|
|
45
|
+
# first_name FROM name — turf's set_name_parts does exactly that, and
|
|
46
|
+
# writing through it would discard the value we were just handed.
|
|
47
|
+
attrs = { first_name: value }
|
|
48
|
+
attrs[:name] = value if current_user.name.blank?
|
|
49
|
+
current_user.update_columns(attrs)
|
|
50
|
+
|
|
51
|
+
render json: { ok: true, first_name: value, next: remaining_steps }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# POST /onboarding/skip_first_name
|
|
56
|
+
#
|
|
57
|
+
# Session-scoped, deliberately: skipping means "not now", not "never". A later
|
|
58
|
+
# session can ask again (the field is still blank), which is the whole reason
|
|
59
|
+
# this is not a users column.
|
|
60
|
+
def skip_first_name
|
|
61
|
+
session[Studio::FIRST_NAME_SKIP_SESSION_KEY] = true
|
|
62
|
+
render json: { ok: true, next: remaining_steps }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# What is left AFTER this write. The host's resolver decides; the engine's
|
|
68
|
+
# default is "nothing further", which is the right answer for an app whose
|
|
69
|
+
# only onboarding ask is the name.
|
|
70
|
+
def remaining_steps
|
|
71
|
+
resolver = Studio.onboarding_steps_resolver
|
|
72
|
+
return [] unless resolver.respond_to?(:call)
|
|
73
|
+
|
|
74
|
+
Array(resolver.call(current_user, session)).map(&:to_s)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -211,14 +211,6 @@ module Studio
|
|
|
211
211
|
name: "Alex McRitchie", email: "alex@#{sample_domain}")
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
-
# Deliberately NAMELESS, and always present. This is the only recipient that
|
|
215
|
-
# exercises the name-free fallback header — the case a magic link hits every
|
|
216
|
-
# time it reaches someone with no account yet, and the one nobody thinks to
|
|
217
|
-
# check because whoever is previewing has a name on file.
|
|
218
|
-
def sample_member
|
|
219
|
-
new(id: "sample-member", label: "No name on file", admin: false,
|
|
220
|
-
name: nil, email: "someone@#{sample_domain}")
|
|
221
|
-
end
|
|
222
214
|
end
|
|
223
215
|
end
|
|
224
216
|
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
First-name capture — the shared onboarding step.
|
|
3
|
+
|
|
4
|
+
Asks a freshly signed-in account for a first name, once, and lets them skip.
|
|
5
|
+
Ported from turf-monster's post-auth onboarding chain (2026-08) because the
|
|
6
|
+
same beat is wanted in McRitchie Studio: the field exists in every Studio app
|
|
7
|
+
(`users.first_name`), and the ask is identical everywhere. What is NOT shared —
|
|
8
|
+
and deliberately stays in the host — is the ORDER of the chain around it: turf
|
|
9
|
+
walks welcome → first name → age → wallet, which means nothing in a hub app.
|
|
10
|
+
This partial owns ONE step; the host owns the sequence.
|
|
11
|
+
|
|
12
|
+
Locals (all optional, defaults via local_assigns.fetch):
|
|
13
|
+
submit_path — POST target for the name (default "/onboarding/first_name")
|
|
14
|
+
skip_path — POST target for the skip (default "/onboarding/skip_first_name")
|
|
15
|
+
heading — (default "What should we call you?")
|
|
16
|
+
subtext — the one-line why (default speaks about emails)
|
|
17
|
+
placeholder — (default "Alex")
|
|
18
|
+
max_length — (default 40; the server stays the real bound)
|
|
19
|
+
progress — [current, total] to render the segmented pill, or nil for none
|
|
20
|
+
modal_store — Alpine store name (default "modals"; the living style guide
|
|
21
|
+
mounts its own page-scoped host and passes "dsModals")
|
|
22
|
+
done_event — window event dispatched with { next: [...] } when this step is
|
|
23
|
+
finished, saved or skipped (default "onboarding-step-done").
|
|
24
|
+
The HOST decides what happens next; this partial never knows.
|
|
25
|
+
|
|
26
|
+
CONTRACT WITH THE SERVER. Both endpoints answer JSON `{ ok: true, next: [...] }`
|
|
27
|
+
— `next` being whatever steps the host says remain. A non-ok response must carry
|
|
28
|
+
`{ error: "…" }`, which is rendered inline. The step reports `next` upward and
|
|
29
|
+
closes; it never opens another modal itself.
|
|
30
|
+
|
|
31
|
+
CRITICAL (Alpine): this partial is cloned from a <template x-if> by the modal
|
|
32
|
+
host, so it must have ONE root element, and the x-data below is a
|
|
33
|
+
DOUBLE-QUOTED attribute — a single " anywhere inside it (a code comment
|
|
34
|
+
included) closes it early and the whole component mounts as a silent no-op that
|
|
35
|
+
still renders markup. Keep every inner string SINGLE-quoted.
|
|
36
|
+
%>
|
|
37
|
+
<%
|
|
38
|
+
submit_path = local_assigns.fetch(:submit_path, "/onboarding/first_name")
|
|
39
|
+
skip_path = local_assigns.fetch(:skip_path, "/onboarding/skip_first_name")
|
|
40
|
+
heading = local_assigns.fetch(:heading, "What should we call you?")
|
|
41
|
+
subtext = local_assigns.fetch(:subtext,
|
|
42
|
+
"Just your first name — we use it to address you in emails.")
|
|
43
|
+
placeholder = local_assigns.fetch(:placeholder, "Alex")
|
|
44
|
+
max_length = local_assigns.fetch(:max_length, 40)
|
|
45
|
+
progress = local_assigns.fetch(:progress, nil)
|
|
46
|
+
modal_store = local_assigns.fetch(:modal_store, "modals")
|
|
47
|
+
done_event = local_assigns.fetch(:done_event, "onboarding-step-done")
|
|
48
|
+
field_id = local_assigns.fetch(:id, "onboarding-first-name")
|
|
49
|
+
%>
|
|
50
|
+
<div x-data="{
|
|
51
|
+
get props() { var c = $store.<%= modal_store %>.current(); return (c && c.props) || {}; },
|
|
52
|
+
firstName: '',
|
|
53
|
+
submitting: false,
|
|
54
|
+
error: '',
|
|
55
|
+
async save() {
|
|
56
|
+
if (this.submitting) return;
|
|
57
|
+
var value = (this.firstName || '').trim();
|
|
58
|
+
if (!value) { this.error = 'Enter your first name, or skip for now.'; return; }
|
|
59
|
+
this.submitting = true; this.error = '';
|
|
60
|
+
var data = await this.post('<%= submit_path %>', { first_name: value });
|
|
61
|
+
this.submitting = false;
|
|
62
|
+
if (!data || !data.ok) {
|
|
63
|
+
this.error = (data && data.error) || 'Could not save that — try again.';
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.finish(data.next || []);
|
|
67
|
+
},
|
|
68
|
+
async skip() {
|
|
69
|
+
if (this.submitting) return;
|
|
70
|
+
this.submitting = true; this.error = '';
|
|
71
|
+
var data = await this.post('<%= skip_path %>', {});
|
|
72
|
+
this.submitting = false;
|
|
73
|
+
this.finish((data && data.next) || []);
|
|
74
|
+
},
|
|
75
|
+
async post(url, body) {
|
|
76
|
+
var meta = document.querySelector('meta[name=csrf-token]');
|
|
77
|
+
try {
|
|
78
|
+
var res = await fetch(url, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
'Accept': 'application/json',
|
|
83
|
+
'X-CSRF-Token': meta ? meta.content : ''
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(body)
|
|
86
|
+
});
|
|
87
|
+
return await res.json();
|
|
88
|
+
} catch (e) { return null; }
|
|
89
|
+
},
|
|
90
|
+
finish(next) {
|
|
91
|
+
window.dispatchEvent(new CustomEvent('<%= done_event %>', { detail: { next: next } }));
|
|
92
|
+
$store.<%= modal_store %>.close();
|
|
93
|
+
}
|
|
94
|
+
}"
|
|
95
|
+
class="relative">
|
|
96
|
+
|
|
97
|
+
<div class="relative mb-3 -mt-2">
|
|
98
|
+
<h3 class="text-heading font-bold text-lg leading-tight text-center pt-1"><%= heading %></h3>
|
|
99
|
+
<%# The × SKIPS rather than merely closing: this lands seconds after signup,
|
|
100
|
+
and a close that silently abandons the chain is how a host ends up with a
|
|
101
|
+
step nobody can answer again. %>
|
|
102
|
+
<button @click="skip()"
|
|
103
|
+
class="absolute top-0 right-0 -mr-2 text-secondary hover:text-heading text-xl leading-none"
|
|
104
|
+
aria-label="Skip">×</button>
|
|
105
|
+
</div>
|
|
106
|
+
<% if progress %>
|
|
107
|
+
<%= render "studio/modals/blocks/progress_pill", current: progress.first, total: progress.last %>
|
|
108
|
+
<% end %>
|
|
109
|
+
<p class="text-xs text-body mb-4 text-center"><%= subtext %></p>
|
|
110
|
+
|
|
111
|
+
<form @submit.prevent="save()" novalidate>
|
|
112
|
+
<div class="mb-3">
|
|
113
|
+
<label class="block text-xs text-secondary mb-1 font-medium" for="<%= field_id %>">First name</label>
|
|
114
|
+
<%# Focused on open. The HTML autofocus attribute is not enough for a modal
|
|
115
|
+
mounted from a <template x-if> after the document parsed, so Alpine does
|
|
116
|
+
it; preventScroll keeps the mount animation from being yanked. %>
|
|
117
|
+
<input id="<%= field_id %>" type="text" x-model="firstName"
|
|
118
|
+
maxlength="<%= max_length %>" autocomplete="given-name" enterkeyhint="done"
|
|
119
|
+
placeholder="<%= placeholder %>"
|
|
120
|
+
:disabled="submitting"
|
|
121
|
+
x-init="$nextTick(() => $el.focus({ preventScroll: true }))"
|
|
122
|
+
class="input-field">
|
|
123
|
+
</div>
|
|
124
|
+
<template x-if="error">
|
|
125
|
+
<p class="text-red-400 text-xs mb-3" x-text="error"></p>
|
|
126
|
+
</template>
|
|
127
|
+
<button type="submit" :disabled="submitting"
|
|
128
|
+
class="btn btn-primary btn-lg w-full gap-2 disabled:cursor-wait">
|
|
129
|
+
<span x-show="submitting" class="cta-spinner" aria-hidden="true"></span>
|
|
130
|
+
<span x-text="submitting ? 'Saving…' : 'Save and continue'"></span>
|
|
131
|
+
</button>
|
|
132
|
+
</form>
|
|
133
|
+
|
|
134
|
+
<button type="button" @click="skip()" :disabled="submitting"
|
|
135
|
+
class="mt-3 w-full text-center text-sm text-secondary hover:text-heading disabled:opacity-50">
|
|
136
|
+
Skip for now
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
@@ -309,6 +309,14 @@
|
|
|
309
309
|
<div><%= render "style/modals/auth" %></div>
|
|
310
310
|
</template>
|
|
311
311
|
|
|
312
|
+
<%# --- Onboarding --- %>
|
|
313
|
+
<%# The REAL engine partial, mounted on the page-scoped host: what the
|
|
314
|
+
style page shows is exactly what a consumer renders, not a copy. %>
|
|
315
|
+
<template x-if="$store.dsModals.current().id === 'onboarding-first-name'">
|
|
316
|
+
<div><%= render "studio/modals/onboarding/first_name",
|
|
317
|
+
modal_store: "dsModals", done_event: "ds-onboarding-step-done" %></div>
|
|
318
|
+
</template>
|
|
319
|
+
|
|
312
320
|
<%# --- Eligibility + entry --- %>
|
|
313
321
|
<template x-if="$store.dsModals.current().id === 'age-verify'">
|
|
314
322
|
<div><%= render "style/modals/age_verify" %></div>
|
|
@@ -463,6 +471,43 @@
|
|
|
463
471
|
</template>
|
|
464
472
|
|
|
465
473
|
<%# ===================================================================== %>
|
|
474
|
+
<%# 0. ONBOARDING — the post-auth chain's shared step %>
|
|
475
|
+
<%# ===================================================================== %>
|
|
476
|
+
<section class="space-y-5">
|
|
477
|
+
<div class="space-y-1">
|
|
478
|
+
<h3 class="text-xl font-bold text-heading">Onboarding</h3>
|
|
479
|
+
<p class="text-muted text-sm">
|
|
480
|
+
The step every Studio app asks a brand-new account, ported from Turf
|
|
481
|
+
Monster
|
|
482
|
+
(<code class="font-mono text-2xs">studio/modals/onboarding/_first_name</code>).
|
|
483
|
+
It is <strong>one step, not a flow</strong>: the host owns the SEQUENCE
|
|
484
|
+
around it — Turf walks <strong>welcome → first name → age gate
|
|
485
|
+
→ wallet setup</strong>, while a hub app may ask nothing else — so
|
|
486
|
+
this partial never opens another modal. It reports what remains on its
|
|
487
|
+
<code class="font-mono text-2xs">done_event</code> and closes, and the host
|
|
488
|
+
decides what comes next. Every endpoint and label is a local with a
|
|
489
|
+
default, so mounting it under different routes needs no fork.
|
|
490
|
+
<strong>Both</strong> the × and the "Skip for now" link SKIP (a close
|
|
491
|
+
that silently abandons the chain is how a host ends up with a step nobody
|
|
492
|
+
can answer again), and the field focuses itself on open.
|
|
493
|
+
</p>
|
|
494
|
+
</div>
|
|
495
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
496
|
+
<%= render layout: "style/modal_specimen", locals: {
|
|
497
|
+
label: "First name",
|
|
498
|
+
reference: %(the shared onboarding first-name step (studio-engine studio/modals/onboarding/_first_name) — open with $store.dsModals.open('onboarding-first-name'). Posts to submit_path/skip_path (defaults /onboarding/first_name and /onboarding/skip_first_name) and dispatches its done_event with the steps the SERVER says remain),
|
|
499
|
+
open_expr: "$store.dsModals.open('onboarding-first-name')",
|
|
500
|
+
glow_when: ds_glow.call("onboarding-first-name")
|
|
501
|
+
} do %>
|
|
502
|
+
<p class="text-muted text-2xs">
|
|
503
|
+
Saving or skipping dispatches
|
|
504
|
+
<code class="font-mono">ds-onboarding-step-done</code> here rather than
|
|
505
|
+
the app-level event, so the specimen cannot drive a real chain.
|
|
506
|
+
</p>
|
|
507
|
+
<% end %>
|
|
508
|
+
</div>
|
|
509
|
+
</section>
|
|
510
|
+
|
|
466
511
|
<%# 1. AUTH suite %>
|
|
467
512
|
<%# ===================================================================== %>
|
|
468
513
|
<section class="space-y-5">
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# The standard Studio user profile columns — the small set every Studio app
|
|
2
|
+
# carries whether or not it uses them today, so an app never has to invent its
|
|
3
|
+
# own spelling for the same fact.
|
|
4
|
+
#
|
|
5
|
+
# THIS IS THE ENGINE'S FIRST MIGRATION AGAINST A HOST TABLE. Every other engine
|
|
6
|
+
# migration creates a `studio_*` table the engine owns outright; `users` belongs
|
|
7
|
+
# to the host. That boundary is crossed deliberately, on the operator's call
|
|
8
|
+
# (2026-08-13), because the alternative is three hand-written migrations in
|
|
9
|
+
# mcritchie-studio, turf-monster and mcritchie-industries that must be kept
|
|
10
|
+
# identical by discipline alone — and column drift across apps is exactly what a
|
|
11
|
+
# standard is supposed to prevent. One definition, one place.
|
|
12
|
+
#
|
|
13
|
+
# Two properties make that safe:
|
|
14
|
+
#
|
|
15
|
+
# 1. EVERY add is `if_not_exists: true`. The apps disagree TODAY — McRitchie
|
|
16
|
+
# Studio and turf-monster already have `first_name`, and turf already has
|
|
17
|
+
# `birth_year` (as an integer, matching below). An unguarded add would raise
|
|
18
|
+
# on those apps, which is the failure mode that makes engine migrations
|
|
19
|
+
# against host tables frightening in the first place.
|
|
20
|
+
# 2. It no-ops on an app with no `users` table at all, rather than raising.
|
|
21
|
+
# The engine's own dummy is such an app.
|
|
22
|
+
#
|
|
23
|
+
# Migrations are install-copied (`bin/rails studio_engine:install:migrations`),
|
|
24
|
+
# not auto-run, so each app still adopts this deliberately.
|
|
25
|
+
#
|
|
26
|
+
# ON THE BIRTH COLUMNS: three integers, not one date. Splitting them keeps the
|
|
27
|
+
# two questions apps actually ask cheap and separable — age (needs the year, and
|
|
28
|
+
# the month/day for the boundary case) and "whose birthday is today" (needs
|
|
29
|
+
# month + day, and no year at all). turf-monster's AgePolicy composes a Date from
|
|
30
|
+
# the trio. Storing no single date-of-birth column is the point.
|
|
31
|
+
#
|
|
32
|
+
# ON ip_locations: a jsonb ARRAY of the distinct places an account has signed in
|
|
33
|
+
# from, appended to when a NEW location appears. Shape, dedupe and the growth cap
|
|
34
|
+
# belong to Studio::IpLocations — the column is just where it lands. It holds
|
|
35
|
+
# IP-derived location data, which is personal data in most jurisdictions; the
|
|
36
|
+
# 50-entry cap there bounds it, and no app writes to it until it opts in.
|
|
37
|
+
class AddStandardUserProfileColumns < ActiveRecord::Migration[7.2]
|
|
38
|
+
def change
|
|
39
|
+
# An app that has no users table (the engine's dummy, and any future
|
|
40
|
+
# consumer that names its accounts something else) is simply skipped.
|
|
41
|
+
return unless table_exists?(:users)
|
|
42
|
+
|
|
43
|
+
add_column :users, :first_name, :string, if_not_exists: true
|
|
44
|
+
|
|
45
|
+
add_column :users, :birth_day, :integer, if_not_exists: true
|
|
46
|
+
add_column :users, :birth_month, :integer, if_not_exists: true
|
|
47
|
+
add_column :users, :birth_year, :integer, if_not_exists: true
|
|
48
|
+
|
|
49
|
+
add_column :users, :ip_locations, :jsonb, default: [], null: false, if_not_exists: true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The shape, dedupe rule and growth cap for `users.ip_locations` — the jsonb
|
|
5
|
+
# array recording the distinct places an account has been seen from.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately PURE: it takes the existing array and returns a new one. No
|
|
8
|
+
# ActiveRecord, no clock beyond what the caller passes, no geo lookup. That
|
|
9
|
+
# keeps the analytics shape identical in every app while leaving the RESOLUTION
|
|
10
|
+
# to the host — turf-monster already resolves IP → state/country through
|
|
11
|
+
# Geocoder in ApplicationController#detect_geo_state and can hand the result
|
|
12
|
+
# straight here; an app with no geo lookup at all still gets a useful record of
|
|
13
|
+
# the distinct IPs.
|
|
14
|
+
#
|
|
15
|
+
# An entry:
|
|
16
|
+
#
|
|
17
|
+
# { "ip" => "203.0.113.7", "country" => "US", "region" => "TX",
|
|
18
|
+
# "city" => "Austin", "first_seen_at" => "2026-08-13T21:00:00Z",
|
|
19
|
+
# "last_seen_at" => "2026-08-14T09:30:00Z", "count" => 4 }
|
|
20
|
+
#
|
|
21
|
+
# String keys throughout, ISO-8601 times: this round-trips through jsonb, and a
|
|
22
|
+
# symbol-keyed hash written today would come back string-keyed tomorrow.
|
|
23
|
+
module IpLocations
|
|
24
|
+
# Distinct locations kept per user, most-recently-seen first. A cap is not
|
|
25
|
+
# optional for a column that grows on sign-in: an account that travels, or
|
|
26
|
+
# sits behind a rotating consumer IP, would otherwise grow this row without
|
|
27
|
+
# bound and drag every SELECT that loads the user along with it.
|
|
28
|
+
MAX_ENTRIES = 50
|
|
29
|
+
|
|
30
|
+
# Loopback and private ranges. A dev session signs in from 127.0.0.1 all day
|
|
31
|
+
# and a container from 10.x; recording those buys nothing and would crowd the
|
|
32
|
+
# real entries out of the cap.
|
|
33
|
+
SKIPPED_IP = /\A(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1\z|fe80:|f[cd])/i
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Returns the array to store. Appends a new entry when this location has not
|
|
38
|
+
# been seen before; otherwise refreshes the existing one in place.
|
|
39
|
+
def push(entries, ip:, country: nil, region: nil, city: nil, at: nil)
|
|
40
|
+
at = normalize_time(at)
|
|
41
|
+
ip = ip.to_s.strip
|
|
42
|
+
key = location_key(ip: ip, country: country, region: region, city: city)
|
|
43
|
+
return normalize(entries) if key.nil?
|
|
44
|
+
|
|
45
|
+
existing = normalize(entries)
|
|
46
|
+
match = existing.find { |e| e["key"] == key }
|
|
47
|
+
|
|
48
|
+
if match
|
|
49
|
+
match["last_seen_at"] = at
|
|
50
|
+
match["count"] = match["count"].to_i + 1
|
|
51
|
+
# The IP within a location legitimately changes (a reassigned lease); the
|
|
52
|
+
# LOCATION is what we deduped on, so keep the freshest address for it.
|
|
53
|
+
match["ip"] = ip if ip.present?
|
|
54
|
+
else
|
|
55
|
+
existing << {
|
|
56
|
+
"key" => key, "ip" => ip.presence, "country" => presence(country),
|
|
57
|
+
"region" => presence(region), "city" => presence(city),
|
|
58
|
+
"first_seen_at" => at, "last_seen_at" => at, "count" => 1
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
existing.sort_by { |e| e["last_seen_at"].to_s }.reverse.first(MAX_ENTRIES)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Has this account been seen here before? The question a caller asks to avoid
|
|
66
|
+
# a write on every single request.
|
|
67
|
+
def seen?(entries, ip: nil, country: nil, region: nil, city: nil)
|
|
68
|
+
key = location_key(ip: ip.to_s.strip, country: country, region: region, city: city)
|
|
69
|
+
return false if key.nil?
|
|
70
|
+
|
|
71
|
+
normalize(entries).any? { |e| e["key"] == key }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# What the location is, for deduping. Geo when we have any of it — two
|
|
75
|
+
# sign-ins from Austin are ONE location even from different addresses, which
|
|
76
|
+
# is the whole point of tracking places rather than addresses. Falls back to
|
|
77
|
+
# the IP when the lookup gave us nothing, so an app with no geo still records
|
|
78
|
+
# something useful. nil (record nothing) when there is neither.
|
|
79
|
+
def location_key(ip:, country: nil, region: nil, city: nil)
|
|
80
|
+
geo = [country, region, city].map { |v| presence(v) }
|
|
81
|
+
return "geo:" + geo.map(&:to_s).join("|").downcase if geo.any?
|
|
82
|
+
return nil if ip.blank? || ip.match?(SKIPPED_IP)
|
|
83
|
+
|
|
84
|
+
"ip:#{ip.downcase}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize(entries)
|
|
88
|
+
Array(entries).filter_map do |entry|
|
|
89
|
+
next unless entry.respond_to?(:to_h)
|
|
90
|
+
|
|
91
|
+
row = entry.to_h.transform_keys(&:to_s)
|
|
92
|
+
next if row.empty?
|
|
93
|
+
|
|
94
|
+
# Backfill for rows written before these fields existed, so an upgrade in
|
|
95
|
+
# place neither duplicates locations it already holds nor treats them as
|
|
96
|
+
# the stalest thing in the column.
|
|
97
|
+
row["key"] ||= location_key(ip: row["ip"].to_s, country: row["country"],
|
|
98
|
+
region: row["region"], city: row["city"])
|
|
99
|
+
# A stored row is evidence of at least one sighting.
|
|
100
|
+
row["count"] = 1 if row["count"].to_i < 1
|
|
101
|
+
row["last_seen_at"] ||= row["first_seen_at"]
|
|
102
|
+
row
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_time(value)
|
|
107
|
+
return value if value.is_a?(String) && value.present?
|
|
108
|
+
return Time.now.utc.iso8601 if value.nil?
|
|
109
|
+
|
|
110
|
+
value.respond_to?(:utc) ? value.utc.iso8601 : value.to_s
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def presence(value)
|
|
114
|
+
str = value.to_s.strip
|
|
115
|
+
str.empty? ? nil : str
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "studio/version"
|
|
2
2
|
require "studio/log_rotation"
|
|
3
|
+
require "studio/ip_locations"
|
|
3
4
|
require "studio/engine"
|
|
4
5
|
require "studio/color_scale"
|
|
5
6
|
require "studio/environment_banner"
|
|
@@ -141,6 +142,90 @@ module Studio
|
|
|
141
142
|
# resolution are always on, so an app sends branded email either way.
|
|
142
143
|
mattr_accessor :draw_admin_emails_routes, default: false
|
|
143
144
|
|
|
145
|
+
# Draw the shared first-name onboarding endpoints
|
|
146
|
+
# (Studio::OnboardingController#first_name / #skip_first_name). OFF by default,
|
|
147
|
+
# and for the same hard reason as draw_admin_emails_routes above:
|
|
148
|
+
# turf-monster ALREADY owns `post "/onboarding/first_name"` with the helper
|
|
149
|
+
# names onboarding_first_name_path and onboarding_skip_first_name_path, so
|
|
150
|
+
# drawing these unconditionally raises `Invalid route name, already in use`
|
|
151
|
+
# while that app's routes.rb loads — which takes down its ENTIRE route set, not
|
|
152
|
+
# just this page. Consumer CI runs each consumer's main, so a default-on flag
|
|
153
|
+
# cannot be fixed from inside the engine. Each app's adoption task turns it on
|
|
154
|
+
# as it deletes its local copy:
|
|
155
|
+
#
|
|
156
|
+
# config.draw_onboarding_routes = true
|
|
157
|
+
#
|
|
158
|
+
# Gates only the ENDPOINTS. The modal partial
|
|
159
|
+
# (studio/modals/onboarding/_first_name) and its /admin/style specimen are
|
|
160
|
+
# always available, so a host can preview the step before it wires the writes.
|
|
161
|
+
mattr_accessor :draw_onboarding_routes, default: false
|
|
162
|
+
|
|
163
|
+
# What the onboarding endpoints report back as still-remaining after a write,
|
|
164
|
+
# so the client can keep walking its chain without a second round trip.
|
|
165
|
+
#
|
|
166
|
+
# The engine owns ONE STEP (the first-name ask); the HOST owns the SEQUENCE
|
|
167
|
+
# around it. turf-monster walks welcome → first name → age → wallet, which
|
|
168
|
+
# means nothing in a hub app, so this resolver is how a host says what comes
|
|
169
|
+
# next. It takes (user, session) and returns an array of step names; the
|
|
170
|
+
# default — no further steps — is correct for an app whose only ask is the name.
|
|
171
|
+
#
|
|
172
|
+
# config.onboarding_steps_resolver = ->(user, session) {
|
|
173
|
+
# OnboardingFlow.new(user, session).remaining.map(&:to_s)
|
|
174
|
+
# }
|
|
175
|
+
mattr_accessor :onboarding_steps_resolver, default: ->(_user, _session) { [] }
|
|
176
|
+
|
|
177
|
+
# Session key recording "asked, and they said not now". Session-scoped
|
|
178
|
+
# DELIBERATELY: skipping means not now, not never — the field stays blank, so a
|
|
179
|
+
# later session may ask again. That is the whole reason this is not a column.
|
|
180
|
+
FIRST_NAME_SKIP_SESSION_KEY = :onboarding_skipped_first_name
|
|
181
|
+
|
|
182
|
+
# The shared rule for "does this account still owe us a first name?" — the one
|
|
183
|
+
# piece of onboarding logic every app agrees on. Hosts compose it into their own
|
|
184
|
+
# flow rather than re-deriving it (turf's OnboardingFlow calls straight through).
|
|
185
|
+
#
|
|
186
|
+
# Tolerates a host whose users table has no first_name column: an app that has
|
|
187
|
+
# not run the migration yet is simply never asked, instead of raising on every
|
|
188
|
+
# signed-in request.
|
|
189
|
+
def self.first_name_outstanding?(user, session = {})
|
|
190
|
+
return false if user.blank?
|
|
191
|
+
return false unless user.respond_to?(:first_name)
|
|
192
|
+
return false if session.present? && session[FIRST_NAME_SKIP_SESSION_KEY]
|
|
193
|
+
|
|
194
|
+
user.first_name.blank?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Record a place this account has been seen from, if it is a place we have not
|
|
198
|
+
# seen it from before. Returns true when something was actually written.
|
|
199
|
+
#
|
|
200
|
+
# NEW LOCATIONS ONLY, and that is the design rather than a shortcut: this is
|
|
201
|
+
# called from the request path, so refreshing a counter on every hit would mean
|
|
202
|
+
# a database write per request for no analytic gain. The first sign-in from a
|
|
203
|
+
# place writes; the next thousand do not. A host wanting last-seen/count
|
|
204
|
+
# refresh can call Studio::IpLocations.push directly on its own cadence.
|
|
205
|
+
#
|
|
206
|
+
# The host resolves the location — turf-monster already has Geocoder wired in
|
|
207
|
+
# ApplicationController#detect_geo_state — and passes whatever it got. Pass
|
|
208
|
+
# only an IP and the IP is what gets deduped on.
|
|
209
|
+
#
|
|
210
|
+
# Tolerates an app that has not run the migration (no ip_locations column):
|
|
211
|
+
# it records nothing rather than raising on a request path.
|
|
212
|
+
def self.record_ip_location!(user, ip:, country: nil, region: nil, city: nil, at: nil)
|
|
213
|
+
return false if user.blank?
|
|
214
|
+
return false unless user.respond_to?(:ip_locations)
|
|
215
|
+
|
|
216
|
+
current = user.ip_locations
|
|
217
|
+
return false if IpLocations.seen?(current, ip: ip, country: country, region: region, city: city)
|
|
218
|
+
|
|
219
|
+
updated = IpLocations.push(current, ip: ip, country: country, region: region,
|
|
220
|
+
city: city, at: at)
|
|
221
|
+
return false if updated == IpLocations.normalize(current)
|
|
222
|
+
|
|
223
|
+
# update_columns, not update!: analytics must never block a request, and a
|
|
224
|
+
# validation failure elsewhere on the record is not this write's business.
|
|
225
|
+
user.update_columns(ip_locations: updated)
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
144
229
|
# Optional admin Act As / impersonation session conventions. Consumers that
|
|
145
230
|
# include Studio::Impersonation get current_user layered over true_user with
|
|
146
231
|
# these session keys, but still own authorization, audit logging, and routes.
|
|
@@ -584,6 +669,20 @@ module Studio
|
|
|
584
669
|
as: :admin_email_logo, constraints: { key: /[a-z0-9_]+/ }
|
|
585
670
|
end
|
|
586
671
|
|
|
672
|
+
# The shared first-name onboarding step's two writes. OPT-IN — see
|
|
673
|
+
# Studio.draw_onboarding_routes above: turf-monster owns these exact helper
|
|
674
|
+
# names today, and drawing them there before its adoption task deletes the
|
|
675
|
+
# local pair kills every route in that app.
|
|
676
|
+
#
|
|
677
|
+
# The paths match the partial's defaults, so a host that opts in and has no
|
|
678
|
+
# other onboarding of its own needs no further wiring.
|
|
679
|
+
if Studio.draw_onboarding_routes
|
|
680
|
+
post "onboarding/first_name", to: "studio/onboarding#first_name",
|
|
681
|
+
as: :onboarding_first_name
|
|
682
|
+
post "onboarding/skip_first_name", to: "studio/onboarding#skip_first_name",
|
|
683
|
+
as: :onboarding_skip_first_name
|
|
684
|
+
end
|
|
685
|
+
|
|
587
686
|
# DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
|
|
588
687
|
# each consumer's DEFAULT-BRANCH suite against this engine, and both
|
|
589
688
|
# mcritchie-studio and turf-monster have tests on `main` that GET this page
|
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.
|
|
4
|
+
version: 0.46.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -203,6 +203,7 @@ files:
|
|
|
203
203
|
- app/controllers/studio/local_emails_controller.rb
|
|
204
204
|
- app/controllers/studio/local_reviews_controller.rb
|
|
205
205
|
- app/controllers/studio/models_controller.rb
|
|
206
|
+
- app/controllers/studio/onboarding_controller.rb
|
|
206
207
|
- app/controllers/style_controller.rb
|
|
207
208
|
- app/controllers/theme_settings_controller.rb
|
|
208
209
|
- app/helpers/studio/admin_models_table_helper.rb
|
|
@@ -321,6 +322,7 @@ files:
|
|
|
321
322
|
- app/views/studio/modals/blocks/_solana_tx_link.html.erb
|
|
322
323
|
- app/views/studio/modals/blocks/_success_card.html.erb
|
|
323
324
|
- app/views/studio/modals/blocks/_wallet_brand_sprite.html.erb
|
|
325
|
+
- app/views/studio/modals/onboarding/_first_name.html.erb
|
|
324
326
|
- app/views/studio/modals/shared/_age_attestation.html.erb
|
|
325
327
|
- app/views/studio/modals/shared/_email_field.html.erb
|
|
326
328
|
- app/views/studio/modals/templates/_action.html.erb
|
|
@@ -356,6 +358,7 @@ files:
|
|
|
356
358
|
- db/migrate/20260812210000_add_copy_to_studio_email_settings.rb
|
|
357
359
|
- db/migrate/20260812220000_add_subject_to_studio_email_settings.rb
|
|
358
360
|
- db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
|
|
361
|
+
- db/migrate/20260813220000_add_standard_user_profile_columns.rb
|
|
359
362
|
- lib/studio-engine.rb
|
|
360
363
|
- lib/studio.rb
|
|
361
364
|
- lib/studio/cable.rb
|
|
@@ -365,6 +368,7 @@ files:
|
|
|
365
368
|
- lib/studio/engine.rb
|
|
366
369
|
- lib/studio/environment_banner.rb
|
|
367
370
|
- lib/studio/image_cache.rb
|
|
371
|
+
- lib/studio/ip_locations.rb
|
|
368
372
|
- lib/studio/link_resolution.rb
|
|
369
373
|
- lib/studio/link_token.rb
|
|
370
374
|
- lib/studio/log_rotation.rb
|