create-ailk 0.25.1 → 0.26.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.
- package/README.md +12 -3
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +12 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +29 -0
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +188 -18
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +15 -0
- package/templates/apps/api/.env.example +21 -0
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/CLAUDE.md +22 -1
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +33 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/versions.json +48 -48
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*
|
|
20
20
|
* After any of the three, one plain line when some of the member's billing
|
|
21
21
|
* is on a Stripe account this spoke's key cannot read (#8217 D12), then
|
|
22
|
-
* each section a module contributes (`accountBillingSections`, #8231 D4)
|
|
22
|
+
* each section a module contributes (`accountBillingSections`, #8231 D4),
|
|
23
|
+
* then each section the site adds (`siteBillingSections`, #9401 D7).
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
|
|
@@ -29,6 +30,7 @@ import { getTranslations } from "next-intl/server";
|
|
|
29
30
|
import type { ReactElement } from "react";
|
|
30
31
|
|
|
31
32
|
import { accountBillingSections } from "../../../../../lib/account-config";
|
|
33
|
+
import { siteBillingSections } from "../../../../../lib/site-contributions";
|
|
32
34
|
import { accountSession } from "../account-data";
|
|
33
35
|
import { openBillingPortal } from "../actions";
|
|
34
36
|
import { type BillingState, loadBilling } from "../billing-source";
|
|
@@ -161,7 +163,7 @@ export default async function AccountBillingPage({ params }: Props): Promise<Rea
|
|
|
161
163
|
</div>
|
|
162
164
|
) : null}
|
|
163
165
|
|
|
164
|
-
{accountBillingSections.map((section) => (
|
|
166
|
+
{[...accountBillingSections, ...siteBillingSections].map((section) => (
|
|
165
167
|
<SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
|
|
166
168
|
{section.render(ctx)}
|
|
167
169
|
</SettingsSection>
|
|
@@ -20,18 +20,23 @@
|
|
|
20
20
|
* current pathname (via `usePathname()`) so a successful social sign-in
|
|
21
21
|
* lands back on the exact dashboard page the visitor requested.
|
|
22
22
|
*
|
|
23
|
-
* Email sign-in link (#7435 § D9): when the layout passes `
|
|
24
|
-
* gate renders `/sign-in`'s own `SignInCard` — the same
|
|
25
|
-
* me a
|
|
26
|
-
* has no submit wiring and would otherwise sit inert
|
|
27
|
-
* Without the prop every render is what it was before.
|
|
23
|
+
* Email sign-in link (#7435 § D9): when the layout passes `card` with
|
|
24
|
+
* `emailLink`, the gate renders `/sign-in`'s own `SignInCard` — the same
|
|
25
|
+
* providers plus "Send me a magic link" — in place of `AuthPanel`, whose
|
|
26
|
+
* email + password form has no submit wiring and would otherwise sit inert
|
|
27
|
+
* beside the new field. Without the prop every render is what it was before.
|
|
28
|
+
*
|
|
29
|
+
* The one card (#9662): on the member `/account` gate the layout passes
|
|
30
|
+
* `card` whenever a method is on, with the site's mark, the "Don't have an
|
|
31
|
+
* account? Sign up" line and the Terms + Privacy acknowledgement, so the gate
|
|
32
|
+
* renders the same card `/sign-in` does.
|
|
28
33
|
*/
|
|
29
34
|
|
|
30
35
|
import { EmptyState, Link } from "@working-theory/ui/primitives";
|
|
31
36
|
import { AuthPanel, type AuthPanelProvider } from "@working-theory/ui/sections";
|
|
32
37
|
import { createAuthClient } from "better-auth/react";
|
|
33
38
|
import { usePathname } from "next/navigation";
|
|
34
|
-
import type { ReactElement } from "react";
|
|
39
|
+
import type { ReactElement, ReactNode } from "react";
|
|
35
40
|
|
|
36
41
|
import { SignInCard } from "../(auth)/sign-in/sign-in-card";
|
|
37
42
|
import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
@@ -52,17 +57,25 @@ export interface SignInGateProps {
|
|
|
52
57
|
/** The active social providers, resolved server-side by the layout (#7419). */
|
|
53
58
|
providers: AuthPanelProvider[];
|
|
54
59
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
60
|
+
* Renders `SignInCard` in place of `AuthPanel` — present when the email
|
|
61
|
+
* sign-in link is on (#7435) or on the member gate (#9662). The card's copy,
|
|
62
|
+
* mark, prospect line and footnote are resolved server-side by the layout.
|
|
57
63
|
*/
|
|
58
|
-
|
|
64
|
+
card?: {
|
|
65
|
+
text: SignInCardText;
|
|
66
|
+
/** Offer "Send me a magic link" — the email sign-in link is on. */
|
|
67
|
+
emailLink: boolean;
|
|
68
|
+
logo?: ReactNode;
|
|
69
|
+
prospect?: { prompt: string; linkLabel: string; href: string };
|
|
70
|
+
footnote?: ReactNode;
|
|
71
|
+
};
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
export function SignInGate({
|
|
62
75
|
labels,
|
|
63
76
|
signInPath,
|
|
64
77
|
providers,
|
|
65
|
-
|
|
78
|
+
card,
|
|
66
79
|
}: SignInGateProps): ReactElement {
|
|
67
80
|
const pathname = usePathname();
|
|
68
81
|
const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
|
|
@@ -79,25 +92,35 @@ export function SignInGate({
|
|
|
79
92
|
</Link>
|
|
80
93
|
);
|
|
81
94
|
|
|
82
|
-
if (
|
|
83
|
-
const { text } =
|
|
95
|
+
if (card) {
|
|
96
|
+
const { text } = card;
|
|
84
97
|
return (
|
|
85
98
|
<div
|
|
86
99
|
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
|
|
87
100
|
data-testid="sign-in-gate"
|
|
88
101
|
>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
{/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
|
|
103
|
+
<div className="w-full max-w-100">
|
|
104
|
+
<SignInCard
|
|
105
|
+
providers={providers}
|
|
106
|
+
method="sso"
|
|
107
|
+
next={pathname}
|
|
108
|
+
labels={text.labels}
|
|
109
|
+
notConfiguredSubhead={text.notConfiguredSubhead}
|
|
110
|
+
errorLabels={text.errorLabels}
|
|
111
|
+
emailLink={card.emailLink}
|
|
112
|
+
errorCallbackURL={signInHref}
|
|
113
|
+
signInHref={signInHref}
|
|
114
|
+
emailLinkLabels={text.emailLinkLabels}
|
|
115
|
+
{...(card.logo !== undefined ? { logo: card.logo } : {})}
|
|
116
|
+
{...(card.prospect !== undefined
|
|
117
|
+
? { prospect: card.prospect }
|
|
118
|
+
: {})}
|
|
119
|
+
{...(card.footnote !== undefined
|
|
120
|
+
? { footnote: card.footnote }
|
|
121
|
+
: {})}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
101
124
|
{signInLink}
|
|
102
125
|
</div>
|
|
103
126
|
);
|
|
@@ -11,9 +11,11 @@ import { readLocales } from "@working-theory/web-i18n";
|
|
|
11
11
|
import { getTranslations } from "next-intl/server";
|
|
12
12
|
import type { ReactElement } from "react";
|
|
13
13
|
|
|
14
|
+
import { legalFootnote } from "../(auth)/legal-footnote";
|
|
14
15
|
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
15
16
|
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
16
17
|
import { isAccountPath } from "../../../lib/auth-routes";
|
|
18
|
+
import { siteLogo } from "../../../lib/site-logo";
|
|
17
19
|
|
|
18
20
|
import { SignInGate } from "./sign-in-gate";
|
|
19
21
|
|
|
@@ -39,9 +41,7 @@ export async function SignedOutGate({
|
|
|
39
41
|
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
40
42
|
});
|
|
41
43
|
const active = getActiveProviderIds();
|
|
42
|
-
const emailLink = active.includes("magic-link")
|
|
43
|
-
? { text: await resolveSignInCardText(locale) }
|
|
44
|
-
: undefined;
|
|
44
|
+
const emailLink = active.includes("magic-link");
|
|
45
45
|
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
46
46
|
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
47
47
|
);
|
|
@@ -75,6 +75,25 @@ export async function SignedOutGate({
|
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// The sign-in card: with the email link on, and on the member gate, which
|
|
79
|
+
// also wears the one card's frame — the mark, the toggle line to /sign-up
|
|
80
|
+
// and the Terms + Privacy acknowledgement (#9662).
|
|
81
|
+
const text =
|
|
82
|
+
emailLink || isAccount ? await resolveSignInCardText(locale) : undefined;
|
|
83
|
+
const card = text
|
|
84
|
+
? {
|
|
85
|
+
text,
|
|
86
|
+
emailLink,
|
|
87
|
+
...(isAccount
|
|
88
|
+
? {
|
|
89
|
+
logo: siteLogo(),
|
|
90
|
+
prospect: { ...text.prospect, href: localeHref(locale, "/sign-up") },
|
|
91
|
+
footnote: legalFootnote(locale, text.acknowledgement),
|
|
92
|
+
}
|
|
93
|
+
: {}),
|
|
94
|
+
}
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
78
97
|
return (
|
|
79
98
|
<SignInGate
|
|
80
99
|
labels={{
|
|
@@ -84,7 +103,7 @@ export async function SignedOutGate({
|
|
|
84
103
|
}}
|
|
85
104
|
signInPath={signInPath}
|
|
86
105
|
providers={providers}
|
|
87
|
-
{...(
|
|
106
|
+
{...(card ? { card } : {})}
|
|
88
107
|
/>
|
|
89
108
|
);
|
|
90
109
|
}
|
|
@@ -952,7 +952,9 @@ export default async function LocaleLayout({
|
|
|
952
952
|
* cookie read — same #5557 hydration-safety property, no gate
|
|
953
953
|
* needed. This also means the banner never blocks the page: it
|
|
954
954
|
* reserves its own space (D1) and carries a close-without-choosing
|
|
955
|
-
* control (D2) instead of a hard server-side hide.
|
|
955
|
+
* control (D2) instead of a hard server-side hide. The standard
|
|
956
|
+
* chrome's compact card has no close control (#9656 D5): it is a
|
|
957
|
+
* non-modal corner card and Decline All is one tap.
|
|
956
958
|
*/}
|
|
957
959
|
{isAccountRoute ? null : (
|
|
958
960
|
<ConsentBanner
|
|
@@ -961,18 +963,36 @@ export default async function LocaleLayout({
|
|
|
961
963
|
consentCookieName={CONSENT_COOKIE_NAME}
|
|
962
964
|
consentDecided={consentDecided}
|
|
963
965
|
title={tConsent("title")}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
966
|
+
// #9656 D7 — the standard chrome mounts the compact floating
|
|
967
|
+
// card on every width, its Privacy Policy link on the button
|
|
968
|
+
// row; the minimal chrome keeps its hairline band (#7742 D5),
|
|
969
|
+
// the statement with the link inline.
|
|
970
|
+
{...(isMinimalChrome
|
|
971
|
+
? {
|
|
972
|
+
message: (
|
|
973
|
+
<>
|
|
974
|
+
{consentBannerStatement}{" "}
|
|
975
|
+
<Link
|
|
976
|
+
href={localeHref(locale, "/privacy")}
|
|
977
|
+
variant="unstyled"
|
|
978
|
+
className="font-semibold text-current underline underline-offset-4"
|
|
979
|
+
>
|
|
980
|
+
{tConsent("policyLinkLabel")}
|
|
981
|
+
</Link>
|
|
982
|
+
</>
|
|
983
|
+
),
|
|
984
|
+
}
|
|
985
|
+
: {
|
|
986
|
+
message: consentBannerStatement,
|
|
987
|
+
presentation: "floating" as const,
|
|
988
|
+
corner: "bottom-left" as const,
|
|
989
|
+
consentAppearance: "compact" as const,
|
|
990
|
+
mobileConsent: "inherit" as const,
|
|
991
|
+
consentPolicyLink: {
|
|
992
|
+
label: tConsent("policyLinkLabel"),
|
|
993
|
+
href: "/privacy",
|
|
994
|
+
},
|
|
995
|
+
})}
|
|
976
996
|
dismissible={false}
|
|
977
997
|
dismissLabel={tConsent("dismissLabel")}
|
|
978
998
|
acceptLabel={tConsent("acceptLabel")}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
"use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* BookingHandOff — `/meet/confirmed`'s booked time and Slack next step
|
|
6
|
+
* (#9853 D10). The booker writes one `sessionStorage` item before it
|
|
7
|
+
* navigates here (`BOOKING_CONFIRMATION_STORAGE_KEY`); this reads it once and
|
|
8
|
+
* removes it, the `/thanks` page's `AccountInviteLink` pattern, so the page's
|
|
9
|
+
* address carries nothing about the booking and a later visit shows nothing.
|
|
10
|
+
*
|
|
11
|
+
* It renders only an item written for this page (`to`'s path, without its
|
|
12
|
+
* query or hash, is `/meet/confirmed` or `/<locale>/meet/confirmed`) in the last ten
|
|
13
|
+
* minutes. The time is formatted in the zone Cal.com recorded, with the zone
|
|
14
|
+
* named, so the page and the Slack welcome name the same one; a zone `Intl`
|
|
15
|
+
* does not know renders nothing. Every string is a prop the server page read
|
|
16
|
+
* from the `schedule` namespace.
|
|
17
|
+
*/
|
|
18
|
+
import { Container, Heading, Text } from "@working-theory/ui";
|
|
19
|
+
import {
|
|
20
|
+
BOOKING_CONFIRMATION_STORAGE_KEY,
|
|
21
|
+
BookingConfirmationHandOffSchema,
|
|
22
|
+
} from "@working-theory/validation";
|
|
23
|
+
import type { ReactElement } from "react";
|
|
24
|
+
import { useEffect, useState } from "react";
|
|
25
|
+
|
|
26
|
+
/** A reserved name that never resolves: only used to read the path of `to`. */
|
|
27
|
+
const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
|
|
28
|
+
const CONFIRMED_PATH = "/meet/confirmed";
|
|
29
|
+
const MAX_AGE_MS = 10 * 60_000;
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
locale: string;
|
|
33
|
+
/** `schedule.confirmed.bookedTimeHeading`. */
|
|
34
|
+
bookedTimeHeading: string;
|
|
35
|
+
/** `schedule.confirmed.slackHeadline`. */
|
|
36
|
+
slackHeadline: string;
|
|
37
|
+
/** `schedule.confirmed.slackBody`. */
|
|
38
|
+
slackBody: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Shown {
|
|
42
|
+
time: string;
|
|
43
|
+
slackOptIn: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether `to` names this page, with or without this page's `/<locale>`. */
|
|
47
|
+
function namesThisPage(to: string, locale: string): boolean {
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(to, PLACEHOLDER_ORIGIN);
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (url.origin !== PLACEHOLDER_ORIGIN) return false;
|
|
55
|
+
return (
|
|
56
|
+
url.pathname === CONFIRMED_PATH ||
|
|
57
|
+
url.pathname === `/${locale}${CONFIRMED_PATH}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read the hand-off once, remove it, and answer what to show (or `null`). */
|
|
62
|
+
function takeHandOff(locale: string): Shown | null {
|
|
63
|
+
try {
|
|
64
|
+
const stored = window.sessionStorage.getItem(
|
|
65
|
+
BOOKING_CONFIRMATION_STORAGE_KEY,
|
|
66
|
+
);
|
|
67
|
+
if (stored === null) return null;
|
|
68
|
+
window.sessionStorage.removeItem(BOOKING_CONFIRMATION_STORAGE_KEY);
|
|
69
|
+
const parsed = BookingConfirmationHandOffSchema.safeParse(
|
|
70
|
+
JSON.parse(stored),
|
|
71
|
+
);
|
|
72
|
+
if (!parsed.success) return null;
|
|
73
|
+
const { to, savedAt, start, timeZone, slackOptIn } = parsed.data;
|
|
74
|
+
const age = Date.now() - savedAt;
|
|
75
|
+
if (!namesThisPage(to, locale) || age < 0 || age >= MAX_AGE_MS) return null;
|
|
76
|
+
const time = new Intl.DateTimeFormat(locale, {
|
|
77
|
+
weekday: "long",
|
|
78
|
+
month: "long",
|
|
79
|
+
day: "numeric",
|
|
80
|
+
hour: "numeric",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
timeZone,
|
|
83
|
+
timeZoneName: "longGeneric",
|
|
84
|
+
}).format(new Date(start));
|
|
85
|
+
return { time, slackOptIn };
|
|
86
|
+
} catch {
|
|
87
|
+
// Unparseable JSON, refused storage, or a zone `Intl` does not know.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function BookingHandOff({
|
|
93
|
+
locale,
|
|
94
|
+
bookedTimeHeading,
|
|
95
|
+
slackHeadline,
|
|
96
|
+
slackBody,
|
|
97
|
+
}: Props): ReactElement | null {
|
|
98
|
+
const [shown, setShown] = useState<Shown | null>(null);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
// Only ever sets: React's development double-mount runs this twice, and
|
|
102
|
+
// the second run finds the item already removed.
|
|
103
|
+
const found = takeHandOff(locale);
|
|
104
|
+
if (found !== null) setShown(found);
|
|
105
|
+
}, [locale]);
|
|
106
|
+
|
|
107
|
+
if (shown === null) return null;
|
|
108
|
+
return (
|
|
109
|
+
<Container className="pt-block-loose">
|
|
110
|
+
<div role="status" data-testid="booking-hand-off">
|
|
111
|
+
<Heading level={2} visual={4}>
|
|
112
|
+
{bookedTimeHeading}
|
|
113
|
+
</Heading>
|
|
114
|
+
<Text className="mt-2">{shown.time}</Text>
|
|
115
|
+
{shown.slackOptIn && (
|
|
116
|
+
<>
|
|
117
|
+
<Heading level={2} visual={4} className="mt-block-tight">
|
|
118
|
+
{slackHeadline}
|
|
119
|
+
</Heading>
|
|
120
|
+
<Text tone="muted" className="mt-2">
|
|
121
|
+
{slackBody}
|
|
122
|
+
</Text>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</Container>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* Tests for `/meet/confirmed`'s booking hand-off (#9853 D9, D10; AC-7).
|
|
4
|
+
*
|
|
5
|
+
* The booker hands this page the booked time, its zone and the Slack opt-in
|
|
6
|
+
* through one `sessionStorage` item, never the address. The component reads
|
|
7
|
+
* it once and removes it, and shows it only when it was written for this page
|
|
8
|
+
* in the last ten minutes, formatted in the zone Cal.com recorded — the
|
|
9
|
+
* prospect's, not the viewer's. Anything else renders nothing.
|
|
10
|
+
*/
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { act, render, screen } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
import { BookingHandOff } from "../BookingHandOff";
|
|
15
|
+
|
|
16
|
+
const STORAGE_KEY = "ailk:booking-confirmation";
|
|
17
|
+
const PROPS = {
|
|
18
|
+
locale: "en",
|
|
19
|
+
bookedTimeHeading: "copy:confirmed.bookedTimeHeading",
|
|
20
|
+
slackHeadline: "copy:confirmed.slackHeadline",
|
|
21
|
+
slackBody: "copy:confirmed.slackBody",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function handOff(overrides: Record<string, unknown> = {}) {
|
|
25
|
+
return {
|
|
26
|
+
to: "/meet/confirmed",
|
|
27
|
+
savedAt: Date.now(),
|
|
28
|
+
start: "2026-10-30T20:00:00.000Z",
|
|
29
|
+
timeZone: "America/Denver",
|
|
30
|
+
slackOptIn: true,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function store(value: unknown): void {
|
|
36
|
+
window.sessionStorage.setItem(
|
|
37
|
+
STORAGE_KEY,
|
|
38
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function renderHandOff() {
|
|
43
|
+
let utils!: ReturnType<typeof render>;
|
|
44
|
+
await act(async () => {
|
|
45
|
+
utils = render(<BookingHandOff {...PROPS} />);
|
|
46
|
+
});
|
|
47
|
+
return utils;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
window.sessionStorage.clear();
|
|
52
|
+
window.history.replaceState(null, "", "/en/meet/confirmed");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("<BookingHandOff/> (#9853 AC-7)", () => {
|
|
56
|
+
it("shows the booked time in the prospect's zone, named, and the Slack step; then forgets it", async () => {
|
|
57
|
+
store(handOff());
|
|
58
|
+
const { container } = await renderHandOff();
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText(PROPS.bookedTimeHeading)).toBeInTheDocument();
|
|
61
|
+
expect(container.textContent).toContain("2:00");
|
|
62
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
63
|
+
expect(screen.getByText(PROPS.slackHeadline)).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText(PROPS.slackBody)).toBeInTheDocument();
|
|
65
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
66
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("accepts a destination written with a locale prefix, a query or a hash", async () => {
|
|
70
|
+
store(handOff({ to: "/en/meet/confirmed?x=1#top" }));
|
|
71
|
+
const { container } = await renderHandOff();
|
|
72
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows no Slack text when the box was not ticked", async () => {
|
|
76
|
+
store(handOff({ slackOptIn: false }));
|
|
77
|
+
const { container } = await renderHandOff();
|
|
78
|
+
expect(container.textContent).toContain("Mountain Time");
|
|
79
|
+
expect(screen.queryByText(PROPS.slackHeadline)).toBeNull();
|
|
80
|
+
expect(screen.queryByText(PROPS.slackBody)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each<[string, () => void]>([
|
|
84
|
+
["no item", () => undefined],
|
|
85
|
+
["an unparseable item", () => store("{not json")],
|
|
86
|
+
[
|
|
87
|
+
"an item with an extra email key",
|
|
88
|
+
() => store(handOff({ email: "ada@example.com" })),
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
"an item for another page",
|
|
92
|
+
() => store(handOff({ to: "/before-your-call" })),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"an item eleven minutes old",
|
|
96
|
+
() => store(handOff({ savedAt: Date.now() - 11 * 60_000 })),
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
"a zone Intl does not know",
|
|
100
|
+
() => store(handOff({ timeZone: "Mars/Olympus" })),
|
|
101
|
+
],
|
|
102
|
+
])("renders nothing, and removes the item, for %s", async (_label, seed) => {
|
|
103
|
+
seed();
|
|
104
|
+
const { container } = await renderHandOff();
|
|
105
|
+
expect(container).toBeEmptyDOMElement();
|
|
106
|
+
expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("shows nothing on a later visit", async () => {
|
|
110
|
+
store(handOff());
|
|
111
|
+
const first = await renderHandOff();
|
|
112
|
+
first.unmount();
|
|
113
|
+
const { container } = await renderHandOff();
|
|
114
|
+
expect(container).toBeEmptyDOMElement();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
"/fr/meet/confirmed",
|
|
119
|
+
"/anything/meet/confirmed",
|
|
120
|
+
"//evil.example/meet/confirmed",
|
|
121
|
+
])("renders nothing for a destination of %s", async (to) => {
|
|
122
|
+
store(handOff({ to }));
|
|
123
|
+
const { container } = await renderHandOff();
|
|
124
|
+
expect(container).toBeEmptyDOMElement();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -459,7 +459,9 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
459
459
|
/**
|
|
460
460
|
* The sign-up grants (#8765 D4): the user hook decides whether the new
|
|
461
461
|
* user carries agreements, the account hook writes them on the captured
|
|
462
|
-
* transaction client.
|
|
462
|
+
* transaction client. A magic-link sign-up creates no Account, so the
|
|
463
|
+
* session hook writes its grants, in their own transaction, before the
|
|
464
|
+
* session exists (#9667 D5). No other database hook is installed.
|
|
463
465
|
*/
|
|
464
466
|
databaseHooks: signUpAgreementHooks(),
|
|
465
467
|
secret: resolveSecret(),
|
|
@@ -671,9 +673,11 @@ export function createAuth(deps: CreateAuthDeps = {}) {
|
|
|
671
673
|
/**
|
|
672
674
|
* `magicLink` only when its predicate holds (#7435 § D2): absent, its
|
|
673
675
|
* `/sign-in/magic-link` and `/magic-link/verify` endpoints do not exist.
|
|
674
|
-
* Sign-in only — the callback mails existing users
|
|
675
|
-
* `disableSignUp` refuses to create one even for a
|
|
676
|
-
* (lib/auth-email.ts).
|
|
676
|
+
* Sign-in only while sign-up is closed — the callback mails existing users
|
|
677
|
+
* and nobody else, and `disableSignUp` refuses to create one even for a
|
|
678
|
+
* live token (lib/auth-email.ts). While `isSignUpOpen()` a request
|
|
679
|
+
* carrying the agreements is a sign-up: its token is bound to them, and
|
|
680
|
+
* the verify creates the account only for a token so bound (#9667).
|
|
677
681
|
*
|
|
678
682
|
* `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
|
|
679
683
|
* `/sign-up/email` without every sign-up agreement is refused before the
|
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
// English when no catalog exists (e.g. a locale is enabled in project.yaml
|
|
5
5
|
// but its messages/<locale>.json has not been translated yet).
|
|
6
6
|
//
|
|
7
|
+
// The site's own strings (#9401 D8) live in messages/site/<locale>.json, an
|
|
8
|
+
// overlay the template ships as `{}` and never changes, so `create-ailk
|
|
9
|
+
// update` keeps a site's edit of it. It falls back to the English overlay,
|
|
10
|
+
// then to nothing, and merges add-only: a key the template catalog already
|
|
11
|
+
// has keeps the template's value. The merge builds new objects, descends only
|
|
12
|
+
// into plain objects, takes only string leaves and skips prototype keys, so
|
|
13
|
+
// it can neither mutate the imported catalog nor pollute `Object.prototype`.
|
|
14
|
+
//
|
|
7
15
|
// Spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
|
|
8
16
|
|
|
9
17
|
import type { AbstractIntlMessages } from "next-intl";
|
|
@@ -11,6 +19,42 @@ import { getRequestConfig } from "next-intl/server";
|
|
|
11
19
|
|
|
12
20
|
const DEFAULT_LOCALE = "en";
|
|
13
21
|
|
|
22
|
+
const UNSAFE_KEYS: ReadonlySet<string> = new Set([
|
|
23
|
+
"__proto__",
|
|
24
|
+
"constructor",
|
|
25
|
+
"prototype",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
type Catalog = Record<string, unknown>;
|
|
29
|
+
|
|
30
|
+
function isCatalog(value: unknown): value is Catalog {
|
|
31
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `base` with every key of `overlay` it lacks added — new objects, never a mutation. */
|
|
35
|
+
function mergeAddOnly(base: Catalog, overlay: Catalog): Catalog {
|
|
36
|
+
const out: Catalog = { ...base };
|
|
37
|
+
for (const [key, value] of Object.entries(overlay)) {
|
|
38
|
+
if (UNSAFE_KEYS.has(key)) continue;
|
|
39
|
+
const has = Object.prototype.hasOwnProperty.call(base, key);
|
|
40
|
+
if (typeof value === "string") {
|
|
41
|
+
if (!has) out[key] = value;
|
|
42
|
+
} else if (isCatalog(value)) {
|
|
43
|
+
if (!has) out[key] = mergeAddOnly({}, value);
|
|
44
|
+
else if (isCatalog(base[key])) out[key] = mergeAddOnly(base[key], value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadSiteOverlay(locale: string): Promise<Catalog> {
|
|
51
|
+
const mod =
|
|
52
|
+
(await import(`../messages/site/${locale}.json`).catch(() => null)) ??
|
|
53
|
+
(await import(`../messages/site/${DEFAULT_LOCALE}.json`).catch(() => null));
|
|
54
|
+
const overlay: unknown = mod ? (mod.default ?? mod) : null;
|
|
55
|
+
return isCatalog(overlay) ? overlay : {};
|
|
56
|
+
}
|
|
57
|
+
|
|
14
58
|
export default getRequestConfig(async ({ requestLocale }) => {
|
|
15
59
|
// requestLocale is the modern API (next-intl >= 3.22). It resolves the
|
|
16
60
|
// active locale from the request, validated by middleware.
|
|
@@ -32,5 +76,9 @@ export default getRequestConfig(async ({ requestLocale }) => {
|
|
|
32
76
|
messages = (fallback.default ?? fallback) as AbstractIntlMessages;
|
|
33
77
|
}
|
|
34
78
|
|
|
35
|
-
|
|
79
|
+
const overlay = await loadSiteOverlay(locale);
|
|
80
|
+
return {
|
|
81
|
+
locale,
|
|
82
|
+
messages: mergeAddOnly(messages, overlay) as AbstractIntlMessages,
|
|
83
|
+
};
|
|
36
84
|
});
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* 401/403) is `false`, any other failure rejects. The popups are bound
|
|
27
27
|
* in the route (`account/new-popups.tsx`, tested beside it): this file
|
|
28
28
|
* imports nothing under `(authed)`.
|
|
29
|
+
* 8. #9401 AC-11 — the template's `site-contributions.ts` stub ships no
|
|
30
|
+
* Billing section either; a site fills it.
|
|
29
31
|
*/
|
|
30
32
|
|
|
31
33
|
const mockSignupMe = jest.fn();
|
|
@@ -68,6 +70,7 @@ import {
|
|
|
68
70
|
accountSettingsSections,
|
|
69
71
|
type AccountNewRead,
|
|
70
72
|
} from "../account-config";
|
|
73
|
+
import { siteBillingSections } from "../site-contributions";
|
|
71
74
|
|
|
72
75
|
const INIT = { headers: { cookie: "session=abc" } };
|
|
73
76
|
|
|
@@ -166,6 +169,10 @@ describe("account-config exports (AC-6)", () => {
|
|
|
166
169
|
expect(accountBillingSections).toEqual([]);
|
|
167
170
|
});
|
|
168
171
|
|
|
172
|
+
it("ships no site Billing section either: the stub is the site's to fill (#9401 AC-11, D7)", () => {
|
|
173
|
+
expect(siteBillingSections).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
|
|
169
176
|
it("ships the base kinds and the member kinds in their sections (#8231 D4–D6)", () => {
|
|
170
177
|
expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
|
|
171
178
|
["result", "updates"],
|