studio-engine 0.97.0.rc1 → 0.98.0.rc1
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 +89 -0
- data/app/javascript/studio/alpine_scopes.js +66 -0
- data/app/javascript/studio/alpine_shims.js +4 -0
- data/app/javascript/studio/application.js +4 -1
- data/app/javascript/studio/birthday.js +351 -0
- data/app/javascript/studio/confirm_interstitial.js +44 -0
- data/app/javascript/studio/controllers/identity_mini_controller.js +32 -0
- data/app/javascript/studio/controllers/style_modals_controller.js +41 -0
- data/app/javascript/studio/cropper.js +281 -0
- data/app/javascript/studio/identity_mini.js +74 -0
- data/app/javascript/studio/image_upload.js +189 -0
- data/app/javascript/studio/profile_form.js +88 -0
- data/app/javascript/studio/stimulus.js +4 -0
- data/app/javascript/studio/style_guide.js +13 -0
- data/app/javascript/studio/style_modals.js +214 -0
- data/app/views/components/_avatar_cropper.html.erb +3 -38
- data/app/views/layouts/studio/_head.html.erb +12 -2
- data/app/views/studio/_alpine_scopes_guard.html.erb +178 -0
- data/app/views/studio/_birthday_assets.html.erb +6 -240
- data/app/views/studio/_confirm_interstitial.html.erb +31 -18
- data/app/views/studio/_cropper_assets.html.erb +9 -8
- data/app/views/studio/modals/_crop_photo.html.erb +1 -1
- data/app/views/studio/modals/_image_upload.html.erb +8 -321
- data/app/views/studio/modals/blocks/_birthday.html.erb +2 -3
- data/app/views/studio/profiles/_birthday_fields.html.erb +0 -1
- data/app/views/studio/profiles/_identity_mini.html.erb +5 -62
- data/app/views/studio/profiles/_plain_save.html.erb +11 -0
- data/app/views/studio/profiles/edit.html.erb +1 -6
- data/app/views/style/_modals.html.erb +36 -420
- data/lib/studio/version.rb +1 -1
- metadata +14 -3
- data/app/views/studio/profiles/_birthday_picker_script.html.erb +0 -146
- data/app/views/studio/profiles/_form_script.html.erb +0 -65
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96463f400e143dc8688f6803440f724745f9064ec1921e55e58faf9be91edca2
|
|
4
|
+
data.tar.gz: 93e5d60b3dff0cc99c33d951c5326f8b74b192dd7f2ddc44d9f76adc0d047a2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 953aedd6855e26a05484615332176e44b59ed4a890c635b0f6042215876afea856504b43d1b5e99bf16ec845a4dfb7aaefefcf8fc2caa33031780fce108a5f5a
|
|
7
|
+
data.tar.gz: 2e1000ccd5b1b15c16c7ce911cd5c5387730d7b2672186d1f46f1a8b4c82b6919f926a13e16f71b9a6a833bcf4ecec8d63b1aa185ed5136ad186de0eb6eb8653
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,95 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.98.0 — 2026-10-09
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The modal blocks and the profile scripts move to ES modules.** The crop
|
|
12
|
+
photo modal, the image upload hosts, the avatar cropper, the birthday card,
|
|
13
|
+
the profile form and its birthday row, the compact identity bar, the sign-in
|
|
14
|
+
interstitial and the style guide's modal demos render markup only; nine
|
|
15
|
+
inline scripts are gone. Rendered markup and styling are unchanged apart from
|
|
16
|
+
the attributes and the interstitial's fallback named here, and every `x-data`
|
|
17
|
+
a page writes still resolves.
|
|
18
|
+
- **The factories.** `cropPhotoModal` is `studio/cropper`; `imageUploadHost`,
|
|
19
|
+
`avatarCropperHost` and `submitFormWithProgress` are `studio/image_upload`;
|
|
20
|
+
`birthdayModal` and `studioBirthdayFields` are `studio/birthday`;
|
|
21
|
+
`studioProfileForm` is `studio/profile_form`. `studio/alpine_scopes`
|
|
22
|
+
publishes each on `window`, where a host has not defined the name first.
|
|
23
|
+
They are in the preloaded boot graph, not lazy: Alpine evaluates an `x-data`
|
|
24
|
+
as it meets the element, and a factory that is not there leaves a photo
|
|
25
|
+
upload, the birthday gate or the profile form dead. `layouts/studio/_head`
|
|
26
|
+
also imports `studio/alpine_scopes` by its own nonced module tag, so a boot
|
|
27
|
+
that fails to load keeps a page its factories.
|
|
28
|
+
- **Partials.** `studio/modals/_image_upload` and `studio/_birthday_assets`
|
|
29
|
+
render nothing and stay, so a layout that renders either by name keeps
|
|
30
|
+
working; the renders can be deleted. `studio/_cropper_assets` loads
|
|
31
|
+
Cropper.js (from cdnjs, as before) and nothing else.
|
|
32
|
+
- **Compact identity bar.** `studio/profiles/_identity_mini` carries
|
|
33
|
+
`data-studio-controller="identity-mini"`; when the bar shows is
|
|
34
|
+
`studio/identity_mini`. The controller is registered statically, because on
|
|
35
|
+
the edit page the bar carries Save and Discard.
|
|
36
|
+
- **Sign-in interstitial.** `studio/_confirm_interstitial` posts its form from
|
|
37
|
+
`studio/confirm_interstitial`, loaded by its own nonced module tag (the page
|
|
38
|
+
has no import map). The fallback button shows itself four seconds in two
|
|
39
|
+
ways: by CSS alone, so a page whose script never arrives still has something
|
|
40
|
+
to press, and by the module's own timer, for an engine that holds a CSS
|
|
41
|
+
animation while the post is pending (WebKit). It is hidden by `max-height`
|
|
42
|
+
and `visibility` in place of `display: none`.
|
|
43
|
+
- **Style guide.** The Modals section's demo drivers, its enter/leave
|
|
44
|
+
simulator and the wallet stubs are `studio/style_modals`, on a
|
|
45
|
+
`style-modals` controller the section's own module tag registers
|
|
46
|
+
(`studio/style_guide`); its demo buttons carry `data-studio-action` in place
|
|
47
|
+
of `onclick`. `$store.dsModals` is the engine's own modal stack: the overlay
|
|
48
|
+
declares `data-studio-controller="modal-host"` and
|
|
49
|
+
`data-modal-host-store-value="dsModals"`, and the guide's copy of the stack
|
|
50
|
+
is deleted. The card carries no `max-w-*` of its own and takes its width
|
|
51
|
+
from `StudioModals.CARD_WIDTHS` by id, as an app's cards do.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **A page whose x-data factories failed to load says so, and its profile form
|
|
56
|
+
can still be saved.** One failed request among `studio/alpine_scopes`,
|
|
57
|
+
`studio/cropper`, `studio/image_upload`, `studio/birthday` and
|
|
58
|
+
`studio/profile_form` leaves all six factories undefined until a reload. A
|
|
59
|
+
nonced inline guard in `layouts/studio/_head` (`studio/_alpine_scopes_guard`,
|
|
60
|
+
beside the hold button's) marks each element that binds one of them
|
|
61
|
+
`data-studio-scope-failed="<factory>"` once the page's boot is over and puts
|
|
62
|
+
`<p class="studio-scope-notice" role="status">This did not load. Reload the
|
|
63
|
+
page.</p>` first inside it, on a card a modal mounts later too. Inside a
|
|
64
|
+
marked element every `[data-studio-scope-fallback]` is shown and a text field
|
|
65
|
+
left blank goes back to the value the server rendered: the profile form's
|
|
66
|
+
plain Save (`studio/profiles/_plain_save`) is such a fallback, so a person can
|
|
67
|
+
still save. A healthy or slow page is never marked. The line carries its own
|
|
68
|
+
styles, so a host changes nothing; `.studio-scope-notice` is there to restyle
|
|
69
|
+
it.
|
|
70
|
+
- **The crop photo modal says when Cropper.js did not load.** It waits up to
|
|
71
|
+
three seconds for the library (the script is deferred, and on a Turbo visit
|
|
72
|
+
can land after the card opens), then shows "The photo cropper did not load.
|
|
73
|
+
Reload the page and try again." on its error line and leaves the modal
|
|
74
|
+
dismissible. It used to show the image with no cropper and a Crop & Save
|
|
75
|
+
button that did nothing.
|
|
76
|
+
|
|
77
|
+
### Breaking
|
|
78
|
+
|
|
79
|
+
- **`studio/profiles/_form_script` and `studio/profiles/_birthday_picker_script`
|
|
80
|
+
are deleted.** `studio/profiles/edit` and `studio/profiles/_birthday_fields`
|
|
81
|
+
no longer render them; an app that renders either by name deletes the render.
|
|
82
|
+
- **The factories are no longer in a page's HTML.** A test that looked for
|
|
83
|
+
`window.birthdayModal = function`, `window.imageUploadHost`,
|
|
84
|
+
`window.studioProfileForm` or `cropPhotoModal` source in a rendered page
|
|
85
|
+
looks for the name in `app/javascript/studio/alpine_scopes.js` and for
|
|
86
|
+
`import "studio/alpine_scopes"` in the page's head.
|
|
87
|
+
`Alpine.data("cropPhotoModal")` is no longer registered; the `x-data` resolves
|
|
88
|
+
through `window.cropPhotoModal`.
|
|
89
|
+
- **`window.dsModalDemos`, `window.dsWalletConnectDemo` and the stub
|
|
90
|
+
`window.walletProvider` exist only on the style guide, once its section's
|
|
91
|
+
controller has connected.** `registerDsModals` and
|
|
92
|
+
`window.__dsAnimControlsBound` are gone.
|
|
93
|
+
- **`#magic-fallback` on the sign-in interstitial is no longer `display: none`.**
|
|
94
|
+
A host stylesheet or test that read its `display` reads `visibility`.
|
|
95
|
+
|
|
7
96
|
## 0.97.0 — 2026-10-09
|
|
8
97
|
|
|
9
98
|
### Changed
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// studio/alpine_scopes: the x-data factories the engine's partials and its
|
|
2
|
+
// hosts' pages bind, each published on window from the module that owns it.
|
|
3
|
+
//
|
|
4
|
+
// x-data="cropPhotoModal({ store: 'modals' })" studio/cropper
|
|
5
|
+
// x-data="imageUploadHost({ aspectRatio: 5 })" studio/image_upload
|
|
6
|
+
// x-data="avatarCropperHost()" studio/image_upload
|
|
7
|
+
// x-data="birthdayModal({ minAge, url })" studio/birthday
|
|
8
|
+
// x-data="studioBirthdayFields('1991-01-31')" studio/birthday
|
|
9
|
+
// x-data="studioProfileForm({ first_name: 'Pat' })" studio/profile_form
|
|
10
|
+
// submitFormWithProgress(form, opts) studio/image_upload
|
|
11
|
+
//
|
|
12
|
+
// WHY THESE ARRIVE WITH THE PAGE AND ARE NOT LAZY. Alpine evaluates an x-data
|
|
13
|
+
// expression when it meets the element: at start for a page's own markup, at
|
|
14
|
+
// mount for a modal card. A factory that is not defined by then leaves its
|
|
15
|
+
// element dead, and the element here is a photo upload, the birthday gate or
|
|
16
|
+
// the profile form. A dynamic import cannot promise to have resolved, and one
|
|
17
|
+
// that fails is final for the document (studio/lazy_controllers). So the
|
|
18
|
+
// factories are imported statically and preloaded on every page.
|
|
19
|
+
//
|
|
20
|
+
// WHY THIS IS ITS OWN ENTRY POINT. layouts/studio/_head imports this module by
|
|
21
|
+
// its own nonced module tag as well as through the boot graph
|
|
22
|
+
// (studio/alpine_shims imports it), like studio/alpine_stores. Its only imports
|
|
23
|
+
// are the modules that own the factories, and those import nothing, so a throw
|
|
24
|
+
// or a missing file elsewhere in studio/application's graph still leaves a
|
|
25
|
+
// page its uploads, its birthday gate and its profile form. It installs itself
|
|
26
|
+
// on evaluation, once.
|
|
27
|
+
//
|
|
28
|
+
// WHEN ONE OF THESE FILES FAILS TO LOAD, every name here is missing: a failed
|
|
29
|
+
// static import stops this module, by either door, for the life of the
|
|
30
|
+
// document. The inline guard in the head (studio/_alpine_scopes_guard) says so
|
|
31
|
+
// on each element that binds one and shows the profile form's plain Save. A
|
|
32
|
+
// factory added to SCOPES is added to that guard's list
|
|
33
|
+
// (test/javascript/alpine_scopes_guard.test.mjs holds the two together).
|
|
34
|
+
//
|
|
35
|
+
// A name a host defined first is left alone.
|
|
36
|
+
import { cropPhotoModal } from "studio/cropper"
|
|
37
|
+
import { imageUploadHost, avatarCropperHost, submitFormWithProgress } from "studio/image_upload"
|
|
38
|
+
import { birthdayModal, studioBirthdayFields } from "studio/birthday"
|
|
39
|
+
import { studioProfileForm } from "studio/profile_form"
|
|
40
|
+
|
|
41
|
+
// name -> the factory Alpine calls. Alpine passes the expression's own
|
|
42
|
+
// arguments and nothing else.
|
|
43
|
+
export const SCOPES = {
|
|
44
|
+
cropPhotoModal: (opts) => cropPhotoModal(opts),
|
|
45
|
+
imageUploadHost: (opts) => imageUploadHost(opts),
|
|
46
|
+
avatarCropperHost: () => avatarCropperHost(),
|
|
47
|
+
birthdayModal: (opts) => birthdayModal(opts),
|
|
48
|
+
studioBirthdayFields: (initial) => studioBirthdayFields(initial),
|
|
49
|
+
studioProfileForm: (initial) => studioProfileForm(initial)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Plain functions a page calls by name.
|
|
53
|
+
export const FUNCTIONS = {
|
|
54
|
+
submitFormWithProgress: (form, opts) => submitFormWithProgress(form, opts)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function installAlpineScopes(win) {
|
|
58
|
+
for (const name of Object.keys(SCOPES)) {
|
|
59
|
+
if (!win[name]) win[name] = SCOPES[name]
|
|
60
|
+
}
|
|
61
|
+
for (const name of Object.keys(FUNCTIONS)) {
|
|
62
|
+
if (!win[name]) win[name] = FUNCTIONS[name]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof window !== "undefined") installAlpineScopes(window)
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
// host's body binding survives a boot that failed to load. Importing it here
|
|
16
16
|
// keeps it in the boot graph, preloaded; it installs itself on evaluation.
|
|
17
17
|
import "studio/alpine_stores"
|
|
18
|
+
// The x-data factories pages bind (cropPhotoModal, imageUploadHost, ...) are
|
|
19
|
+
// studio/alpine_scopes, which the head also loads by its own module tag and
|
|
20
|
+
// which installs itself the same way.
|
|
21
|
+
import "studio/alpine_scopes"
|
|
18
22
|
import { NavCollapse } from "studio/nav_collapse"
|
|
19
23
|
import {
|
|
20
24
|
showNavSpinner, hideNavSpinner, installSpinnerReset, fireSuccessConfetti
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The Stimulus application itself, its attribute schema, the hold button and
|
|
6
6
|
// the lazily registered page-specific controllers are studio/stimulus. This
|
|
7
|
-
// file registers the every-page chrome on it
|
|
7
|
+
// file registers the every-page chrome on it, and the profile page's compact
|
|
8
|
+
// identity bar, which carries Save once the full card has scrolled away.
|
|
8
9
|
//
|
|
9
10
|
// Everything this file imports statically is preloaded on every page
|
|
10
11
|
// (Studio::Engine.javascript_boot_graph), so the boot costs one round trip.
|
|
@@ -13,6 +14,7 @@ import NavCollapseController from "studio/controllers/nav_collapse_controller"
|
|
|
13
14
|
import ModalHostController from "studio/controllers/modal_host_controller"
|
|
14
15
|
import ToastController from "studio/controllers/toast_controller"
|
|
15
16
|
import LinkSidebarController from "studio/controllers/link_sidebar_controller"
|
|
17
|
+
import IdentityMiniController from "studio/controllers/identity_mini_controller"
|
|
16
18
|
import { startPinnedStack } from "studio/pinned_stack"
|
|
17
19
|
import { installAlpineShims } from "studio/alpine_shims"
|
|
18
20
|
|
|
@@ -23,5 +25,6 @@ application.register("nav-collapse", NavCollapseController)
|
|
|
23
25
|
application.register("modal-host", ModalHostController)
|
|
24
26
|
application.register("toast", ToastController)
|
|
25
27
|
application.register("link-sidebar", LinkSidebarController)
|
|
28
|
+
application.register("identity-mini", IdentityMiniController)
|
|
26
29
|
|
|
27
30
|
export { application, schema }
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// studio/birthday: the two places a person picks a date of birth from three
|
|
2
|
+
// selects (studio/fields/_date_of_birth), and the date arithmetic both share.
|
|
3
|
+
//
|
|
4
|
+
// x-data="birthdayModal({ minAge, state, url, store, gateId })"
|
|
5
|
+
// the birthday card (studio/modals/blocks/_birthday): asks for the date
|
|
6
|
+
// and posts it to the app, which decides.
|
|
7
|
+
// x-data="studioBirthdayFields('1991-01-31')"
|
|
8
|
+
// the profile page's birthday row (studio/profiles/_birthday_fields):
|
|
9
|
+
// writes the picked date into the enclosing profile form.
|
|
10
|
+
//
|
|
11
|
+
// Both bind month, day, year, months, dayOptions, years and onMonthChange().
|
|
12
|
+
//
|
|
13
|
+
// studio/alpine_scopes publishes both on window, statically: the birthday card
|
|
14
|
+
// gates an entry, and a card whose factory is missing has nothing to press.
|
|
15
|
+
// It imports nothing; test/javascript/birthday.test.mjs loads it as a data:
|
|
16
|
+
// module.
|
|
17
|
+
|
|
18
|
+
export const MONTHS = [
|
|
19
|
+
{ n: 1, label: "January" }, { n: 2, label: "February" }, { n: 3, label: "March" },
|
|
20
|
+
{ n: 4, label: "April" }, { n: 5, label: "May" }, { n: 6, label: "June" },
|
|
21
|
+
{ n: 7, label: "July" }, { n: 8, label: "August" }, { n: 9, label: "September" },
|
|
22
|
+
{ n: 10, label: "October" }, { n: 11, label: "November" }, { n: 12, label: "December" }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
26
|
+
|
|
27
|
+
// How long the style guide's demo card takes to answer.
|
|
28
|
+
export const DEMO_RESOLVE_MS = 450
|
|
29
|
+
|
|
30
|
+
export const VERIFY_FAILED = "We couldn't verify your age. Please check the date and try again."
|
|
31
|
+
export const NETWORK_ERROR = "Network error — please try again."
|
|
32
|
+
|
|
33
|
+
// The days a month has: 30 in April, 29 in a February only in a leap year. Day
|
|
34
|
+
// 0 of the next month is the last day of this one. With no year picked the
|
|
35
|
+
// year is 2000, a leap year, so February offers 29. With no month, 31.
|
|
36
|
+
export function daysInMonth(month, year) {
|
|
37
|
+
const m = parseInt(month, 10)
|
|
38
|
+
if (!m) return 31
|
|
39
|
+
return new Date(parseInt(year, 10) || 2000, m, 0).getDate()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// [1, 2, ... n] for the day select.
|
|
43
|
+
export function dayOptions(month, year) {
|
|
44
|
+
return Array.from({ length: daysInMonth(month, year) }, (_, index) => index + 1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Whether a picked day still exists once the month or year changes (the 31st
|
|
48
|
+
// does not survive a move to February).
|
|
49
|
+
export function dayExists(day, month, year) {
|
|
50
|
+
return !!day && parseInt(day, 10) <= daysInMonth(month, year)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The years counted down from `newest` years ago to `oldest` years ago.
|
|
54
|
+
export function yearsBack(now, newest, oldest) {
|
|
55
|
+
const years = []
|
|
56
|
+
for (let y = now.getFullYear() - newest; y >= now.getFullYear() - oldest; y--) years.push(y)
|
|
57
|
+
return years
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Whole years between a date of birth and `now`: a birthday later this year
|
|
61
|
+
// has not happened yet.
|
|
62
|
+
export function ageOn(year, month, day, now) {
|
|
63
|
+
const dob = new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10))
|
|
64
|
+
let age = now.getFullYear() - dob.getFullYear()
|
|
65
|
+
const hadBirthday = (now.getMonth() > dob.getMonth()) ||
|
|
66
|
+
(now.getMonth() === dob.getMonth() && now.getDate() >= dob.getDate())
|
|
67
|
+
if (!hadBirthday) age -= 1
|
|
68
|
+
return age
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// "1991-01-31" as the three strings the selects hold ("1991", "1", "31"), or
|
|
72
|
+
// three blanks for anything that is not a full date. Strings, because that is
|
|
73
|
+
// what a pick produces: an option renders :value="m.n", the DOM stringifies it
|
|
74
|
+
// and x-model writes the string back.
|
|
75
|
+
export function partsOfIso(value) {
|
|
76
|
+
const match = ISO_DATE.exec(String(value || ""))
|
|
77
|
+
if (!match) return { year: "", month: "", day: "" }
|
|
78
|
+
return { year: String(+match[1]), month: String(+match[2]), day: String(+match[3]) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The three parts as "1991-01-31", or "" until all three are picked.
|
|
82
|
+
export function isoOfParts(year, month, day) {
|
|
83
|
+
if (!(year && month && day)) return ""
|
|
84
|
+
return year + "-" + String(month).padStart(2, "0") + "-" + String(day).padStart(2, "0")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// What the profile form's dirty check compares. A complete date is its ISO
|
|
88
|
+
// string, so a finished edit compares against the value the page loaded with.
|
|
89
|
+
// An untouched empty field is "", the same as a person with no birthday on
|
|
90
|
+
// file. A partial pick is the three parts joined, so choosing only a month
|
|
91
|
+
// reads as a change.
|
|
92
|
+
export function dateSignature(year, month, day) {
|
|
93
|
+
if (year && month && day) return isoOfParts(year, month, day)
|
|
94
|
+
if (!year && !month && !day) return ""
|
|
95
|
+
return [year, month, day].join("-")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The date a modal entry's props carry back from the age-gate card
|
|
99
|
+
// (dobYear, dobMonth, dobDay), as select strings, or null when any part is
|
|
100
|
+
// missing. A day the restored month does not have comes back blank: a select
|
|
101
|
+
// holding a value its options do not contain reads as complete and would
|
|
102
|
+
// submit a date nobody picked.
|
|
103
|
+
export function returnedDob(props) {
|
|
104
|
+
props = props || {}
|
|
105
|
+
const y = parseInt(props.dobYear, 10)
|
|
106
|
+
const m = parseInt(props.dobMonth, 10)
|
|
107
|
+
const d = parseInt(props.dobDay, 10)
|
|
108
|
+
if (!y || !m || !d) return null
|
|
109
|
+
return { year: String(y), month: String(m), day: d <= daysInMonth(m, y) ? String(d) : "" }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The line a refusal reads when no age-gate card is registered.
|
|
113
|
+
export function refusalLine(minAge, stateCode) {
|
|
114
|
+
return "You must be " + minAge + "+" + (stateCode ? " in " + stateCode : "") + "."
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What the app's answer means. The app tells "too young" from "bad request":
|
|
118
|
+
// body.underage or a 403 is a refusal, which goes to the age-gate card; any
|
|
119
|
+
// other failure is an error, which stays on the card's own line.
|
|
120
|
+
export function verdictOf(response) {
|
|
121
|
+
const body = (response && response.body) || {}
|
|
122
|
+
if (response && response.ok && body.verified) return { verdict: "verified" }
|
|
123
|
+
if (body.underage === true || (response && response.status === 403)) {
|
|
124
|
+
return { verdict: "refused", message: body.error || body.message || "" }
|
|
125
|
+
}
|
|
126
|
+
return { verdict: "error", message: body.error || VERIFY_FAILED }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// x-data for the birthday card. The factory holds no policy: minAge is the
|
|
130
|
+
// app's (0 means the app supplied none), the age it computes is a hint, and
|
|
131
|
+
// the app's answer at `url` is the verdict.
|
|
132
|
+
//
|
|
133
|
+
// opts:
|
|
134
|
+
// minAge the app's minimum age.
|
|
135
|
+
// state a jurisdiction label, shown and never edited.
|
|
136
|
+
// url the app endpoint the date posts to.
|
|
137
|
+
// store the Alpine store the card is mounted in (default "modals").
|
|
138
|
+
// gateId the modal a refusal opens (the age-gate card). Blank, the
|
|
139
|
+
// refusal is the card's own error line.
|
|
140
|
+
// demo the style guide: answer locally, post nothing.
|
|
141
|
+
// demoUnderage the style guide: take the refused branch.
|
|
142
|
+
//
|
|
143
|
+
// env: { win, now } for the tests.
|
|
144
|
+
export function birthdayModal(opts, env) {
|
|
145
|
+
opts = opts || {}
|
|
146
|
+
const win = (env && env.win) || window
|
|
147
|
+
const now = (env && env.now) || new Date()
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
month: "", day: "", year: "",
|
|
151
|
+
submitting: false,
|
|
152
|
+
error: "",
|
|
153
|
+
minAge: parseInt(opts.minAge, 10) || 0,
|
|
154
|
+
stateCode: opts.state || "",
|
|
155
|
+
url: opts.url || "",
|
|
156
|
+
store: opts.store || "modals",
|
|
157
|
+
demo: !!opts.demo,
|
|
158
|
+
demoUnderage: !!opts.demoUnderage,
|
|
159
|
+
gateId: opts.gateId || "",
|
|
160
|
+
months: MONTHS,
|
|
161
|
+
// From 13 years ago back 100: a sane range to pick from, not an age bar.
|
|
162
|
+
years: yearsBack(now, 13, 100),
|
|
163
|
+
|
|
164
|
+
get dayOptions() {
|
|
165
|
+
return dayOptions(this.month, this.year)
|
|
166
|
+
},
|
|
167
|
+
get complete() {
|
|
168
|
+
return !!(this.month && this.day && this.year)
|
|
169
|
+
},
|
|
170
|
+
get computedAge() {
|
|
171
|
+
if (!this.complete) return null
|
|
172
|
+
return ageOn(this.year, this.month, this.day, now)
|
|
173
|
+
},
|
|
174
|
+
get isUnderage() {
|
|
175
|
+
return this.computedAge !== null && !!this.minAge && this.computedAge < this.minAge
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
onMonthChange() {
|
|
179
|
+
if (this.day && !dayExists(this.day, this.month, this.year)) this.day = ""
|
|
180
|
+
this.error = ""
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
// THE RETURN TRIP. _reject writes the date onto the store on its way to the
|
|
184
|
+
// age-gate card, and that card's back() hands the same three parts back, so
|
|
185
|
+
// a card mounted from a return trip finds the person's date on its own
|
|
186
|
+
// entry. Read off the store, where this factory already meets it.
|
|
187
|
+
_returnedDob() {
|
|
188
|
+
try {
|
|
189
|
+
const entry = win.Alpine.store(this.store).current()
|
|
190
|
+
return returnedDob(entry && entry.props)
|
|
191
|
+
} catch (_) {
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
// Alpine calls init() at mount, before it walks the three selects, so the
|
|
197
|
+
// restored date is their first state. The DATE is restored, never the
|
|
198
|
+
// verdict: the app decides again on the next submit.
|
|
199
|
+
init() {
|
|
200
|
+
const dob = this._returnedDob()
|
|
201
|
+
if (!dob) return
|
|
202
|
+
this.year = dob.year
|
|
203
|
+
this.month = dob.month
|
|
204
|
+
this.day = dob.day
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
// Accepted: flip the session flag if the page has one, close, and let the
|
|
208
|
+
// app resume what it was gating (it listens for `age-verified`).
|
|
209
|
+
_finish() {
|
|
210
|
+
try {
|
|
211
|
+
const session = win.Alpine && win.Alpine.store && win.Alpine.store("session")
|
|
212
|
+
if (session) session.ageVerified = true
|
|
213
|
+
} catch (_) {}
|
|
214
|
+
try { win.Alpine.store(this.store).close() } catch (_) {}
|
|
215
|
+
win.dispatchEvent(new win.CustomEvent("age-verified"))
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
// Refused: hand off to the age-gate card, which owns what to do next. A
|
|
219
|
+
// handoff and not an error, so the person is never left on a red card with
|
|
220
|
+
// nothing to press. swap(), so that card's back link returns here. The date
|
|
221
|
+
// crosses the store as three numbers, which a reactive proxy carries intact.
|
|
222
|
+
_reject(message) {
|
|
223
|
+
if (!this.gateId) {
|
|
224
|
+
this.error = message || this._refusalLine()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
win.Alpine.store(this.store).swap(this.gateId, {
|
|
229
|
+
minAge: this.minAge,
|
|
230
|
+
state: this.stateCode,
|
|
231
|
+
message: message || "",
|
|
232
|
+
dobYear: parseInt(this.year, 10) || null,
|
|
233
|
+
dobMonth: parseInt(this.month, 10) || null,
|
|
234
|
+
dobDay: parseInt(this.day, 10) || null
|
|
235
|
+
})
|
|
236
|
+
} catch (_) {
|
|
237
|
+
this.error = message || this._refusalLine()
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
_refusalLine() {
|
|
242
|
+
return refusalLine(this.minAge, this.stateCode)
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
// No refusal on the client: every complete date goes to the app, and its
|
|
246
|
+
// answer routes here.
|
|
247
|
+
submit() {
|
|
248
|
+
if (!this.complete || this.submitting) return
|
|
249
|
+
this.error = ""
|
|
250
|
+
this.submitting = true
|
|
251
|
+
const self = this
|
|
252
|
+
|
|
253
|
+
if (this.demo) {
|
|
254
|
+
win.setTimeout(function () {
|
|
255
|
+
self.submitting = false
|
|
256
|
+
if (self.demoUnderage || self.isUnderage) self._reject()
|
|
257
|
+
else self._finish()
|
|
258
|
+
}, DEMO_RESOLVE_MS)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const csrf = (win.document.querySelector('meta[name="csrf-token"]') || {}).content || ""
|
|
263
|
+
win.fetch(this.url, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
266
|
+
body: JSON.stringify({ year: this.year, month: this.month, day: this.day })
|
|
267
|
+
})
|
|
268
|
+
.then(function (response) {
|
|
269
|
+
return response.json().then(function (body) { return { ok: response.ok, status: response.status, body } })
|
|
270
|
+
})
|
|
271
|
+
.then(function (response) {
|
|
272
|
+
self.submitting = false
|
|
273
|
+
const answer = verdictOf(response)
|
|
274
|
+
if (answer.verdict === "verified") self._finish()
|
|
275
|
+
else if (answer.verdict === "refused") self._reject(answer.message)
|
|
276
|
+
else self.error = answer.message
|
|
277
|
+
})
|
|
278
|
+
.catch(function () {
|
|
279
|
+
self.submitting = false
|
|
280
|
+
self.error = NETWORK_ERROR
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// x-data for the profile page's birthday row: three selects in place of a date
|
|
287
|
+
// picker, so there is no popover to place and none to restore open. `initial`
|
|
288
|
+
// is the ISO date on file, or blank.
|
|
289
|
+
//
|
|
290
|
+
// It sits inside the profile form's scope (studio/profile_form), and reaches
|
|
291
|
+
// that scope's `fields` through Alpine's scope chain: publishing the picked
|
|
292
|
+
// date into fields.birthday is what raises the save bar.
|
|
293
|
+
export function studioBirthdayFields(initial, env) {
|
|
294
|
+
const today = () => (env && env.now) || new Date()
|
|
295
|
+
const parts = partsOfIso(initial)
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
year: parts.year,
|
|
299
|
+
month: parts.month,
|
|
300
|
+
day: parts.day,
|
|
301
|
+
months: MONTHS,
|
|
302
|
+
|
|
303
|
+
// This year back 120: a birthday is not in the future, and 120 covers
|
|
304
|
+
// every living person.
|
|
305
|
+
get years() {
|
|
306
|
+
return yearsBack(today(), 0, 120)
|
|
307
|
+
},
|
|
308
|
+
get dayOptions() {
|
|
309
|
+
return dayOptions(this.month, this.year)
|
|
310
|
+
},
|
|
311
|
+
get complete() {
|
|
312
|
+
return !!(this.year && this.month && this.day)
|
|
313
|
+
},
|
|
314
|
+
get isoValue() {
|
|
315
|
+
return isoOfParts(this.year, this.month, this.day)
|
|
316
|
+
},
|
|
317
|
+
get signature() {
|
|
318
|
+
return dateSignature(this.year, this.month, this.day)
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
// Named for what the shared field binds. Clears a day the new month or
|
|
322
|
+
// year does not have, then publishes.
|
|
323
|
+
onMonthChange() {
|
|
324
|
+
if (this.day && !dayExists(this.day, this.month, this.year)) this.day = ""
|
|
325
|
+
this.publish()
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
publish() {
|
|
329
|
+
if (this.fields) this.fields.birthday = this.signature
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
// DISCARD REACHES THE SELECTS through this watch. The form's discard
|
|
333
|
+
// reassigns its `fields`, which restores every input bound with x-model;
|
|
334
|
+
// the selects are bound to this scope's own parts, so they follow
|
|
335
|
+
// fields.birthday instead. The early return when the value already matches
|
|
336
|
+
// is what stops publish and watch from looping.
|
|
337
|
+
init() {
|
|
338
|
+
const self = this
|
|
339
|
+
this.publish()
|
|
340
|
+
this.$watch("fields.birthday", function (value) {
|
|
341
|
+
if (value === self.signature) return
|
|
342
|
+
self.adopt(value)
|
|
343
|
+
})
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
// Puts the selects on an ISO date; anything else empties all three.
|
|
347
|
+
adopt(value) {
|
|
348
|
+
Object.assign(this, partsOfIso(value))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// studio/confirm_interstitial: the scanner-safe sign-in page
|
|
2
|
+
// (studio/_confirm_interstitial) posts its own form. The GET that renders the
|
|
3
|
+
// page is inert; this POST is what burns the single-use token and signs in.
|
|
4
|
+
//
|
|
5
|
+
// The page is a whole document with no layout, no import map and no Stimulus
|
|
6
|
+
// application, so it loads this module by its own tag and the module runs on
|
|
7
|
+
// evaluation. It posts once: the form is stamped, so a second evaluation (a
|
|
8
|
+
// restored page) does not post again.
|
|
9
|
+
//
|
|
10
|
+
// THE FALLBACK BUTTON HAS TWO WAYS TO APPEAR, four seconds in. The page shows
|
|
11
|
+
// it by CSS alone, so a page whose script never arrives still has something to
|
|
12
|
+
// press. This module shows it too, by a timer, because an engine may hold a
|
|
13
|
+
// CSS animation while the post's navigation is pending (WebKit does, and the
|
|
14
|
+
// post is pending for exactly as long as the button is wanted).
|
|
15
|
+
//
|
|
16
|
+
// It imports nothing; test/javascript/confirm_interstitial.test.mjs loads it
|
|
17
|
+
// as a data: module.
|
|
18
|
+
|
|
19
|
+
export const FORM_ID = "magic-consume-form"
|
|
20
|
+
export const FALLBACK_ID = "magic-fallback"
|
|
21
|
+
// The class that shows the fallback (the page's own stylesheet).
|
|
22
|
+
export const SHOWN_CLASS = "is-shown"
|
|
23
|
+
export const FALLBACK_AFTER_MS = 4000
|
|
24
|
+
|
|
25
|
+
// Submits the form unless it has been submitted already. Answers whether it
|
|
26
|
+
// submitted.
|
|
27
|
+
export function autoSubmit(form) {
|
|
28
|
+
if (!form || form.dataset.autoSubmitted) return false
|
|
29
|
+
form.dataset.autoSubmitted = "1"
|
|
30
|
+
if (typeof form.requestSubmit === "function") form.requestSubmit()
|
|
31
|
+
else form.submit()
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shows the fallback block once `ms` have passed. Returns the timer.
|
|
36
|
+
export function revealFallbackLater(fallback, later, ms) {
|
|
37
|
+
if (!fallback) return null
|
|
38
|
+
return later(function () { fallback.classList.add(SHOWN_CLASS) }, ms == null ? FALLBACK_AFTER_MS : ms)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof document !== "undefined") {
|
|
42
|
+
autoSubmit(document.getElementById(FORM_ID))
|
|
43
|
+
revealFallbackLater(document.getElementById(FALLBACK_ID), setTimeout)
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// identity-mini: the compact identity bar's lifetime, on the engine's Stimulus
|
|
2
|
+
// application (studio/application, which reads data-studio-controller). It sits
|
|
3
|
+
// on the bar studio/profiles/_identity_mini renders:
|
|
4
|
+
//
|
|
5
|
+
// <div data-studio-identity-mini data-studio-controller="identity-mini"
|
|
6
|
+
// class="studio-identity-mini" inert>
|
|
7
|
+
//
|
|
8
|
+
// and watches the page's full card ([data-studio-identity-full]). When the bar
|
|
9
|
+
// shows is studio/identity_mini. connect() starts the watch, on a full load
|
|
10
|
+
// and on a Turbo visit alike; the bar goes back to hidden and inert before
|
|
11
|
+
// Turbo caches the page and when it leaves it.
|
|
12
|
+
//
|
|
13
|
+
// Registered statically by studio/application: on the edit page the bar
|
|
14
|
+
// carries Save and Discard once the card has scrolled away, so it arrives with
|
|
15
|
+
// the page.
|
|
16
|
+
import { Controller } from "@hotwired/stimulus"
|
|
17
|
+
import { IdentityMini, FULL_SELECTOR } from "studio/identity_mini"
|
|
18
|
+
|
|
19
|
+
export default class extends Controller {
|
|
20
|
+
connect() {
|
|
21
|
+
this.bar = new IdentityMini(this.element, document.querySelector(FULL_SELECTOR), window.IntersectionObserver)
|
|
22
|
+
this.bar.start()
|
|
23
|
+
this.onBeforeCache = () => this.bar.reset()
|
|
24
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
disconnect() {
|
|
28
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
29
|
+
if (this.bar) this.bar.reset()
|
|
30
|
+
this.bar = null
|
|
31
|
+
}
|
|
32
|
+
}
|