create-m5kdev 0.25.2 → 0.26.1

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.
Files changed (143) hide show
  1. package/dist/src/create.cjs +18 -1
  2. package/dist/src/create.cjs.map +1 -1
  3. package/dist/src/fs.cjs +65 -5
  4. package/dist/src/fs.cjs.map +1 -1
  5. package/dist/src/fs.d.cts +13 -2
  6. package/dist/src/index.cjs +0 -0
  7. package/dist/src/paths.cjs +2 -2
  8. package/dist/src/prompts.cjs +22 -0
  9. package/dist/src/prompts.cjs.map +1 -1
  10. package/dist/src/runCli.cjs +13 -2
  11. package/dist/src/runCli.cjs.map +1 -1
  12. package/dist/src/types.d.cts +9 -1
  13. package/package.json +8 -6
  14. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
  15. package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
  16. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
  17. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
  18. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
  19. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
  20. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
  21. package/templates/minimal-app/README.md.tpl +3 -3
  22. package/templates/minimal-app/apps/.gitignore.tpl +13 -0
  23. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
  24. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
  25. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
  26. package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
  27. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
  28. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
  29. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
  30. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
  31. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
  32. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
  33. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
  34. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
  35. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  36. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
  37. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
  38. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
  39. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
  40. package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
  41. package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
  42. package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
  43. package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
  44. package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
  45. package/templates/minimal-app/apps/expo/.prettierignore +13 -0
  46. package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
  47. package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
  48. package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
  49. package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
  50. package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
  51. package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
  52. package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
  53. package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
  54. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
  55. package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
  56. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
  57. package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
  58. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
  59. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
  60. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
  61. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
  62. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
  63. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
  64. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
  65. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
  66. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
  67. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
  68. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
  69. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
  70. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
  71. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
  72. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
  73. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
  74. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
  75. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
  76. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
  77. package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
  78. package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
  79. package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
  80. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
  81. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
  82. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
  83. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
  84. package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
  85. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
  86. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
  87. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
  88. package/templates/minimal-app/apps/server/package.json.tpl +56 -52
  89. package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
  90. package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
  91. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
  92. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
  93. package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
  94. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
  95. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  96. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
  97. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
  98. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
  99. package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
  100. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
  101. package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
  102. package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
  103. package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
  104. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
  105. package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
  106. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
  107. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  108. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  109. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
  110. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
  111. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
  112. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
  113. package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
  114. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
  115. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
  116. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
  117. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
  118. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  119. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
  120. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
  121. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
  122. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
  123. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
  124. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
  125. package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
  126. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  127. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
  128. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  129. package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
  130. package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
  131. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  132. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  133. package/templates/minimal-app/template.manifest.json +21 -0
  134. package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
  135. package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
  136. package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
  137. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
  138. package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
  139. package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
  140. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
  141. package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
  142. package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
  143. /package/templates/minimal-app/apps/webapp/public/{logo.svg → logo.svg.tpl} +0 -0
@@ -0,0 +1,71 @@
1
+ import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
2
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
3
+ import { Link, useRouter } from "expo-router";
4
+ import { Button } from "heroui-native";
5
+ import { useState } from "react";
6
+ import { Text, View } from "react-native";
7
+ import { AuthScaffold } from "../components/AuthScaffold";
8
+ import { FormTextField } from "../components/FormTextField";
9
+ import { StatusText } from "../components/StatusText";
10
+
11
+ export default function LoginScreen() {
12
+ const authClient = useAuthClient();
13
+ const { registerSession } = useSession();
14
+ const router = useRouter();
15
+ const [email, setEmail] = useState("");
16
+ const [password, setPassword] = useState("");
17
+ const [status, setStatus] = useState<string | null>(null);
18
+ const [isBusy, setIsBusy] = useState(false);
19
+
20
+ const submit = async () => {
21
+ setIsBusy(true);
22
+ setStatus(null);
23
+ try {
24
+ const result = await authClient.signIn.email({ email, password });
25
+ if (result.error || !result.data?.user) {
26
+ setStatus(result.error?.message ?? "Authentication failed");
27
+ return;
28
+ }
29
+ registerSession(() => router.replace("/posts"));
30
+ } catch (error) {
31
+ setStatus(error instanceof Error ? error.message : "Authentication failed");
32
+ } finally {
33
+ setIsBusy(false);
34
+ }
35
+ };
36
+
37
+ return (
38
+ <AuthScaffold title="Login" description="Sign in to the protected Expo test app.">
39
+ <View className="gap-4">
40
+ <FormTextField
41
+ label="Email"
42
+ testID="login-email"
43
+ value={email}
44
+ onChangeText={setEmail}
45
+ keyboardType="email-address"
46
+ />
47
+ <FormTextField
48
+ label="Password"
49
+ testID="login-password"
50
+ value={password}
51
+ onChangeText={setPassword}
52
+ secureTextEntry
53
+ />
54
+ <StatusText testID="auth-error" tone="error">
55
+ {status}
56
+ </StatusText>
57
+ <Button testID="login-submit" onPress={submit} isDisabled={isBusy}>
58
+ Login
59
+ </Button>
60
+ <View className="flex-row justify-between">
61
+ <Link href="/signup" asChild>
62
+ <Text className="text-primary underline">Sign up</Text>
63
+ </Link>
64
+ <Link href="/forgot-password" asChild>
65
+ <Text className="text-primary underline">Forgot password</Text>
66
+ </Link>
67
+ </View>
68
+ </View>
69
+ </AuthScaffold>
70
+ );
71
+ }
@@ -0,0 +1,19 @@
1
+ import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
2
+ import { Redirect } from "expo-router";
3
+ import { useEffect, useState } from "react";
4
+ import { LoadingScreen } from "../components/LoadingScreen";
5
+
6
+ export default function LogoutScreen() {
7
+ const authClient = useAuthClient();
8
+ const [done, setDone] = useState(false);
9
+
10
+ useEffect(() => {
11
+ authClient.signOut().finally(() => setDone(true));
12
+ }, [authClient]);
13
+
14
+ if (!done) {
15
+ return <LoadingScreen />;
16
+ }
17
+
18
+ return <Redirect href="/login" />;
19
+ }
@@ -0,0 +1,85 @@
1
+ import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
2
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
3
+ import { type Href, Redirect, useLocalSearchParams, useRouter } from "expo-router";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { LoadingScreen } from "../../components/LoadingScreen";
6
+ import { trpcClient } from "../../lib/trpc";
7
+
8
+ function singleParam(value: string | string[] | undefined) {
9
+ return Array.isArray(value) ? value[0] : value;
10
+ }
11
+
12
+ export default function AcceptInvitationScreen() {
13
+ const authClient = useAuthClient();
14
+ const { data: session, registerSession } = useSession();
15
+ const router = useRouter();
16
+ const params = useLocalSearchParams<{ id?: string | string[] }>();
17
+ const invitationId = singleParam(params.id);
18
+ const [errorPath, setErrorPath] = useState<string | null>(null);
19
+
20
+ const signupPath = useMemo(() => {
21
+ if (!invitationId) return null;
22
+ return trpcClient.auth.readInvitation
23
+ .query({ id: invitationId })
24
+ .then((invitation) => {
25
+ const search = new URLSearchParams({
26
+ invitation: invitationId,
27
+ email: invitation.email,
28
+ });
29
+ return `/signup?${search.toString()}`;
30
+ })
31
+ .catch(() => "/signup");
32
+ }, [invitationId]);
33
+
34
+ useEffect(() => {
35
+ let cancelled = false;
36
+
37
+ if (!invitationId) {
38
+ setErrorPath("/signup");
39
+ return;
40
+ }
41
+
42
+ if (!session) {
43
+ signupPath?.then((path) => {
44
+ if (!cancelled) router.replace(path as Href);
45
+ });
46
+ return;
47
+ }
48
+
49
+ authClient.organization
50
+ .acceptInvitation({ invitationId })
51
+ .then(async ({ data, error }) => {
52
+ if (error) {
53
+ throw new Error(error.message ?? "Unable to accept invitation");
54
+ }
55
+
56
+ const organizationId =
57
+ (data as { invitation?: { organizationId?: string } | null } | null)?.invitation
58
+ ?.organizationId ?? null;
59
+
60
+ if (organizationId) {
61
+ const activeResult = await authClient.organization.setActive({ organizationId });
62
+ if (activeResult.error) {
63
+ throw new Error(activeResult.error.message ?? "Unable to activate organization");
64
+ }
65
+ }
66
+
67
+ if (!cancelled) {
68
+ registerSession(() => router.replace("/posts"));
69
+ }
70
+ })
71
+ .catch(() => {
72
+ if (!cancelled) setErrorPath("/signup");
73
+ });
74
+
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [authClient, invitationId, registerSession, router, session, signupPath]);
79
+
80
+ if (errorPath) {
81
+ return <Redirect href={errorPath as Href} />;
82
+ }
83
+
84
+ return <LoadingScreen />;
85
+ }
@@ -0,0 +1,40 @@
1
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
2
+ import { Link, Redirect } from "expo-router";
3
+ import { Button, Card, Typography } from "heroui-native";
4
+ import { ScrollView, Text, View } from "react-native";
5
+
6
+ export default function PostsScreen() {
7
+ const { data: session } = useSession();
8
+
9
+ if (!session) {
10
+ return <Redirect href="/login" />;
11
+ }
12
+
13
+ return (
14
+ <ScrollView className="flex-1 bg-background" contentContainerClassName="px-5 py-8">
15
+ <View className="gap-5">
16
+ <View className="gap-2">
17
+ <Text className="text-3xl font-semibold text-foreground">Editorial posts</Text>
18
+ <Typography.Paragraph className="text-default-600">
19
+ A small blog that protects auth changes across web and Expo.
20
+ </Typography.Paragraph>
21
+ <Text testID="session-email" className="text-sm text-default-600">
22
+ {session.user.email}
23
+ </Text>
24
+ </View>
25
+ <Card className="gap-2 p-5">
26
+ <Text className="text-lg font-semibold text-foreground">Native fixture post</Text>
27
+ <Typography.Paragraph>
28
+ This protected screen is rendered by Expo Router and authenticated through
29
+ @m5kdev/frontend.
30
+ </Typography.Paragraph>
31
+ </Card>
32
+ <Link href="/logout" asChild>
33
+ <Button testID="logout-submit" variant="outline">
34
+ Logout
35
+ </Button>
36
+ </Link>
37
+ </View>
38
+ </ScrollView>
39
+ );
40
+ }
@@ -0,0 +1,75 @@
1
+ import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
2
+ import { useLocalSearchParams } from "expo-router";
3
+ import { Button } from "heroui-native";
4
+ import { useState } from "react";
5
+ import { View } from "react-native";
6
+ import { AuthScaffold } from "../components/AuthScaffold";
7
+ import { FormTextField } from "../components/FormTextField";
8
+ import { StatusText } from "../components/StatusText";
9
+
10
+ function singleParam(value: string | string[] | undefined) {
11
+ return Array.isArray(value) ? value[0] : value;
12
+ }
13
+
14
+ export default function ResetPasswordScreen() {
15
+ const authClient = useAuthClient();
16
+ const params = useLocalSearchParams<{ token?: string | string[] }>();
17
+ const token = singleParam(params.token);
18
+ const [newPassword, setNewPassword] = useState("");
19
+ const [confirmPassword, setConfirmPassword] = useState("");
20
+ const [status, setStatus] = useState<string | null>(null);
21
+ const [tone, setTone] = useState<"error" | "success">("success");
22
+ const [isBusy, setIsBusy] = useState(false);
23
+
24
+ const submit = async () => {
25
+ setStatus(null);
26
+ if (!token) {
27
+ setTone("error");
28
+ setStatus("Reset token is required");
29
+ return;
30
+ }
31
+ if (newPassword !== confirmPassword) {
32
+ setTone("error");
33
+ setStatus("Passwords do not match");
34
+ return;
35
+ }
36
+ setIsBusy(true);
37
+ try {
38
+ await authClient.resetPassword({ newPassword, token });
39
+ setTone("success");
40
+ setStatus("Password reset successfully");
41
+ } catch (error) {
42
+ setTone("error");
43
+ setStatus(error instanceof Error ? error.message : "Password reset failed");
44
+ } finally {
45
+ setIsBusy(false);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <AuthScaffold title="Set a new password">
51
+ <View className="gap-4">
52
+ <FormTextField
53
+ label="New password"
54
+ testID="reset-password-new"
55
+ value={newPassword}
56
+ onChangeText={setNewPassword}
57
+ secureTextEntry
58
+ />
59
+ <FormTextField
60
+ label="Confirm password"
61
+ testID="reset-password-confirm"
62
+ value={confirmPassword}
63
+ onChangeText={setConfirmPassword}
64
+ secureTextEntry
65
+ />
66
+ <StatusText testID="auth-status" tone={tone}>
67
+ {status}
68
+ </StatusText>
69
+ <Button testID="reset-password-submit" onPress={submit} isDisabled={isBusy}>
70
+ Reset password
71
+ </Button>
72
+ </View>
73
+ </AuthScaffold>
74
+ );
75
+ }
@@ -0,0 +1,193 @@
1
+ import { useAuthClient } from "@m5kdev/frontend/modules/auth/hooks/useAuthClient";
2
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
3
+ import { USER_LOCALE_HEADER } from "@m5kdev/commons/modules/auth/auth.constants";
4
+ import { resolveAppLocale } from "@m5kdev/commons/modules/auth/auth.locale";
5
+ import { APP_LOCALE_CONFIG } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
6
+ import { Link, useLocalSearchParams, useRouter } from "expo-router";
7
+ import { Button } from "heroui-native";
8
+ import { useEffect, useMemo, useState } from "react";
9
+ import { Text, View } from "react-native";
10
+ import { AuthScaffold } from "../components/AuthScaffold";
11
+ import { FormTextField } from "../components/FormTextField";
12
+ import { StatusText } from "../components/StatusText";
13
+ import { isWaitlistProfile } from "../config";
14
+ import { trpcClient } from "../lib/trpc";
15
+
16
+ function singleParam(value: string | string[] | undefined) {
17
+ return Array.isArray(value) ? value[0] : value;
18
+ }
19
+
20
+ export default function SignupScreen() {
21
+ const authClient = useAuthClient();
22
+ const { registerSession } = useSession();
23
+ const router = useRouter();
24
+ const params = useLocalSearchParams<{
25
+ code?: string | string[];
26
+ email?: string | string[];
27
+ invitation?: string | string[];
28
+ }>();
29
+ const code = singleParam(params.code);
30
+ const invitation = singleParam(params.invitation);
31
+ const lockedEmail = singleParam(params.email);
32
+ const isPublicWaitlist = isWaitlistProfile && !code && !invitation;
33
+ const [email, setEmail] = useState(lockedEmail ?? "");
34
+ const [password, setPassword] = useState("");
35
+ const [status, setStatus] = useState<string | null>(null);
36
+ const [statusTone, setStatusTone] = useState<"error" | "success">("success");
37
+ const [isBusy, setIsBusy] = useState(false);
38
+ const [waitlistCodeStatus, setWaitlistCodeStatus] = useState<string | null>(null);
39
+ const [isValidatingWaitlistCode, setIsValidatingWaitlistCode] = useState(false);
40
+ const [waitlistCodeError, setWaitlistCodeError] = useState(false);
41
+
42
+ const waitlistStatusText = useMemo(() => {
43
+ if (!code) return null;
44
+ if (isValidatingWaitlistCode) return "Validating the invitation code...";
45
+ if (waitlistCodeError) return "An error occurred while validating the invitation code.";
46
+ if (waitlistCodeStatus === "VALID") return "Invitation code is valid. You can proceed.";
47
+ if (waitlistCodeStatus === "EXPIRED") return "Invitation code has expired.";
48
+ if (waitlistCodeStatus === "NOT_FOUND") return "Invitation code not found.";
49
+ if (waitlistCodeStatus) return "Invalid invitation code.";
50
+ return null;
51
+ }, [code, isValidatingWaitlistCode, waitlistCodeError, waitlistCodeStatus]);
52
+
53
+ useEffect(() => {
54
+ if (lockedEmail) setEmail(lockedEmail);
55
+ }, [lockedEmail]);
56
+
57
+ useEffect(() => {
58
+ let cancelled = false;
59
+ if (!code) {
60
+ setWaitlistCodeStatus(null);
61
+ setWaitlistCodeError(false);
62
+ return;
63
+ }
64
+
65
+ setIsValidatingWaitlistCode(true);
66
+ setWaitlistCodeError(false);
67
+ trpcClient.auth.validateWaitlistCode
68
+ .query({ code })
69
+ .then((result) => {
70
+ if (!cancelled) setWaitlistCodeStatus(result.status);
71
+ })
72
+ .catch(() => {
73
+ if (!cancelled) setWaitlistCodeError(true);
74
+ })
75
+ .finally(() => {
76
+ if (!cancelled) setIsValidatingWaitlistCode(false);
77
+ });
78
+
79
+ return () => {
80
+ cancelled = true;
81
+ };
82
+ }, [code]);
83
+
84
+ const submitWaitlist = async () => {
85
+ setIsBusy(true);
86
+ setStatus(null);
87
+ try {
88
+ await trpcClient.auth.joinWaitlist.mutate({ email });
89
+ setStatusTone("success");
90
+ setStatus("Waitlist confirmation email sent");
91
+ } catch (error) {
92
+ setStatusTone("error");
93
+ setStatus(error instanceof Error ? error.message : "Failed to join waitlist");
94
+ } finally {
95
+ setIsBusy(false);
96
+ }
97
+ };
98
+
99
+ const submitSignup = async () => {
100
+ setIsBusy(true);
101
+ setStatus(null);
102
+ try {
103
+ const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
104
+ const userLocale = resolveAppLocale(deviceLocale, APP_LOCALE_CONFIG);
105
+ const result = await authClient.signUp.email(
106
+ {
107
+ name: email,
108
+ email,
109
+ password,
110
+ },
111
+ {
112
+ headers: {
113
+ "Waitlist-Invitation-Code": code ?? "",
114
+ "Organization-Invitation-Code": invitation ?? "",
115
+ [USER_LOCALE_HEADER]: userLocale,
116
+ },
117
+ }
118
+ );
119
+ if (result.error) {
120
+ setStatusTone("error");
121
+ setStatus(result.error.message ?? "Signup failed");
122
+ return;
123
+ }
124
+ if (isWaitlistProfile || invitation) {
125
+ const loginResult = await authClient.signIn.email({ email, password });
126
+ if (loginResult.error || !loginResult.data?.user) {
127
+ setStatusTone("error");
128
+ setStatus(loginResult.error?.message ?? "Authentication failed");
129
+ return;
130
+ }
131
+ registerSession(() => router.replace("/posts"));
132
+ return;
133
+ }
134
+ setStatusTone("success");
135
+ setStatus("Verification email sent");
136
+ } catch (error) {
137
+ setStatusTone("error");
138
+ setStatus(error instanceof Error ? error.message : "Signup failed");
139
+ } finally {
140
+ setIsBusy(false);
141
+ }
142
+ };
143
+
144
+ return (
145
+ <AuthScaffold
146
+ title={isPublicWaitlist ? "Join the waitlist" : "Sign up"}
147
+ description={
148
+ isPublicWaitlist
149
+ ? "Request access to the waitlist test profile."
150
+ : "Create a test account for the Expo app."
151
+ }
152
+ >
153
+ <View className="gap-4">
154
+ <FormTextField
155
+ label="Email"
156
+ testID={isPublicWaitlist ? "waitlist-email" : "signup-email"}
157
+ value={email}
158
+ onChangeText={setEmail}
159
+ keyboardType="email-address"
160
+ editable={!lockedEmail}
161
+ />
162
+ {isPublicWaitlist ? null : (
163
+ <FormTextField
164
+ label="Password"
165
+ testID="signup-password"
166
+ value={password}
167
+ onChangeText={setPassword}
168
+ secureTextEntry
169
+ />
170
+ )}
171
+ <StatusText
172
+ testID="waitlist-code-status"
173
+ tone={waitlistCodeStatus === "VALID" ? "success" : "error"}
174
+ >
175
+ {waitlistStatusText}
176
+ </StatusText>
177
+ <StatusText testID="auth-status" tone={statusTone}>
178
+ {status}
179
+ </StatusText>
180
+ <Button
181
+ testID={isPublicWaitlist ? "waitlist-submit" : "signup-submit"}
182
+ onPress={isPublicWaitlist ? submitWaitlist : submitSignup}
183
+ isDisabled={isBusy}
184
+ >
185
+ {isPublicWaitlist ? "Join the waitlist" : "Sign up"}
186
+ </Button>
187
+ <Link href="/login" asChild>
188
+ <Text className="text-center text-primary underline">Already have an account?</Text>
189
+ </Link>
190
+ </View>
191
+ </AuthScaffold>
192
+ );
193
+ }
@@ -0,0 +1,34 @@
1
+ import { Card, Typography } from "heroui-native";
2
+ import type { ReactNode } from "react";
3
+ import { ScrollView, Text, View } from "react-native";
4
+ import { appTitle } from "../config";
5
+
6
+ export function AuthScaffold({
7
+ children,
8
+ description,
9
+ title,
10
+ }: {
11
+ children: ReactNode;
12
+ description?: string;
13
+ title: string;
14
+ }) {
15
+ return (
16
+ <ScrollView className="flex-1 bg-background" contentContainerClassName="flex-grow px-5 py-8">
17
+ <View className="flex-1 justify-center gap-5">
18
+ <View className="gap-2">
19
+ <Text className="text-center text-xs font-semibold uppercase tracking-widest text-default-500">
20
+ Expo auth fixture
21
+ </Text>
22
+ <Text className="text-center text-3xl font-semibold text-foreground">{appTitle}</Text>
23
+ <Typography.Paragraph className="text-center text-default-600">
24
+ {description ?? "Native auth screens backed by @m5kdev/frontend."}
25
+ </Typography.Paragraph>
26
+ </View>
27
+ <Card className="gap-5 p-5">
28
+ <Text className="text-xl font-semibold text-foreground">{title}</Text>
29
+ {children}
30
+ </Card>
31
+ </View>
32
+ </ScrollView>
33
+ );
34
+ }
@@ -0,0 +1,37 @@
1
+ import { Input, Label, TextField } from "heroui-native";
2
+ import type { TextInputProps } from "react-native";
3
+
4
+ export function FormTextField({
5
+ label,
6
+ testID,
7
+ value,
8
+ onChangeText,
9
+ secureTextEntry,
10
+ keyboardType,
11
+ autoCapitalize = "none",
12
+ editable = true,
13
+ }: {
14
+ label: string;
15
+ testID: string;
16
+ value: string;
17
+ onChangeText: (value: string) => void;
18
+ secureTextEntry?: boolean;
19
+ keyboardType?: TextInputProps["keyboardType"];
20
+ autoCapitalize?: TextInputProps["autoCapitalize"];
21
+ editable?: boolean;
22
+ }) {
23
+ return (
24
+ <TextField isRequired isDisabled={!editable}>
25
+ <Label>{label}</Label>
26
+ <Input
27
+ testID={testID}
28
+ value={value}
29
+ onChangeText={onChangeText}
30
+ secureTextEntry={secureTextEntry}
31
+ keyboardType={keyboardType}
32
+ autoCapitalize={autoCapitalize}
33
+ editable={editable}
34
+ />
35
+ </TextField>
36
+ );
37
+ }
@@ -0,0 +1,10 @@
1
+ import { Typography } from "heroui-native";
2
+ import { View } from "react-native";
3
+
4
+ export function LoadingScreen() {
5
+ return (
6
+ <View className="flex-1 items-center justify-center bg-background px-6">
7
+ <Typography.Paragraph testID="loading-state">Loading auth session...</Typography.Paragraph>
8
+ </View>
9
+ );
10
+ }
@@ -0,0 +1,22 @@
1
+ import { Text } from "react-native";
2
+
3
+ export function StatusText({
4
+ children,
5
+ tone = "default",
6
+ testID,
7
+ }: {
8
+ children: string | null;
9
+ tone?: "default" | "error" | "success";
10
+ testID?: string;
11
+ }) {
12
+ if (!children) return null;
13
+
14
+ const toneClass =
15
+ tone === "error" ? "text-danger" : tone === "success" ? "text-success" : "text-default-600";
16
+
17
+ return (
18
+ <Text testID={testID} className={`text-sm ${toneClass}`}>
19
+ {children}
20
+ </Text>
21
+ );
22
+ }
@@ -0,0 +1,9 @@
1
+ import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
2
+
3
+ export const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL ?? "http://127.0.0.1:8080";
4
+
5
+ export const appUrl = process.env.EXPO_PUBLIC_APP_URL ?? "http://127.0.0.1:8081";
6
+
7
+ export const isWaitlistProfile = process.env.EXPO_PUBLIC_ENABLE_WAITLIST === "true";
8
+
9
+ export const appTitle = APP_NAME;
@@ -0,0 +1,11 @@
1
+ @import "tailwindcss";
2
+ @import "uniwind";
3
+
4
+ @import "heroui-native/styles";
5
+
6
+ /* End-user path: heroui-native lives in the project's node_modules, one
7
+ level up from this src/global.css file. */
8
+ /* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind v4 source directive. */
9
+ @source '../node_modules/heroui-native/lib';
10
+ /* biome-ignore lint/suspicious/noUnknownAtRules: Tailwind v4 source directive for workspace package classes. */
11
+ @source '../../../../packages/frontend/dist/src/**/*.{js,ts,jsx,tsx}';
@@ -0,0 +1,20 @@
1
+ import type { BackendTRPCRouter } from "@m5kdev/backend/types";
2
+ import { transformer } from "@m5kdev/commons/utils/trpc";
3
+ import { createTRPCClient, httpBatchLink } from "@trpc/client";
4
+ import { serverUrl } from "../config";
5
+
6
+ export const trpcClient = createTRPCClient<BackendTRPCRouter>({
7
+ links: [
8
+ httpBatchLink({
9
+ url: `${serverUrl}/trpc`,
10
+ fetch(url, options) {
11
+ return fetch(url, {
12
+ ...options,
13
+ body: options?.body ? (options.body as BodyInit | null | undefined) : undefined,
14
+ credentials: "include",
15
+ });
16
+ },
17
+ transformer,
18
+ }),
19
+ ],
20
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "paths": {
6
+ "@/*": ["./src/*"],
7
+ "@/assets/*": ["./assets/*"]
8
+ }
9
+ },
10
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "uniwind-env.d.ts"]
11
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="uniwind/types" />
@@ -0,0 +1,10 @@
1
+ // NOTE: This file is generated by uniwind and it should not be edited manually.
2
+ /// <reference types="uniwind/types" />
3
+
4
+ declare module 'uniwind' {
5
+ export interface UniwindConfig {
6
+ themes: readonly ['light', 'dark']
7
+ }
8
+ }
9
+
10
+ export {}