@ramonclaudio/create-vexpo 0.1.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 +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { startTransition, useActionState, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import * as AppleAuthentication from "expo-apple-authentication";
|
|
3
|
+
import { Image as ExpoImage } from "expo-image";
|
|
4
|
+
import * as ImagePicker from "expo-image-picker";
|
|
5
|
+
import { router, useNavigation } from "expo-router";
|
|
6
|
+
import { useQuery } from "convex/react";
|
|
7
|
+
import {
|
|
8
|
+
Host,
|
|
9
|
+
ScrollView,
|
|
10
|
+
VStack,
|
|
11
|
+
HStack,
|
|
12
|
+
TextField,
|
|
13
|
+
Button,
|
|
14
|
+
Text,
|
|
15
|
+
Image,
|
|
16
|
+
Spacer,
|
|
17
|
+
RNHostView,
|
|
18
|
+
ConfirmationDialog,
|
|
19
|
+
} from "@expo/ui/swift-ui";
|
|
20
|
+
import {
|
|
21
|
+
autocorrectionDisabled,
|
|
22
|
+
foregroundStyle,
|
|
23
|
+
disabled,
|
|
24
|
+
keyboardType,
|
|
25
|
+
submitLabel,
|
|
26
|
+
textFieldStyle,
|
|
27
|
+
textInputAutocapitalization,
|
|
28
|
+
padding,
|
|
29
|
+
frame,
|
|
30
|
+
scrollDismissesKeyboard,
|
|
31
|
+
accessibilityLabel,
|
|
32
|
+
accessibilityHint,
|
|
33
|
+
onTapGesture,
|
|
34
|
+
tint,
|
|
35
|
+
background,
|
|
36
|
+
border,
|
|
37
|
+
clipShape,
|
|
38
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
39
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
40
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
41
|
+
|
|
42
|
+
import { api } from "@/convex/_generated/api";
|
|
43
|
+
import { isReservedUsername, isValidUsernameFormat } from "@/convex/constants";
|
|
44
|
+
import { authClient } from "@/lib/auth-client";
|
|
45
|
+
import { assets } from "@/lib/assets";
|
|
46
|
+
import { haptics } from "@/lib/haptics";
|
|
47
|
+
import { OtpVerification, type PendingAvatar } from "@/components/auth/otp-verification";
|
|
48
|
+
import { PasswordField } from "@/components/auth/password-field";
|
|
49
|
+
import { SegmentedToggle } from "@/components/auth/segmented-toggle";
|
|
50
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
51
|
+
import { firstError, signUpSchema } from "@/lib/schemas";
|
|
52
|
+
import { ErrorText } from "@/components/ui/status-text";
|
|
53
|
+
import { announce } from "@/lib/a11y";
|
|
54
|
+
import { useColorScheme, useColors, useThemedAsset } from "@/hooks/use-theme";
|
|
55
|
+
|
|
56
|
+
type SignUpState = { error?: string; verify?: boolean };
|
|
57
|
+
const initialState: SignUpState = {};
|
|
58
|
+
|
|
59
|
+
const AVATAR_SIZE = 56;
|
|
60
|
+
|
|
61
|
+
export default function SignUpScreen() {
|
|
62
|
+
const dfont = useDynamicFont();
|
|
63
|
+
const colorScheme = useColorScheme();
|
|
64
|
+
const colors = useColors();
|
|
65
|
+
const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
|
|
66
|
+
const [name, setName] = useState("");
|
|
67
|
+
const [username, setUsername] = useState("");
|
|
68
|
+
const [email, setEmail] = useState("");
|
|
69
|
+
const [password, setPassword] = useState("");
|
|
70
|
+
const [showVerification, setShowVerification] = useState(false);
|
|
71
|
+
const [appleAvailable, setAppleAvailable] = useState(false);
|
|
72
|
+
const providers = useQuery(api.auth.getEnabledProviders);
|
|
73
|
+
const showApple = appleAvailable && providers?.apple === true;
|
|
74
|
+
// When `emailFeatures` is false (minimal-tier setup, no Resend), the
|
|
75
|
+
// server auto-verifies on sign-up and the user is signed in immediately
|
|
76
|
+
//. no OTP step. When true (testflight tier+), the OTP verification
|
|
77
|
+
// screen renders after sign-up.
|
|
78
|
+
const emailFeatures = providers?.emailFeatures === true;
|
|
79
|
+
|
|
80
|
+
// Avatar picked at sign-up. Held until verifyEmail mints the session, then
|
|
81
|
+
// OtpVerification uploads it via generateAvatarUploadUrl + updateAvatar.
|
|
82
|
+
const [pendingAvatar, setPendingAvatar] = useState<PendingAvatar | null>(null);
|
|
83
|
+
const [avatarPicker, setAvatarPicker] = useState(false);
|
|
84
|
+
const [avatarError, setAvatarError] = useState<string | null>(null);
|
|
85
|
+
|
|
86
|
+
// Live username availability via the better-auth `username` plugin. Status
|
|
87
|
+
// is null while idle, true when the server says the handle is free, false
|
|
88
|
+
// when reserved or already taken. Format errors are left to the schema.
|
|
89
|
+
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
|
|
90
|
+
const [isCheckingUsername, setIsCheckingUsername] = useState(false);
|
|
91
|
+
const usernameCheckRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
92
|
+
|
|
93
|
+
const checkUsernameAvailability = useCallback(async (candidate: string) => {
|
|
94
|
+
setIsCheckingUsername(true);
|
|
95
|
+
try {
|
|
96
|
+
const result = await authClient.isUsernameAvailable({ username: candidate });
|
|
97
|
+
if (result.data) setUsernameAvailable(result.data.available);
|
|
98
|
+
} catch {
|
|
99
|
+
setUsernameAvailable(null);
|
|
100
|
+
} finally {
|
|
101
|
+
setIsCheckingUsername(false);
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const handleUsernameChange = useCallback(
|
|
106
|
+
(value: string) => {
|
|
107
|
+
// Force lowercase so the field reads what the schema stores. iOS would
|
|
108
|
+
// otherwise autocapitalize the first letter on `ascii-capable`.
|
|
109
|
+
const lower = value.toLowerCase();
|
|
110
|
+
setUsername(lower);
|
|
111
|
+
setUsernameAvailable(null);
|
|
112
|
+
if (usernameCheckRef.current) clearTimeout(usernameCheckRef.current);
|
|
113
|
+
const trimmed = lower.trim();
|
|
114
|
+
if (!trimmed || !isValidUsernameFormat(trimmed)) return;
|
|
115
|
+
if (isReservedUsername(trimmed)) {
|
|
116
|
+
setUsernameAvailable(false);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
usernameCheckRef.current = setTimeout(() => {
|
|
120
|
+
void checkUsernameAvailability(trimmed);
|
|
121
|
+
}, 500);
|
|
122
|
+
},
|
|
123
|
+
[checkUsernameAvailability],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
useEffect(
|
|
127
|
+
() => () => {
|
|
128
|
+
if (usernameCheckRef.current) clearTimeout(usernameCheckRef.current);
|
|
129
|
+
},
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const pickAvatar = useCallback(async (source: "library" | "camera") => {
|
|
134
|
+
setAvatarPicker(false);
|
|
135
|
+
// Wait for the action sheet to finish dismissing before opening the
|
|
136
|
+
// picker. iOS refuses to present a second view controller while one is
|
|
137
|
+
// still animating away.
|
|
138
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
139
|
+
const perm =
|
|
140
|
+
source === "camera"
|
|
141
|
+
? await ImagePicker.requestCameraPermissionsAsync()
|
|
142
|
+
: await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
143
|
+
if (!perm.granted) {
|
|
144
|
+
setAvatarError(source === "camera" ? "Camera access denied" : "Photos access denied");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
haptics.light();
|
|
148
|
+
const options: ImagePicker.ImagePickerOptions = {
|
|
149
|
+
mediaTypes: ["images"],
|
|
150
|
+
allowsEditing: true,
|
|
151
|
+
aspect: [1, 1],
|
|
152
|
+
quality: 0.8,
|
|
153
|
+
};
|
|
154
|
+
const result =
|
|
155
|
+
source === "camera"
|
|
156
|
+
? await ImagePicker.launchCameraAsync(options)
|
|
157
|
+
: await ImagePicker.launchImageLibraryAsync(options);
|
|
158
|
+
if (result.canceled) return;
|
|
159
|
+
const asset = result.assets[0];
|
|
160
|
+
if (!asset) return;
|
|
161
|
+
setAvatarError(null);
|
|
162
|
+
setPendingAvatar({ uri: asset.uri, mimeType: asset.mimeType ?? "image/jpeg" });
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const removeAvatar = useCallback(() => {
|
|
166
|
+
setAvatarPicker(false);
|
|
167
|
+
haptics.medium();
|
|
168
|
+
setPendingAvatar(null);
|
|
169
|
+
setAvatarError(null);
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
AppleAuthentication.isAvailableAsync().then(setAppleAvailable);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
const navigation = useNavigation();
|
|
177
|
+
const hasInput =
|
|
178
|
+
name.length > 0 || username.length > 0 || email.length > 0 || password.length > 0;
|
|
179
|
+
// Hold the pending navigation action while we show the discard-changes
|
|
180
|
+
// ConfirmationDialog. Cleared on Discard (after dispatch) or Cancel.
|
|
181
|
+
const [pendingNavAction, setPendingNavAction] = useState<
|
|
182
|
+
Parameters<typeof navigation.dispatch>[0] | null
|
|
183
|
+
>(null);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!hasInput || showVerification) return;
|
|
186
|
+
return navigation.addListener("beforeRemove", (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setPendingNavAction(e.data.action);
|
|
189
|
+
});
|
|
190
|
+
}, [navigation, hasInput, showVerification]);
|
|
191
|
+
|
|
192
|
+
const [state, signUp, isPending] = useActionState<SignUpState, void>(async () => {
|
|
193
|
+
haptics.light();
|
|
194
|
+
|
|
195
|
+
const parsed = signUpSchema.safeParse({ name, username, email, password });
|
|
196
|
+
if (!parsed.success) {
|
|
197
|
+
haptics.error();
|
|
198
|
+
return { error: firstError(parsed)! };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
// When `emailFeatures` is true (testflight-tier setup +), the server has
|
|
203
|
+
// `sendVerificationOnSignUp` on and the response triggers an OTP email.
|
|
204
|
+
// When false (minimal-tier), the server creates a verified account
|
|
205
|
+
// immediately and Better Auth's `autoSignIn: true` returns a session
|
|
206
|
+
// token in the same call. no OTP step, the user lands signed in.
|
|
207
|
+
const response = await authClient.signUp.email({
|
|
208
|
+
email: parsed.data.email,
|
|
209
|
+
password: parsed.data.password,
|
|
210
|
+
name: parsed.data.name,
|
|
211
|
+
...(parsed.data.username ? { username: parsed.data.username } : {}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (response.error) {
|
|
215
|
+
haptics.error();
|
|
216
|
+
return { error: "Unable to create account. Please try a different email or username." };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
haptics.success();
|
|
220
|
+
if (emailFeatures) {
|
|
221
|
+
announce("Account created. Check your email for the verification code.");
|
|
222
|
+
setShowVerification(true);
|
|
223
|
+
return { verify: true };
|
|
224
|
+
}
|
|
225
|
+
announce("Account created. You're signed in.");
|
|
226
|
+
return { ok: true };
|
|
227
|
+
} catch {
|
|
228
|
+
haptics.error();
|
|
229
|
+
return { error: "An unexpected error occurred. Please try again." };
|
|
230
|
+
}
|
|
231
|
+
}, initialState);
|
|
232
|
+
|
|
233
|
+
const [appleState, signUpWithApple, isApplePending] = useActionState<SignUpState, void>(
|
|
234
|
+
async () => {
|
|
235
|
+
haptics.light();
|
|
236
|
+
try {
|
|
237
|
+
const credential = await AppleAuthentication.signInAsync({
|
|
238
|
+
requestedScopes: [
|
|
239
|
+
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
|
240
|
+
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!credential.identityToken) {
|
|
245
|
+
haptics.error();
|
|
246
|
+
return { error: "Apple did not return an identity token" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const response = await authClient.signIn.social({
|
|
250
|
+
provider: "apple",
|
|
251
|
+
idToken: { token: credential.identityToken },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (response.error) {
|
|
255
|
+
haptics.error();
|
|
256
|
+
return { error: response.error.message ?? "Apple sign-up failed" };
|
|
257
|
+
}
|
|
258
|
+
haptics.success();
|
|
259
|
+
announce("Signed up with Apple");
|
|
260
|
+
return { verify: false };
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof Error && "code" in e && e.code === "ERR_REQUEST_CANCELED") return {};
|
|
263
|
+
haptics.error();
|
|
264
|
+
return { error: "Apple sign-up failed" };
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
initialState,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const isLoading = isPending || isApplePending;
|
|
271
|
+
const error = state.error ?? appleState.error ?? avatarError;
|
|
272
|
+
const usernameStatus: { text: string; color: string } | null = (() => {
|
|
273
|
+
if (!username || !isValidUsernameFormat(username.trim().toLowerCase())) return null;
|
|
274
|
+
if (isCheckingUsername) {
|
|
275
|
+
return { text: "Checking availability...", color: colors.mutedForeground as string };
|
|
276
|
+
}
|
|
277
|
+
if (usernameAvailable === true) {
|
|
278
|
+
return { text: "Username is available", color: colors.success as string };
|
|
279
|
+
}
|
|
280
|
+
if (usernameAvailable === false) {
|
|
281
|
+
return { text: "This username is not available", color: colors.destructive as string };
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
})();
|
|
285
|
+
|
|
286
|
+
if (showVerification) {
|
|
287
|
+
return (
|
|
288
|
+
<OtpVerification
|
|
289
|
+
email={email}
|
|
290
|
+
pendingAvatar={pendingAvatar}
|
|
291
|
+
onBack={() => setShowVerification(false)}
|
|
292
|
+
/>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
|
|
297
|
+
const helperModifiers = [dfont({ size: 13 }), foregroundStyle(colors.mutedForeground as string)];
|
|
298
|
+
const inputModifiers = [
|
|
299
|
+
textFieldStyle("plain"),
|
|
300
|
+
padding({ horizontal: 16 }),
|
|
301
|
+
frame({ maxWidth: Infinity, height: ButtonTokens.height }),
|
|
302
|
+
background(colors.muted as string),
|
|
303
|
+
clipShape("capsule"),
|
|
304
|
+
dfont({ size: 16 }),
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
309
|
+
<ScrollView
|
|
310
|
+
modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
|
|
311
|
+
>
|
|
312
|
+
<VStack
|
|
313
|
+
spacing={20}
|
|
314
|
+
alignment="leading"
|
|
315
|
+
modifiers={[padding({ horizontal: 24, top: 60, bottom: 40 })]}
|
|
316
|
+
>
|
|
317
|
+
<RNHostView matchContents>
|
|
318
|
+
<ExpoImage
|
|
319
|
+
source={brandIcon}
|
|
320
|
+
style={{ width: 56, height: 56 } as never}
|
|
321
|
+
accessibilityLabel="App icon"
|
|
322
|
+
contentFit="contain"
|
|
323
|
+
/>
|
|
324
|
+
</RNHostView>
|
|
325
|
+
|
|
326
|
+
<VStack spacing={6} alignment="leading">
|
|
327
|
+
<Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Create your account</Text>
|
|
328
|
+
<Text
|
|
329
|
+
modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
|
|
330
|
+
>
|
|
331
|
+
A verification code will be sent to confirm your email.
|
|
332
|
+
</Text>
|
|
333
|
+
</VStack>
|
|
334
|
+
|
|
335
|
+
<SegmentedToggle
|
|
336
|
+
value="sign-up"
|
|
337
|
+
options={[
|
|
338
|
+
{ value: "sign-in", label: "Sign in" },
|
|
339
|
+
{ value: "sign-up", label: "Sign up" },
|
|
340
|
+
]}
|
|
341
|
+
onChange={(v) => {
|
|
342
|
+
if (v === "sign-in") router.replace("/sign-in");
|
|
343
|
+
}}
|
|
344
|
+
/>
|
|
345
|
+
|
|
346
|
+
{error && <ErrorText>{error}</ErrorText>}
|
|
347
|
+
|
|
348
|
+
<VStack spacing={10} alignment="leading" modifiers={[frame({ maxWidth: 10000 })]}>
|
|
349
|
+
<Text modifiers={labelModifiers}>Profile photo (optional)</Text>
|
|
350
|
+
<ConfirmationDialog
|
|
351
|
+
title="Profile photo"
|
|
352
|
+
isPresented={avatarPicker}
|
|
353
|
+
onIsPresentedChange={setAvatarPicker}
|
|
354
|
+
titleVisibility="visible"
|
|
355
|
+
>
|
|
356
|
+
<ConfirmationDialog.Trigger>
|
|
357
|
+
<HStack
|
|
358
|
+
spacing={16}
|
|
359
|
+
alignment="center"
|
|
360
|
+
modifiers={[
|
|
361
|
+
frame({ maxWidth: 10000 }),
|
|
362
|
+
onTapGesture(() => {
|
|
363
|
+
haptics.light();
|
|
364
|
+
setAvatarPicker(true);
|
|
365
|
+
}),
|
|
366
|
+
accessibilityLabel(
|
|
367
|
+
pendingAvatar ? "Change profile photo" : "Add profile photo",
|
|
368
|
+
),
|
|
369
|
+
]}
|
|
370
|
+
>
|
|
371
|
+
{pendingAvatar ? (
|
|
372
|
+
<RNHostView matchContents>
|
|
373
|
+
<ExpoImage
|
|
374
|
+
source={{ uri: pendingAvatar.uri }}
|
|
375
|
+
style={
|
|
376
|
+
{
|
|
377
|
+
width: AVATAR_SIZE,
|
|
378
|
+
height: AVATAR_SIZE,
|
|
379
|
+
borderRadius: AVATAR_SIZE / 2,
|
|
380
|
+
} as never
|
|
381
|
+
}
|
|
382
|
+
contentFit="cover"
|
|
383
|
+
accessibilityLabel="Selected profile photo"
|
|
384
|
+
/>
|
|
385
|
+
</RNHostView>
|
|
386
|
+
) : (
|
|
387
|
+
<VStack
|
|
388
|
+
alignment="center"
|
|
389
|
+
modifiers={[
|
|
390
|
+
frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }),
|
|
391
|
+
background(colors.muted as string),
|
|
392
|
+
border({ color: colors.border as string, width: 2 }),
|
|
393
|
+
clipShape("circle"),
|
|
394
|
+
]}
|
|
395
|
+
>
|
|
396
|
+
<Image
|
|
397
|
+
systemName="camera"
|
|
398
|
+
size={20}
|
|
399
|
+
color={colors.mutedForeground as string}
|
|
400
|
+
/>
|
|
401
|
+
</VStack>
|
|
402
|
+
)}
|
|
403
|
+
<Text modifiers={helperModifiers}>
|
|
404
|
+
{pendingAvatar ? "Photo selected" : "Click to upload"}
|
|
405
|
+
</Text>
|
|
406
|
+
<Spacer />
|
|
407
|
+
</HStack>
|
|
408
|
+
</ConfirmationDialog.Trigger>
|
|
409
|
+
<ConfirmationDialog.Actions>
|
|
410
|
+
<Button
|
|
411
|
+
label="Choose Photo"
|
|
412
|
+
systemImage="photo.on.rectangle"
|
|
413
|
+
onPress={() => pickAvatar("library")}
|
|
414
|
+
/>
|
|
415
|
+
<Button
|
|
416
|
+
label="Take Photo"
|
|
417
|
+
systemImage="camera"
|
|
418
|
+
onPress={() => pickAvatar("camera")}
|
|
419
|
+
/>
|
|
420
|
+
{pendingAvatar ? (
|
|
421
|
+
<Button label="Remove Photo" role="destructive" onPress={removeAvatar} />
|
|
422
|
+
) : null}
|
|
423
|
+
<Button label="Cancel" role="cancel" />
|
|
424
|
+
</ConfirmationDialog.Actions>
|
|
425
|
+
</ConfirmationDialog>
|
|
426
|
+
</VStack>
|
|
427
|
+
|
|
428
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
429
|
+
<Text modifiers={labelModifiers}>Name</Text>
|
|
430
|
+
<TextField
|
|
431
|
+
placeholder="Your name"
|
|
432
|
+
onTextChange={setName}
|
|
433
|
+
modifiers={[
|
|
434
|
+
...inputModifiers,
|
|
435
|
+
textInputAutocapitalization("words"),
|
|
436
|
+
disabled(isLoading),
|
|
437
|
+
submitLabel("next"),
|
|
438
|
+
accessibilityLabel("Full name"),
|
|
439
|
+
accessibilityHint("Enter the name to display on your account"),
|
|
440
|
+
]}
|
|
441
|
+
/>
|
|
442
|
+
</VStack>
|
|
443
|
+
|
|
444
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
445
|
+
<Text modifiers={labelModifiers}>Username (optional)</Text>
|
|
446
|
+
<TextField
|
|
447
|
+
placeholder="johndoe"
|
|
448
|
+
onTextChange={handleUsernameChange}
|
|
449
|
+
modifiers={[
|
|
450
|
+
...inputModifiers,
|
|
451
|
+
keyboardType("ascii-capable"),
|
|
452
|
+
autocorrectionDisabled(),
|
|
453
|
+
textInputAutocapitalization("never"),
|
|
454
|
+
disabled(isLoading),
|
|
455
|
+
submitLabel("next"),
|
|
456
|
+
accessibilityLabel("Username"),
|
|
457
|
+
accessibilityHint("Choose a unique handle, 3 to 30 characters"),
|
|
458
|
+
]}
|
|
459
|
+
/>
|
|
460
|
+
{usernameStatus ? (
|
|
461
|
+
<Text
|
|
462
|
+
modifiers={[dfont({ size: 13 }), foregroundStyle(usernameStatus.color as string)]}
|
|
463
|
+
>
|
|
464
|
+
{usernameStatus.text}
|
|
465
|
+
</Text>
|
|
466
|
+
) : (
|
|
467
|
+
<Text modifiers={helperModifiers}>A unique handle others can use to find you.</Text>
|
|
468
|
+
)}
|
|
469
|
+
</VStack>
|
|
470
|
+
|
|
471
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
472
|
+
<Text modifiers={labelModifiers}>Email</Text>
|
|
473
|
+
<TextField
|
|
474
|
+
placeholder="you@example.com"
|
|
475
|
+
onTextChange={setEmail}
|
|
476
|
+
modifiers={[
|
|
477
|
+
...inputModifiers,
|
|
478
|
+
keyboardType("email-address"),
|
|
479
|
+
autocorrectionDisabled(),
|
|
480
|
+
textInputAutocapitalization("never"),
|
|
481
|
+
disabled(isLoading),
|
|
482
|
+
submitLabel("next"),
|
|
483
|
+
accessibilityLabel("Email address"),
|
|
484
|
+
accessibilityHint("Enter the email address you want to use for your account"),
|
|
485
|
+
]}
|
|
486
|
+
/>
|
|
487
|
+
</VStack>
|
|
488
|
+
|
|
489
|
+
<VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
|
|
490
|
+
<Text modifiers={labelModifiers}>Password</Text>
|
|
491
|
+
<PasswordField
|
|
492
|
+
onTextChange={setPassword}
|
|
493
|
+
onSubmit={() => startTransition(() => signUp())}
|
|
494
|
+
disabled={isLoading}
|
|
495
|
+
accessibilityLabel="Password"
|
|
496
|
+
accessibilityHint="Enter a password with at least 10 characters"
|
|
497
|
+
/>
|
|
498
|
+
<Text modifiers={helperModifiers}>At least 10 characters.</Text>
|
|
499
|
+
</VStack>
|
|
500
|
+
|
|
501
|
+
<ProminentButton
|
|
502
|
+
label={isPending ? "Creating account..." : "Create account"}
|
|
503
|
+
onPress={() => startTransition(() => signUp())}
|
|
504
|
+
disabled={isLoading}
|
|
505
|
+
/>
|
|
506
|
+
|
|
507
|
+
{showApple && (
|
|
508
|
+
<VStack
|
|
509
|
+
alignment="center"
|
|
510
|
+
modifiers={[frame({ maxWidth: Infinity, height: ButtonTokens.height })]}
|
|
511
|
+
>
|
|
512
|
+
<RNHostView>
|
|
513
|
+
<AppleAuthentication.AppleAuthenticationButton
|
|
514
|
+
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
|
|
515
|
+
buttonStyle={
|
|
516
|
+
colorScheme === "dark"
|
|
517
|
+
? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
|
|
518
|
+
: AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
|
|
519
|
+
}
|
|
520
|
+
cornerRadius={ButtonTokens.cornerRadius}
|
|
521
|
+
style={{
|
|
522
|
+
width: "100%",
|
|
523
|
+
height: "100%",
|
|
524
|
+
opacity: isLoading ? 0.5 : 1,
|
|
525
|
+
}}
|
|
526
|
+
onPress={() => startTransition(() => signUpWithApple())}
|
|
527
|
+
/>
|
|
528
|
+
</RNHostView>
|
|
529
|
+
</VStack>
|
|
530
|
+
)}
|
|
531
|
+
</VStack>
|
|
532
|
+
</ScrollView>
|
|
533
|
+
|
|
534
|
+
<ConfirmationDialog
|
|
535
|
+
title="Discard changes?"
|
|
536
|
+
isPresented={pendingNavAction !== null}
|
|
537
|
+
onIsPresentedChange={(v) => {
|
|
538
|
+
if (!v) setPendingNavAction(null);
|
|
539
|
+
}}
|
|
540
|
+
titleVisibility="visible"
|
|
541
|
+
>
|
|
542
|
+
<ConfirmationDialog.Trigger>
|
|
543
|
+
<Spacer modifiers={[frame({ width: 0, height: 0 })]} />
|
|
544
|
+
</ConfirmationDialog.Trigger>
|
|
545
|
+
<ConfirmationDialog.Actions>
|
|
546
|
+
<Button
|
|
547
|
+
label="Discard"
|
|
548
|
+
role="destructive"
|
|
549
|
+
onPress={() => {
|
|
550
|
+
const action = pendingNavAction;
|
|
551
|
+
setPendingNavAction(null);
|
|
552
|
+
if (action) navigation.dispatch(action);
|
|
553
|
+
}}
|
|
554
|
+
/>
|
|
555
|
+
<Button label="Keep Editing" role="cancel" />
|
|
556
|
+
</ConfirmationDialog.Actions>
|
|
557
|
+
<ConfirmationDialog.Message>
|
|
558
|
+
<Text modifiers={[dfont({ size: 16 })]}>You have unsaved input that will be lost.</Text>
|
|
559
|
+
</ConfirmationDialog.Message>
|
|
560
|
+
</ConfirmationDialog>
|
|
561
|
+
</Host>
|
|
562
|
+
);
|
|
563
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NativeIntent } from "expo-router";
|
|
2
|
+
import { isValidDeepLink } from "@/lib/deep-link";
|
|
3
|
+
|
|
4
|
+
export const redirectSystemPath: NativeIntent["redirectSystemPath"] = ({
|
|
5
|
+
path,
|
|
6
|
+
initial: _initial,
|
|
7
|
+
}) => {
|
|
8
|
+
if (!isValidDeepLink(path)) {
|
|
9
|
+
if (__DEV__) console.warn("[NativeIntent] Blocked:", path);
|
|
10
|
+
return "/";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return path;
|
|
14
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { router, Stack } from "expo-router";
|
|
2
|
+
import { Host, VStack, Text, Spacer, Image } from "@expo/ui/swift-ui";
|
|
3
|
+
import {
|
|
4
|
+
foregroundStyle,
|
|
5
|
+
multilineTextAlignment,
|
|
6
|
+
padding,
|
|
7
|
+
tint,
|
|
8
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
9
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
10
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
11
|
+
import { useColors } from "@/hooks/use-theme";
|
|
12
|
+
|
|
13
|
+
export default function NotFoundScreen() {
|
|
14
|
+
const dfont = useDynamicFont();
|
|
15
|
+
const colors = useColors();
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<Stack.Header>
|
|
19
|
+
<Stack.Screen.Title>Lost?</Stack.Screen.Title>
|
|
20
|
+
</Stack.Header>
|
|
21
|
+
<Host style={{ flex: 1 }}>
|
|
22
|
+
<VStack
|
|
23
|
+
spacing={20}
|
|
24
|
+
alignment="center"
|
|
25
|
+
modifiers={[padding({ horizontal: 24, vertical: 32 }), tint(colors.primary as string)]}
|
|
26
|
+
>
|
|
27
|
+
<Spacer />
|
|
28
|
+
<Image
|
|
29
|
+
systemName="questionmark.circle"
|
|
30
|
+
size={56}
|
|
31
|
+
color={colors.mutedForeground as string}
|
|
32
|
+
/>
|
|
33
|
+
<Text modifiers={[dfont({ size: 24, weight: "bold" }), multilineTextAlignment("center")]}>
|
|
34
|
+
This page doesn't exist
|
|
35
|
+
</Text>
|
|
36
|
+
<Text
|
|
37
|
+
modifiers={[
|
|
38
|
+
dfont({ size: 15 }),
|
|
39
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
40
|
+
multilineTextAlignment("center"),
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
The page you were looking for moved or was never here.
|
|
44
|
+
</Text>
|
|
45
|
+
<ProminentButton label="Take me home" onPress={() => router.replace("/")} />
|
|
46
|
+
<Spacer />
|
|
47
|
+
</VStack>
|
|
48
|
+
</Host>
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|