@scalemule/news-publication 0.0.69 → 0.0.72

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,347 @@
1
+ "use client";
2
+ import { useState, useEffect, useId } from 'react';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+
5
+ var READER_SSO_BOT_PATTERN = /bot|crawl|spider|headless|lighthouse|pagespeed|preview|slurp|facebookexternalhit|embedly|python|curl|wget|phantom|puppeteer|playwright|selenium|prerender/i;
6
+ var READER_SSO_PRIVATE_PATHS = [
7
+ /^\/account(\/|$)/,
8
+ /^\/api(\/|$)/,
9
+ /^\/review(\/|$)/,
10
+ /^\/reset-password(\/|$)/,
11
+ /^\/verify-email(\/|$)/,
12
+ /^\/claim(\/|$)/,
13
+ /^\/tip(\/|$)/,
14
+ /^\/news\/unsubscribe(\/|$)/,
15
+ /^\/pets\/account(\/|$)/,
16
+ /^\/newsletter(\/|$)/
17
+ ];
18
+ var FLOW_QUERY = ["signin", "signed_out", "switch", "link", "subscription", "code", "state", "error"];
19
+ var READER_SSO_ATTEMPT_WINDOW_MS = 5 * 6e4;
20
+ function silentSignInDecision(env) {
21
+ if (!env.enabled) return { attempt: false, reason: "disabled" };
22
+ if (env.hasSession) return { attempt: false, reason: "session" };
23
+ if (env.suppressed) return { attempt: false, reason: "suppressed" };
24
+ if (env.webdriver) return { attempt: false, reason: "webdriver" };
25
+ if (!env.userAgent || READER_SSO_BOT_PATTERN.test(env.userAgent)) return { attempt: false, reason: "bot" };
26
+ if (env.framed) return { attempt: false, reason: "framed" };
27
+ if (env.prerendering || env.visibilityState !== "visible") return { attempt: false, reason: "hidden" };
28
+ if ((env.privatePaths ?? READER_SSO_PRIVATE_PATHS).some((pattern) => pattern.test(env.pathname))) return { attempt: false, reason: "private" };
29
+ const params = new URLSearchParams(env.search);
30
+ if (FLOW_QUERY.some((key) => params.has(key))) return { attempt: false, reason: "flow" };
31
+ if (env.fieldFocused) return { attempt: false, reason: "typing" };
32
+ if (env.lastAttemptAt !== null && env.now - env.lastAttemptAt < READER_SSO_ATTEMPT_WINDOW_MS) return { attempt: false, reason: "recent" };
33
+ return { attempt: true };
34
+ }
35
+ function silentSignInUrl(startPath, pathname, search) {
36
+ return `${startPath}?${new URLSearchParams({ mode: "silent", next: pathname + search })}`;
37
+ }
38
+ function isFormField(element) {
39
+ if (!element) return false;
40
+ const tag = element.tagName;
41
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || element.isContentEditable === true;
42
+ }
43
+ var ATTEMPT_KEY = "reader-sso-attempt-v1";
44
+ function readAttempt() {
45
+ try {
46
+ const raw = window.sessionStorage.getItem(ATTEMPT_KEY);
47
+ const value = raw ? Number(raw) : NaN;
48
+ return Number.isFinite(value) ? value : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+ function writeAttempt(now) {
54
+ try {
55
+ window.sessionStorage.setItem(ATTEMPT_KEY, String(now));
56
+ } catch {
57
+ }
58
+ }
59
+ function browserEnvironment(flags) {
60
+ const doc = document;
61
+ let framed = false;
62
+ try {
63
+ framed = window.top !== window.self;
64
+ } catch {
65
+ framed = true;
66
+ }
67
+ return {
68
+ ...flags,
69
+ webdriver: navigator.webdriver === true,
70
+ userAgent: navigator.userAgent,
71
+ pathname: location.pathname,
72
+ search: location.search,
73
+ fieldFocused: isFormField(document.activeElement),
74
+ prerendering: doc.prerendering === true,
75
+ visibilityState: document.visibilityState,
76
+ framed,
77
+ lastAttemptAt: readAttempt(),
78
+ now: Date.now()
79
+ };
80
+ }
81
+ function ReaderSsoBoot({ enabled = true, hasSession: hasSessionCookie, suppressed, startPath = "/api/news/ledvery-start", sessionPath = "/api/news/session", privatePaths, idleDelayMs = 1500 }) {
82
+ const [staleCookie, setStaleCookie] = useState(false);
83
+ useEffect(() => {
84
+ if (!enabled || !hasSessionCookie || staleCookie) return;
85
+ let cancelled = false;
86
+ fetch(sessionPath, { credentials: "same-origin", cache: "no-store" }).then((response) => response.json()).then((body) => {
87
+ if (!cancelled && body?.authenticated !== true) setStaleCookie(true);
88
+ }).catch(() => {
89
+ });
90
+ return () => {
91
+ cancelled = true;
92
+ };
93
+ }, [enabled, hasSessionCookie, staleCookie, sessionPath]);
94
+ const hasSession = hasSessionCookie && !staleCookie;
95
+ useEffect(() => {
96
+ if (silentSignInDecision(browserEnvironment({ enabled, hasSession, suppressed, privatePaths })).attempt !== true) return;
97
+ let cancelled = false;
98
+ let idle;
99
+ const go = () => {
100
+ if (cancelled) return;
101
+ const env = browserEnvironment({ enabled, hasSession, suppressed, privatePaths });
102
+ if (silentSignInDecision(env).attempt !== true) return;
103
+ writeAttempt(env.now);
104
+ window.location.assign(silentSignInUrl(startPath, env.pathname, env.search));
105
+ };
106
+ const whenIdle = () => {
107
+ const w = window;
108
+ if (typeof w.requestIdleCallback === "function") idle = w.requestIdleCallback(() => {
109
+ idle = window.setTimeout(go, idleDelayMs);
110
+ }, { timeout: 4e3 });
111
+ else idle = window.setTimeout(go, idleDelayMs);
112
+ };
113
+ if (document.readyState === "complete") whenIdle();
114
+ else window.addEventListener("load", whenIdle, { once: true });
115
+ return () => {
116
+ cancelled = true;
117
+ window.removeEventListener("load", whenIdle);
118
+ if (idle !== void 0) window.clearTimeout(idle);
119
+ };
120
+ }, [enabled, hasSession, suppressed, startPath, privatePaths, idleDelayMs]);
121
+ return null;
122
+ }
123
+ function firstName(fullName) {
124
+ if (typeof fullName !== "string") return "";
125
+ const token = fullName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().split(/\s+/u)[0] ?? "";
126
+ return Array.from(token).slice(0, 40).join("");
127
+ }
128
+ function localHour(now, timeZone, locale) {
129
+ const options = { hour: "numeric", hourCycle: "h23" };
130
+ const attempts = [[locale ?? void 0, timeZone ?? void 0], [void 0, timeZone ?? void 0], [void 0, void 0]];
131
+ for (const [loc, tz] of attempts) {
132
+ try {
133
+ const part = new Intl.DateTimeFormat(loc, { ...options, ...tz ? { timeZone: tz } : {} }).formatToParts(now).find((p) => p.type === "hour");
134
+ const hour = part ? Number(part.value) : NaN;
135
+ if (Number.isInteger(hour) && hour >= 0 && hour < 24) return hour;
136
+ } catch {
137
+ }
138
+ }
139
+ return now.getHours();
140
+ }
141
+ function greetingWord(hour) {
142
+ if (hour >= 5 && hour < 12) return "Good morning";
143
+ if (hour >= 12 && hour < 18) return "Good afternoon";
144
+ return "Good evening";
145
+ }
146
+ function greetingText(profile, now = /* @__PURE__ */ new Date(), fallback = "Welcome back") {
147
+ const name = firstName(profile.full_name);
148
+ if (!name) return fallback;
149
+ return `${greetingWord(localHour(now, profile.time_zone, profile.locale))}, ${name}`;
150
+ }
151
+ function ReaderGreeting({ endpoint = "/api/news/session", className, fallback = "Welcome back", as: Tag = "span" }) {
152
+ const [text, setText] = useState(null);
153
+ useEffect(() => {
154
+ let alive = true;
155
+ const controller = new AbortController();
156
+ fetch(endpoint, { credentials: "same-origin", cache: "no-store", signal: controller.signal }).then((response) => response.ok ? response.json() : null).then((summary) => {
157
+ if (!alive || !summary || summary.authenticated !== true) return;
158
+ setText(greetingText(summary, /* @__PURE__ */ new Date(), fallback));
159
+ }).catch(() => {
160
+ });
161
+ return () => {
162
+ alive = false;
163
+ controller.abort();
164
+ };
165
+ }, [endpoint, fallback]);
166
+ if (text === null) return null;
167
+ return /* @__PURE__ */ jsx(Tag, { className, "data-reader-greeting": "", children: text });
168
+ }
169
+ async function postJson(endpoint, body, timeoutMs = 3e4) {
170
+ const response = await fetch(endpoint, {
171
+ method: "POST",
172
+ headers: { "content-type": "application/json" },
173
+ body: JSON.stringify(body),
174
+ credentials: "same-origin",
175
+ signal: AbortSignal.timeout(timeoutMs)
176
+ });
177
+ const data = await response.json().catch(() => ({}));
178
+ if (!response.ok) throw Object.assign(new Error(data.error || "Please try again."), { mfaRequired: data.mfa_required === true });
179
+ return data;
180
+ }
181
+ function GoogleMark() {
182
+ return /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", "aria-hidden": "true", children: [
183
+ /* @__PURE__ */ jsx("path", { d: "M21.805 10.023H12.24v3.955h5.483c-.237 1.273-.958 2.352-2.042 3.073v2.552h3.305c1.936-1.782 3.05-4.41 3.05-7.603 0-.676-.06-1.326-.171-1.977Z", fill: "#4285F4" }),
184
+ /* @__PURE__ */ jsx("path", { d: "M12.24 22c2.745 0 5.047-.909 6.73-2.447l-3.305-2.552c-.918.615-2.094.978-3.425.978-2.636 0-4.87-1.78-5.67-4.173H3.154v2.632A10.16 10.16 0 0 0 12.24 22Z", fill: "#34A853" }),
185
+ /* @__PURE__ */ jsx("path", { d: "M6.57 13.806A6.112 6.112 0 0 1 6.253 12c0-.627.109-1.235.317-1.806V7.562H3.154A10.157 10.157 0 0 0 2 12c0 1.633.39 3.177 1.154 4.438l3.416-2.632Z", fill: "#FBBC04" }),
186
+ /* @__PURE__ */ jsx("path", { d: "M12.24 6.02c1.493 0 2.833.514 3.888 1.523l2.913-2.913C17.281 2.998 14.979 2 12.24 2A10.16 10.16 0 0 0 3.154 7.562l3.416 2.632c.8-2.393 3.034-4.174 5.67-4.174Z", fill: "#EA4335" })
187
+ ] });
188
+ }
189
+ function MailMark() {
190
+ return /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
191
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "5", width: "18", height: "14", rx: "2" }),
192
+ /* @__PURE__ */ jsx("path", { d: "m3 7 9 6 9-6" })
193
+ ] });
194
+ }
195
+ var LEDVERY_BUTTON_LABELS = {
196
+ google: { label: "Continue with Google", busy: "Opening Google\u2026" },
197
+ email: { label: "Continue with email", busy: "Opening sign-in\u2026" }
198
+ };
199
+ function LedveryButton({
200
+ provider = "google",
201
+ subscribe = false,
202
+ disabled = false,
203
+ returnTo = "",
204
+ startPath = "/api/news/ledvery-start",
205
+ label,
206
+ busyLabel,
207
+ description,
208
+ classNames = {},
209
+ onStart
210
+ }) {
211
+ const [busy, setBusy] = useState(false);
212
+ const [error, setError] = useState("");
213
+ const descriptionId = useId();
214
+ const labels = LEDVERY_BUTTON_LABELS[provider];
215
+ return /* @__PURE__ */ jsxs("div", { className: classNames.wrapper, "data-reader-signin": provider, children: [
216
+ /* @__PURE__ */ jsxs(
217
+ "button",
218
+ {
219
+ type: "button",
220
+ className: classNames.button,
221
+ disabled: busy || disabled,
222
+ "aria-describedby": description ? descriptionId : void 0,
223
+ onClick: async () => {
224
+ setBusy(true);
225
+ setError("");
226
+ try {
227
+ onStart?.(provider);
228
+ } catch {
229
+ }
230
+ try {
231
+ const data = await postJson(startPath, { provider, subscribe, consent: subscribe, returnTo });
232
+ if (!data.url) throw new Error("Sign-in is temporarily unavailable. Please try again.");
233
+ window.location.assign(data.url);
234
+ } catch (e) {
235
+ setError(e instanceof Error ? e.message : "Please try again.");
236
+ setBusy(false);
237
+ }
238
+ },
239
+ children: [
240
+ provider === "google" ? /* @__PURE__ */ jsx(GoogleMark, {}) : /* @__PURE__ */ jsx(MailMark, {}),
241
+ /* @__PURE__ */ jsx("span", { children: busy ? busyLabel ?? labels.busy : label ?? labels.label })
242
+ ]
243
+ }
244
+ ),
245
+ description && /* @__PURE__ */ jsx("p", { id: descriptionId, className: classNames.fine, children: description }),
246
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
247
+ ] });
248
+ }
249
+ function AccountLinkForm({
250
+ challengeId,
251
+ hint = "",
252
+ endpoint = "/api/news/ledvery-link",
253
+ next = "",
254
+ subscribe = false,
255
+ classNames = {},
256
+ onLinked,
257
+ onCancel
258
+ }) {
259
+ const [busy, setBusy] = useState(false);
260
+ const [error, setError] = useState("");
261
+ const [password, setPassword] = useState("");
262
+ const [mfaRequired, setMfaRequired] = useState(false);
263
+ const [code, setCode] = useState("");
264
+ return /* @__PURE__ */ jsxs(
265
+ "form",
266
+ {
267
+ className: classNames.form,
268
+ "data-reader-link": "",
269
+ onSubmit: (event) => {
270
+ event.preventDefault();
271
+ setBusy(true);
272
+ setError("");
273
+ postJson(endpoint, { challenge_id: challengeId, password, next, subscribe, ...mfaRequired && code ? { mfa_code: code } : {} }).then((result) => {
274
+ setPassword("");
275
+ setCode("");
276
+ if (onLinked) onLinked(result);
277
+ else window.location.assign(result.location || "/account");
278
+ }).catch((e) => {
279
+ if (e?.mfaRequired) setMfaRequired(true);
280
+ setCode("");
281
+ setError(e instanceof Error ? e.message : "Please try again.");
282
+ setBusy(false);
283
+ });
284
+ },
285
+ children: [
286
+ /* @__PURE__ */ jsx("h2", { children: "You already have a reader account" }),
287
+ /* @__PURE__ */ jsxs("p", { className: classNames.fine, children: [
288
+ hint ? /* @__PURE__ */ jsxs(Fragment, { children: [
289
+ "An account for ",
290
+ /* @__PURE__ */ jsx("strong", { children: hint }),
291
+ " already exists. "
292
+ ] }) : null,
293
+ "Enter its password once to connect your sign-in. Your preferences and subscriptions stay as they are."
294
+ ] }),
295
+ /* @__PURE__ */ jsxs("label", { children: [
296
+ "Reader account password",
297
+ /* @__PURE__ */ jsx("input", { type: "password", autoComplete: "current-password", required: true, minLength: 1, maxLength: 1024, value: password, onChange: (e) => setPassword(e.target.value) })
298
+ ] }),
299
+ mfaRequired && /* @__PURE__ */ jsxs("label", { children: [
300
+ "Authentication code",
301
+ /* @__PURE__ */ jsx("input", { type: "text", inputMode: "numeric", autoComplete: "one-time-code", required: true, maxLength: 32, value: code, onChange: (e) => setCode(e.target.value) })
302
+ ] }),
303
+ /* @__PURE__ */ jsx("button", { className: classNames.button, disabled: busy || !password || mfaRequired && !code, children: busy ? "Connecting\u2026" : "Connect and sign in" }),
304
+ onCancel && /* @__PURE__ */ jsx("button", { type: "button", className: classNames.secondary, disabled: busy, onClick: onCancel, children: "Start over" }),
305
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
306
+ ]
307
+ }
308
+ );
309
+ }
310
+ function AccountSwitchPrompt({
311
+ currentName,
312
+ pendingName,
313
+ endpoint = "/api/news/switch-account",
314
+ classNames = {},
315
+ onDone
316
+ }) {
317
+ const [busy, setBusy] = useState(null);
318
+ const [error, setError] = useState("");
319
+ const current = firstName(currentName) || "your current account";
320
+ const pending = firstName(pendingName) || "the new account";
321
+ const choose = (choice) => {
322
+ setBusy(choice);
323
+ setError("");
324
+ postJson(endpoint, { choice }).then((result) => {
325
+ if (onDone) onDone({ ...result, choice });
326
+ else window.location.assign(result.location || "/account");
327
+ }).catch((e) => {
328
+ setError(e instanceof Error ? e.message : "Please try again.");
329
+ setBusy(null);
330
+ });
331
+ };
332
+ return /* @__PURE__ */ jsxs("div", { className: classNames.wrapper, "data-reader-switch": "", role: "group", "aria-label": "Choose an account", children: [
333
+ /* @__PURE__ */ jsx("h2", { children: "Which account would you like to use?" }),
334
+ /* @__PURE__ */ jsxs("p", { className: classNames.fine, children: [
335
+ "You are signed in as ",
336
+ current,
337
+ ". A different account, ",
338
+ pending,
339
+ ", just signed in through Ledvery."
340
+ ] }),
341
+ /* @__PURE__ */ jsx("button", { type: "button", className: classNames.button, disabled: busy !== null, onClick: () => choose("continue"), children: busy === "continue" ? "Switching\u2026" : `Continue as ${pending}` }),
342
+ /* @__PURE__ */ jsx("button", { type: "button", className: classNames.secondary, disabled: busy !== null, onClick: () => choose("stay"), children: busy === "stay" ? "One moment\u2026" : `Stay as ${current}` }),
343
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: classNames.error, children: error })
344
+ ] });
345
+ }
346
+
347
+ export { AccountLinkForm, AccountSwitchPrompt, LEDVERY_BUTTON_LABELS, LedveryButton, READER_SSO_ATTEMPT_WINDOW_MS, READER_SSO_BOT_PATTERN, READER_SSO_PRIVATE_PATHS, ReaderGreeting, ReaderSsoBoot, firstName, greetingText, greetingWord, isFormField, localHour, silentSignInDecision, silentSignInUrl };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Reader sign-in telemetry registry (ADR-2026-10-19 section 8). Isomorphic on
3
+ * purpose: a site's analytics schema is shared with its browser bundle, so this
4
+ * fragment must not import anything server-only.
5
+ */
6
+ type ReaderSsoEventName = "reader_signin_started" | "reader_signin_completed" | "reader_signin_failed" | "reader_sso_check" | "reader_signed_out" | "reader_account_linked";
7
+ type ReaderSsoEventDefinition = {
8
+ category: "navigation" | "engagement" | "content" | "conversion" | "system";
9
+ source: "server";
10
+ };
11
+ /** Merge into the site's EVENTS registry: `{ ...EVENTS, ...READER_SSO_EVENTS }`. Server-recorded only. */
12
+ declare const READER_SSO_EVENTS: Record<ReaderSsoEventName, ReaderSsoEventDefinition>;
13
+ /** Property keys the site's analytics sanitizer must allow for these events (bounded short tokens). */
14
+ declare const READER_SSO_PROPERTY_KEYS: readonly ["method", "mode", "is_new", "reason", "outcome", "transaction"];
15
+
16
+ export { READER_SSO_EVENTS as R, READER_SSO_PROPERTY_KEYS as a, type ReaderSsoEventDefinition as b, type ReaderSsoEventName as c };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Reader sign-in telemetry registry (ADR-2026-10-19 section 8). Isomorphic on
3
+ * purpose: a site's analytics schema is shared with its browser bundle, so this
4
+ * fragment must not import anything server-only.
5
+ */
6
+ type ReaderSsoEventName = "reader_signin_started" | "reader_signin_completed" | "reader_signin_failed" | "reader_sso_check" | "reader_signed_out" | "reader_account_linked";
7
+ type ReaderSsoEventDefinition = {
8
+ category: "navigation" | "engagement" | "content" | "conversion" | "system";
9
+ source: "server";
10
+ };
11
+ /** Merge into the site's EVENTS registry: `{ ...EVENTS, ...READER_SSO_EVENTS }`. Server-recorded only. */
12
+ declare const READER_SSO_EVENTS: Record<ReaderSsoEventName, ReaderSsoEventDefinition>;
13
+ /** Property keys the site's analytics sanitizer must allow for these events (bounded short tokens). */
14
+ declare const READER_SSO_PROPERTY_KEYS: readonly ["method", "mode", "is_new", "reason", "outcome", "transaction"];
15
+
16
+ export { READER_SSO_EVENTS as R, READER_SSO_PROPERTY_KEYS as a, type ReaderSsoEventDefinition as b, type ReaderSsoEventName as c };