authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 02:29:01+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 02:29:01+02:00
log60af905e70c944228cb35a74fa50740ec4b2d1f7
treef96e8d1593cb40912ed287b5481aa100c686361d
parent8650f05f95ac9f19254de679fdf4b380c37d7b94
signature Signed by PGP key B5690EEEBB952194

Remove Connect waitlist and add GA announcement tooling (#4691)


9 files changed, 236 insertions(+), 282 deletions(-)

apps/mobile/src/Stack.tsx+3-3
...@@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc...@@ -47,7 +47,6 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
47import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";47import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
48import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";48import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
49import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";49import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
50import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen";
51import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";50import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
52import {51import {
53 SettingsLegalDocumentCloseHeaderButton,52 SettingsLegalDocumentCloseHeaderButton,
...@@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({...@@ -190,10 +189,11 @@ const SettingsSheetStack = createNativeStackNavigator({
190 },189 },
191 }),190 }),
192 SettingsWaitlist: createNativeStackScreen({191 SettingsWaitlist: createNativeStackScreen({
193 screen: SettingsWaitlistRouteScreen,192 // Keep the old deep link working after the Connect GA launch.
193 screen: SettingsAuthRouteScreen,
194 linking: "waitlist",194 linking: "waitlist",
195 options: {195 options: {
196 title: "Join the waitlist",196 title: "Sign in",
197 },197 },
198 }),198 }),
199 },199 },
apps/mobile/src/features/cloud/CloudWaitlistEnrollment.tsx deleted-120
...@@ -1,120 +0,0 @@
1import { useWaitlist } from "@clerk/expo";
2import { ActivityIndicator, Pressable, Text, TextInput, View } from "react-native";
3import { useState } from "react";
4
5import { cn } from "../../lib/cn";
6import { CloudWaitlistJoinRejectedError, joinCloudWaitlist } from "./cloudWaitlistJoin";
7
8export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void }) {
9 const { errors, fetchStatus, waitlist } = useWaitlist();
10 const [emailAddress, setEmailAddress] = useState("");
11 const [requestError, setRequestError] = useState<string | null>(null);
12 const isSubmitting = fetchStatus === "fetching";
13 const fieldError = errors.fields.emailAddress?.longMessage;
14
15 const joinWaitlist = async () => {
16 const normalizedEmailAddress = emailAddress.trim();
17 if (!normalizedEmailAddress || isSubmitting) {
18 return;
19 }
20
21 setRequestError(null);
22 try {
23 await joinCloudWaitlist(waitlist, normalizedEmailAddress);
24 } catch (error) {
25 console.error(error);
26 setRequestError(
27 error instanceof CloudWaitlistJoinRejectedError
28 ? "Could not join the waitlist. Check your email address and try again."
29 : "Could not join the waitlist. Check your connection and try again.",
30 );
31 }
32 };
33
34 if (waitlist.id) {
35 return (
36 <View className="gap-[18px]">
37 <Text className="text-center font-t3-bold text-xl text-foreground">
38 You are on the waitlist
39 </Text>
40 <Text className="text-center font-sans text-base text-foreground-secondary">
41 We will email you when your T3 Connect access is ready.
42 </Text>
43 <SignInAction onPress={props.onSignIn} />
44 </View>
45 );
46 }
47
48 return (
49 <View className="gap-[18px]">
50 <Text className="font-sans text-base text-foreground-secondary">
51 Enter your email and we will let you know when access is ready.
52 </Text>
53
54 <View className="gap-2">
55 <Text className="font-t3-bold text-sm text-foreground-secondary">Email address</Text>
56 <TextInput
57 accessibilityLabel="Email address"
58 autoCapitalize="none"
59 autoComplete="email"
60 autoCorrect={false}
61 className={cn(
62 "min-h-[54px] rounded-2xl border border-input-border bg-input px-4 py-3.5 font-sans text-lg text-foreground border-continuous",
63 (fieldError || requestError) && "border-danger-foreground",
64 )}
65 keyboardType="email-address"
66 onChangeText={(value) => {
67 setEmailAddress(value);
68 setRequestError(null);
69 }}
70 onSubmitEditing={() => void joinWaitlist()}
71 placeholder="Enter your email address"
72 placeholderTextColorClassName="accent-placeholder"
73 returnKeyType="join"
74 textContentType="emailAddress"
75 value={emailAddress}
76 />
77 {fieldError || requestError ? (
78 <Text
79 accessibilityLiveRegion="polite"
80 className="font-sans text-sm text-danger-foreground"
81 selectable
82 >
83 {fieldError ?? requestError}
84 </Text>
85 ) : null}
86 </View>
87
88 <Pressable
89 accessibilityRole="button"
90 accessibilityState={{
91 busy: isSubmitting,
92 disabled: isSubmitting || emailAddress.trim().length === 0,
93 }}
94 disabled={isSubmitting || emailAddress.trim().length === 0}
95 onPress={() => void joinWaitlist()}
96 className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]"
97 >
98 {isSubmitting ? (
99 <ActivityIndicator colorClassName="accent-primary-foreground" size="small" />
100 ) : null}
101 <Text className="font-t3-bold text-base text-primary-foreground">
102 {isSubmitting ? "Joining" : "Join the waitlist"}
103 </Text>
104 </Pressable>
105
106 <SignInAction onPress={props.onSignIn} />
107 </View>
108 );
109}
110
111function SignInAction(props: { readonly onPress: () => void }) {
112 return (
113 <View className="flex-row items-center justify-center gap-1 pt-1">
114 <Text className="font-sans text-base text-foreground-secondary">Already have access?</Text>
115 <Pressable accessibilityRole="button" hitSlop={8} onPress={props.onPress}>
116 <Text className="font-t3-bold text-base text-foreground">Sign in</Text>
117 </Pressable>
118 </View>
119 );
120}
apps/mobile/src/features/cloud/cloudWaitlistJoin.test.ts deleted-48
...@@ -1,48 +0,0 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 CloudWaitlistJoinRejectedError,
5 CloudWaitlistJoinRequestError,
6 joinCloudWaitlist,
7} from "./cloudWaitlistJoin";
8
9describe("joinCloudWaitlist", () => {
10 it("submits the provided email address", async () => {
11 const join = vi.fn().mockResolvedValue({ error: null });
12
13 await joinCloudWaitlist({ join }, "person@example.com");
14
15 expect(join).toHaveBeenCalledExactlyOnceWith({ emailAddress: "person@example.com" });
16 });
17
18 it("preserves Clerk rejection details without exposing the email address", async () => {
19 const cause = Object.assign(new Error("The enrollment was rejected."), {
20 code: "form_identifier_invalid",
21 });
22 const join = vi.fn().mockResolvedValue({ error: cause });
23
24 const failure = await joinCloudWaitlist({ join }, "secret@example.com").catch(
25 (error: unknown) => error,
26 );
27
28 expect(failure).toBeInstanceOf(CloudWaitlistJoinRejectedError);
29 expect(failure).toMatchObject({
30 code: "form_identifier_invalid",
31 cause,
32 });
33 expect(String(failure)).not.toContain("secret@example.com");
34 });
35
36 it("distinguishes request failures from rejected enrollments", async () => {
37 const cause = new Error("network unavailable");
38 const join = vi.fn().mockRejectedValue(cause);
39
40 const failure = await joinCloudWaitlist({ join }, "person@example.com").catch(
41 (error: unknown) => error,
42 );
43
44 expect(failure).toBeInstanceOf(CloudWaitlistJoinRequestError);
45 expect(failure).toMatchObject({ cause });
46 expect(failure).not.toBeInstanceOf(CloudWaitlistJoinRejectedError);
47 });
48});
apps/mobile/src/features/cloud/cloudWaitlistJoin.ts deleted-46
...@@ -1,46 +0,0 @@
1import * as Schema from "effect/Schema";
2
3interface CloudWaitlistJoiner {
4 readonly join: (input: { emailAddress: string }) => Promise<{
5 readonly error: { readonly code: string } | null;
6 }>;
7}
8
9export class CloudWaitlistJoinRejectedError extends Schema.TaggedErrorClass<CloudWaitlistJoinRejectedError>()(
10 "CloudWaitlistJoinRejectedError",
11 {
12 code: Schema.String,
13 cause: Schema.Defect(),
14 },
15) {
16 override get message(): string {
17 return `Cloud waitlist enrollment was rejected with code "${this.code}".`;
18 }
19}
20
21export class CloudWaitlistJoinRequestError extends Schema.TaggedErrorClass<CloudWaitlistJoinRequestError>()(
22 "CloudWaitlistJoinRequestError",
23 {
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return "Cloud waitlist enrollment request failed.";
29 }
30}
31
32export async function joinCloudWaitlist(
33 waitlist: CloudWaitlistJoiner,
34 emailAddress: string,
35): Promise<void> {
36 const result = await waitlist.join({ emailAddress }).catch((cause) => {
37 throw new CloudWaitlistJoinRequestError({ cause });
38 });
39
40 if (result.error) {
41 throw new CloudWaitlistJoinRejectedError({
42 code: result.error.code,
43 cause: result.error,
44 });
45 }
46}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+5-4
...@@ -273,13 +273,13 @@ function ConfiguredSettingsRouteScreen() {...@@ -273,13 +273,13 @@ function ConfiguredSettingsRouteScreen() {
273273
274 const promptSignIn = useCallback(() => {274 const promptSignIn = useCallback(() => {
275 Alert.alert(275 Alert.alert(
276 "Request T3 Connect access",276 "Sign in to T3 Connect",
277 "Live Activity updates require approved T3 Connect access so relay can deliver updates to this device.",277 "Live Activity updates require T3 Connect so relay can deliver updates to this device.",
278 [278 [
279 { text: "Cancel", style: "cancel" },279 { text: "Cancel", style: "cancel" },
280 {280 {
281 text: "Continue",281 text: "Continue",
282 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" }),282 onPress: () => navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }),
283 },283 },
284 ],284 ],
285 );285 );
...@@ -441,7 +441,8 @@ function ConfiguredSettingsRouteScreen() {...@@ -441,7 +441,8 @@ function ConfiguredSettingsRouteScreen() {
441 const openAccount = useCallback(() => {441 const openAccount = useCallback(() => {
442 if (!isLoaded) return;442 if (!isLoaded) return;
443 if (!isSignedIn) {443 if (!isSignedIn) {
444 navigation.navigate("SettingsSheet", { screen: "SettingsWaitlist" });444 expandClerkSheet();
445 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
445 return;446 return;
446 }447 }
447 expandClerkSheet();448 expandClerkSheet();
apps/mobile/src/features/settings/SettingsWaitlistRouteScreen.tsx deleted-60
...@@ -1,60 +0,0 @@
1import { useAuth } from "@clerk/expo";
2import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
3import { useCallback } from "react";
4import { ScrollView } from "react-native";
5
6import { CloudWaitlistEnrollment } from "../cloud/CloudWaitlistEnrollment";
7import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
8import { hasCloudPublicConfig } from "../cloud/publicConfig";
9
10export function SettingsWaitlistRouteScreen() {
11 const navigation = useNavigation();
12
13 useFocusEffect(
14 useCallback(() => {
15 if (!hasCloudPublicConfig()) {
16 navigation.dispatch(StackActions.replace("Settings"));
17 }
18 }, [navigation]),
19 );
20
21 return hasCloudPublicConfig() ? <ConfiguredSettingsWaitlistRouteScreen /> : null;
22}
23
24function ConfiguredSettingsWaitlistRouteScreen() {
25 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
26 const { expand } = useClerkSettingsSheetDetent();
27 const navigation = useNavigation();
28
29 useFocusEffect(
30 useCallback(() => {
31 if (isLoaded && isSignedIn) {
32 navigation.dispatch(StackActions.replace("Settings"));
33 }
34 }, [isLoaded, isSignedIn, navigation]),
35 );
36
37 return (
38 <>
39 <ScrollView
40 automaticallyAdjustKeyboardInsets
41 contentContainerStyle={{
42 paddingBottom: 32,
43 paddingHorizontal: 20,
44 paddingTop: 12,
45 }}
46 contentInsetAdjustmentBehavior="automatic"
47 keyboardDismissMode="interactive"
48 keyboardShouldPersistTaps="handled"
49 showsVerticalScrollIndicator={false}
50 >
51 <CloudWaitlistEnrollment
52 onSignIn={() => {
53 expand();
54 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
55 }}
56 />
57 </ScrollView>
58 </>
59 );
60}
apps/web/src/components/clerk/useT3ConnectAuthPrompt.tsx+1-1
...@@ -3,7 +3,7 @@ import { useClerk } from "@clerk/react";...@@ -3,7 +3,7 @@ import { useClerk } from "@clerk/react";
3export function useT3ConnectAuthPrompt() {3export function useT3ConnectAuthPrompt() {
4 const clerk = useClerk();4 const clerk = useClerk();
5 const openAuthPrompt = () => {5 const openAuthPrompt = () => {
6 clerk.openWaitlist();6 clerk.openSignIn({ forceRedirectUrl: window.location.href });
7 };7 };
8 return { authPrompt: null, openAuthPrompt };8 return { authPrompt: null, openAuthPrompt };
9}9}
package.json+1
...@@ -38,6 +38,7 @@...@@ -38,6 +38,7 @@
38 "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64",38 "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64",
39 "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",39 "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64",
40 "release:smoke": "node scripts/release-smoke.ts",40 "release:smoke": "node scripts/release-smoke.ts",
41 "connect:announce-ga": "node scripts/announce-connect-ga.ts",
41 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus",42 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus",
42 "sync:repos": "node scripts/sync-reference-repos.ts"43 "sync:repos": "node scripts/sync-reference-repos.ts"
43 },44 },
scripts/announce-connect-ga.ts created+226
...@@ -0,0 +1,226 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Config from "effect/Config";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
11import { Command, Flag } from "effect/unstable/cli";
12import {
13 FetchHttpClient,
14 HttpClient,
15 HttpClientRequest,
16 HttpClientResponse,
17} from "effect/unstable/http";
18
19const CLERK_API_URL = "https://api.clerk.com/v1";
20const PAGE_SIZE = 500;
21
22export class WaitlistEntry extends Schema.Class<WaitlistEntry>("WaitlistEntry")({
23 id: Schema.String,
24 email_address: Schema.String,
25 status: Schema.Literals(["pending", "invited", "completed", "rejected"]),
26}) {}
27
28const ClerkWaitlistResponse = Schema.Struct({
29 data: Schema.Array(WaitlistEntry),
30 total_count: Schema.Int,
31});
32const PositiveInteger = Schema.Int.check(Schema.isGreaterThan(0));
33const ClerkSecretKey = Config.string("CLERK_SECRET_KEY");
34
35export interface ConnectGaOptions {
36 readonly invite: boolean;
37 readonly limit: number | undefined;
38}
39
40export class ConnectGaRequestError extends Schema.TaggedErrorClass<ConnectGaRequestError>()(
41 "ConnectGaRequestError",
42 {
43 operation: Schema.String,
44 cause: Schema.Defect(),
45 },
46) {
47 override get message(): string {
48 return `Clerk ${this.operation} request failed.`;
49 }
50}
51
52export class ConnectGaResponseError extends Schema.TaggedErrorClass<ConnectGaResponseError>()(
53 "ConnectGaResponseError",
54 {
55 operation: Schema.String,
56 status: Schema.Int,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `Clerk ${this.operation} returned status ${this.status}.`;
62 }
63}
64
65const executeClerkJsonRequest = Effect.fn("executeClerkJsonRequest")(function* <
66 S extends Schema.Top,
67>(request: HttpClientRequest.HttpClientRequest, schema: S, operation: string) {
68 const client = (yield* HttpClient.HttpClient).pipe(
69 HttpClient.retryTransient({
70 retryOn: "errors-and-responses",
71 times: 3,
72 }),
73 );
74 const response = yield* client
75 .execute(request)
76 .pipe(Effect.mapError((cause) => new ConnectGaRequestError({ operation, cause })));
77 const success = yield* HttpClientResponse.filterStatusOk(response).pipe(
78 Effect.mapError(
79 (cause) =>
80 new ConnectGaResponseError({
81 operation,
82 status: response.status,
83 cause,
84 }),
85 ),
86 );
87 return yield* HttpClientResponse.schemaBodyJson(schema)(success).pipe(
88 Effect.mapError(
89 (cause) =>
90 new ConnectGaResponseError({
91 operation,
92 status: response.status,
93 cause,
94 }),
95 ),
96 );
97});
98
99const fetchWaitlistPage = Effect.fn("fetchWaitlistPage")(function* (
100 secretKey: string,
101 offset: number,
102) {
103 const url = new URL(`${CLERK_API_URL}/waitlist_entries`);
104 url.searchParams.set("status", "pending");
105 url.searchParams.set("limit", String(PAGE_SIZE));
106 url.searchParams.set("offset", String(offset));
107 url.searchParams.set("order_by", "+created_at");
108 const request = HttpClientRequest.get(url.href).pipe(
109 HttpClientRequest.bearerToken(secretKey),
110 HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"),
111 );
112 return yield* executeClerkJsonRequest(
113 request,
114 ClerkWaitlistResponse,
115 "list pending waitlist entries",
116 );
117});
118
119export const fetchPendingWaitlistEntries = Effect.fn("fetchPendingWaitlistEntries")(function* (
120 secretKey: string,
121 limit?: number,
122) {
123 const entries: Array<WaitlistEntry> = [];
124 while (true) {
125 if (limit !== undefined && entries.length >= limit) break;
126 const page = yield* fetchWaitlistPage(secretKey, entries.length);
127 entries.push(...page.data);
128 if (entries.length >= page.total_count || page.data.length === 0) break;
129 }
130 return limit === undefined ? entries : entries.slice(0, limit);
131});
132
133export const inviteWaitlistEntry = Effect.fn("inviteWaitlistEntry")(function* (
134 secretKey: string,
135 entry: WaitlistEntry,
136) {
137 const request = HttpClientRequest.post(
138 `${CLERK_API_URL}/waitlist_entries/${encodeURIComponent(entry.id)}/invite`,
139 ).pipe(
140 HttpClientRequest.bearerToken(secretKey),
141 HttpClientRequest.setHeader("Clerk-API-Version", "2026-05-12"),
142 );
143 return yield* executeClerkJsonRequest(
144 request,
145 WaitlistEntry,
146 `invite waitlist entry ${entry.id}`,
147 );
148});
149
150export const announceConnectGa = Effect.fn("announceConnectGa")(function* (
151 options: ConnectGaOptions,
152) {
153 const clerkSecretKey = yield* ClerkSecretKey;
154 const entries = yield* fetchPendingWaitlistEntries(clerkSecretKey, options.limit);
155
156 yield* Effect.logInfo(
157 options.invite ? "Connect GA waitlist invitations starting" : "Connect GA dry run",
158 ).pipe(
159 Effect.annotateLogs({
160 pendingEntries: entries.length,
161 }),
162 );
163
164 if (!options.invite) {
165 for (const entry of entries) {
166 yield* Effect.logInfo("pending waitlist entry").pipe(
167 Effect.annotateLogs({
168 waitlistEntryId: entry.id,
169 emailAddress: entry.email_address,
170 }),
171 );
172 }
173 yield* Effect.logInfo("No invitation was sent. Re-run with --invite after reviewing the list.");
174 return;
175 }
176
177 for (const [index, entry] of entries.entries()) {
178 const invited = yield* inviteWaitlistEntry(clerkSecretKey, entry);
179 yield* Effect.logInfo("Clerk waitlist invitation sent").pipe(
180 Effect.annotateLogs({
181 waitlistEntryId: invited.id,
182 completed: index + 1,
183 total: entries.length,
184 }),
185 );
186 }
187});
188
189export const announceConnectGaCommand = Command.make(
190 "announce-connect-ga",
191 {
192 invite: Flag.boolean("invite").pipe(
193 Flag.withDefault(false),
194 Flag.withDescription(
195 "Invite pending entries through Clerk. Without this flag, only print a dry-run list.",
196 ),
197 ),
198 limit: Flag.integer("limit").pipe(
199 Flag.withSchema(PositiveInteger),
200 Flag.optional,
201 Flag.withDescription("Process at most this many pending waitlist entries."),
202 ),
203 },
204 ({ invite, limit }) =>
205 announceConnectGa({
206 invite,
207 limit: Option.getOrUndefined(limit),
208 }),
209).pipe(
210 Command.withDescription(
211 "Invite pending Clerk waitlist members now that T3 Connect is generally available.",
212 ),
213);
214
215if (import.meta.main) {
216 Command.run(announceConnectGaCommand, { version: "0.0.0" }).pipe(
217 Effect.provide(
218 Layer.mergeAll(
219 Logger.layer([Logger.consolePretty()]),
220 NodeServices.layer,
221 FetchHttpClient.layer,
222 ),
223 ),
224 NodeRuntime.runMain,
225 );
226}