@alfredmouelle/create-stack 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.
Files changed (206) hide show
  1. package/README.md +56 -0
  2. package/_stack/apps/next-base/.dockerignore +10 -0
  3. package/_stack/apps/next-base/Dockerfile +34 -0
  4. package/_stack/apps/next-base/README.md +32 -0
  5. package/_stack/apps/next-base/components.json +25 -0
  6. package/_stack/apps/next-base/drizzle.config.ts +16 -0
  7. package/_stack/apps/next-base/next.config.ts +8 -0
  8. package/_stack/apps/next-base/package.json +70 -0
  9. package/_stack/apps/next-base/postcss.config.mjs +7 -0
  10. package/_stack/apps/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  11. package/_stack/apps/next-base/src/app/api/trpc/[trpc]/route.ts +23 -0
  12. package/_stack/apps/next-base/src/app/auth/_components/forgot-password-form.tsx +92 -0
  13. package/_stack/apps/next-base/src/app/auth/_components/reset-password-form.tsx +105 -0
  14. package/_stack/apps/next-base/src/app/auth/_components/sign-in-form.tsx +126 -0
  15. package/_stack/apps/next-base/src/app/auth/_components/sign-up-form.tsx +139 -0
  16. package/_stack/apps/next-base/src/app/auth/_components/verify-email-actions.tsx +45 -0
  17. package/_stack/apps/next-base/src/app/auth/forgot-password/page.tsx +19 -0
  18. package/_stack/apps/next-base/src/app/auth/layout.tsx +9 -0
  19. package/_stack/apps/next-base/src/app/auth/reset-password/page.tsx +26 -0
  20. package/_stack/apps/next-base/src/app/auth/sign-in/page.tsx +27 -0
  21. package/_stack/apps/next-base/src/app/auth/sign-up/page.tsx +27 -0
  22. package/_stack/apps/next-base/src/app/auth/verify-email/page.tsx +30 -0
  23. package/_stack/apps/next-base/src/app/dashboard/page.tsx +12 -0
  24. package/_stack/apps/next-base/src/app/globals.css +171 -0
  25. package/_stack/apps/next-base/src/app/layout.tsx +23 -0
  26. package/_stack/apps/next-base/src/app/page.tsx +15 -0
  27. package/_stack/apps/next-base/src/components/data-table.tsx +77 -0
  28. package/_stack/apps/next-base/src/components/infinite-data-table.tsx +102 -0
  29. package/_stack/apps/next-base/src/components/sortable-header.tsx +37 -0
  30. package/_stack/apps/next-base/src/components/theme-provider.tsx +8 -0
  31. package/_stack/apps/next-base/src/components/theme-toggle.tsx +37 -0
  32. package/_stack/apps/next-base/src/components/ui/button.tsx +64 -0
  33. package/_stack/apps/next-base/src/components/ui/calendar.tsx +185 -0
  34. package/_stack/apps/next-base/src/components/ui/card.tsx +84 -0
  35. package/_stack/apps/next-base/src/components/ui/date-picker.tsx +85 -0
  36. package/_stack/apps/next-base/src/components/ui/date-range-picker.tsx +138 -0
  37. package/_stack/apps/next-base/src/components/ui/dropdown-menu.tsx +246 -0
  38. package/_stack/apps/next-base/src/components/ui/form.tsx +149 -0
  39. package/_stack/apps/next-base/src/components/ui/input-group.tsx +97 -0
  40. package/_stack/apps/next-base/src/components/ui/input.tsx +18 -0
  41. package/_stack/apps/next-base/src/components/ui/label.tsx +18 -0
  42. package/_stack/apps/next-base/src/components/ui/popover.tsx +76 -0
  43. package/_stack/apps/next-base/src/components/ui/skeleton.tsx +13 -0
  44. package/_stack/apps/next-base/src/components/ui/spinner.tsx +8 -0
  45. package/_stack/apps/next-base/src/components/ui/table.tsx +87 -0
  46. package/_stack/apps/next-base/src/emails/components/components.tsx +199 -0
  47. package/_stack/apps/next-base/src/emails/components/context.tsx +18 -0
  48. package/_stack/apps/next-base/src/emails/components/index.ts +23 -0
  49. package/_stack/apps/next-base/src/emails/components/theme.ts +65 -0
  50. package/_stack/apps/next-base/src/emails/reset-password.tsx +16 -0
  51. package/_stack/apps/next-base/src/emails/verify-email.tsx +15 -0
  52. package/_stack/apps/next-base/src/env.ts +41 -0
  53. package/_stack/apps/next-base/src/features/auth/auth-card.tsx +30 -0
  54. package/_stack/apps/next-base/src/features/auth/form-alert.tsx +27 -0
  55. package/_stack/apps/next-base/src/features/auth/google-button.tsx +66 -0
  56. package/_stack/apps/next-base/src/features/auth/schemas.ts +35 -0
  57. package/_stack/apps/next-base/src/lib/date.ts +4 -0
  58. package/_stack/apps/next-base/src/lib/utils.ts +6 -0
  59. package/_stack/apps/next-base/src/server/api/root.ts +10 -0
  60. package/_stack/apps/next-base/src/server/api/routers/health.router.ts +8 -0
  61. package/_stack/apps/next-base/src/server/api/trpc.ts +56 -0
  62. package/_stack/apps/next-base/src/server/auth/guards.ts +10 -0
  63. package/_stack/apps/next-base/src/server/better-auth/client.ts +9 -0
  64. package/_stack/apps/next-base/src/server/better-auth/config.ts +60 -0
  65. package/_stack/apps/next-base/src/server/better-auth/emails.tsx +25 -0
  66. package/_stack/apps/next-base/src/server/better-auth/index.ts +1 -0
  67. package/_stack/apps/next-base/src/server/better-auth/server.ts +14 -0
  68. package/_stack/apps/next-base/src/server/db/index.ts +6 -0
  69. package/_stack/apps/next-base/src/server/db/keyset.ts +63 -0
  70. package/_stack/apps/next-base/src/server/db/schemas/auth.schema.ts +71 -0
  71. package/_stack/apps/next-base/src/server/db/schemas/index.ts +2 -0
  72. package/_stack/apps/next-base/src/server/db/seed.ts +27 -0
  73. package/_stack/apps/next-base/src/server/email/adapters/resend/config.ts +7 -0
  74. package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +75 -0
  75. package/_stack/apps/next-base/src/server/email/core/address.ts +21 -0
  76. package/_stack/apps/next-base/src/server/email/core/port.ts +89 -0
  77. package/_stack/apps/next-base/src/server/email/core/render.ts +16 -0
  78. package/_stack/apps/next-base/src/server/email/factory.ts +47 -0
  79. package/_stack/apps/next-base/src/server/email/index.ts +36 -0
  80. package/_stack/apps/next-base/src/trpc/query-client.ts +19 -0
  81. package/_stack/apps/next-base/src/trpc/react.tsx +62 -0
  82. package/_stack/apps/next-base/src/trpc/server.ts +23 -0
  83. package/_stack/apps/next-base/tsconfig.json +37 -0
  84. package/_stack/apps/tanstack-base/.dockerignore +13 -0
  85. package/_stack/apps/tanstack-base/Dockerfile +28 -0
  86. package/_stack/apps/tanstack-base/README.md +31 -0
  87. package/_stack/apps/tanstack-base/components.json +25 -0
  88. package/_stack/apps/tanstack-base/drizzle.config.ts +16 -0
  89. package/_stack/apps/tanstack-base/package.json +85 -0
  90. package/_stack/apps/tanstack-base/public/favicon.ico +0 -0
  91. package/_stack/apps/tanstack-base/public/logo192.png +0 -0
  92. package/_stack/apps/tanstack-base/public/logo512.png +0 -0
  93. package/_stack/apps/tanstack-base/public/manifest.json +25 -0
  94. package/_stack/apps/tanstack-base/public/robots.txt +3 -0
  95. package/_stack/apps/tanstack-base/src/components/data-table.tsx +77 -0
  96. package/_stack/apps/tanstack-base/src/components/form/field-error.tsx +18 -0
  97. package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +47 -0
  98. package/_stack/apps/tanstack-base/src/components/infinite-data-table.tsx +102 -0
  99. package/_stack/apps/tanstack-base/src/components/sortable-header.tsx +37 -0
  100. package/_stack/apps/tanstack-base/src/components/theme-provider.tsx +69 -0
  101. package/_stack/apps/tanstack-base/src/components/theme-toggle.tsx +35 -0
  102. package/_stack/apps/tanstack-base/src/components/ui/button.tsx +64 -0
  103. package/_stack/apps/tanstack-base/src/components/ui/calendar.tsx +185 -0
  104. package/_stack/apps/tanstack-base/src/components/ui/card.tsx +84 -0
  105. package/_stack/apps/tanstack-base/src/components/ui/date-picker.tsx +83 -0
  106. package/_stack/apps/tanstack-base/src/components/ui/date-range-picker.tsx +136 -0
  107. package/_stack/apps/tanstack-base/src/components/ui/dropdown-menu.tsx +246 -0
  108. package/_stack/apps/tanstack-base/src/components/ui/input-group.tsx +97 -0
  109. package/_stack/apps/tanstack-base/src/components/ui/input.tsx +18 -0
  110. package/_stack/apps/tanstack-base/src/components/ui/label.tsx +18 -0
  111. package/_stack/apps/tanstack-base/src/components/ui/popover.tsx +74 -0
  112. package/_stack/apps/tanstack-base/src/components/ui/skeleton.tsx +13 -0
  113. package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +8 -0
  114. package/_stack/apps/tanstack-base/src/components/ui/table.tsx +87 -0
  115. package/_stack/apps/tanstack-base/src/emails/components/components.tsx +199 -0
  116. package/_stack/apps/tanstack-base/src/emails/components/context.tsx +18 -0
  117. package/_stack/apps/tanstack-base/src/emails/components/index.ts +23 -0
  118. package/_stack/apps/tanstack-base/src/emails/components/theme.ts +65 -0
  119. package/_stack/apps/tanstack-base/src/emails/reset-password.tsx +16 -0
  120. package/_stack/apps/tanstack-base/src/emails/verify-email.tsx +15 -0
  121. package/_stack/apps/tanstack-base/src/env.ts +41 -0
  122. package/_stack/apps/tanstack-base/src/features/auth/auth-card.tsx +30 -0
  123. package/_stack/apps/tanstack-base/src/features/auth/form-alert.tsx +27 -0
  124. package/_stack/apps/tanstack-base/src/features/auth/google-button.tsx +64 -0
  125. package/_stack/apps/tanstack-base/src/features/auth/schemas.ts +35 -0
  126. package/_stack/apps/tanstack-base/src/lib/date.ts +4 -0
  127. package/_stack/apps/tanstack-base/src/lib/utils.ts +6 -0
  128. package/_stack/apps/tanstack-base/src/router.tsx +40 -0
  129. package/_stack/apps/tanstack-base/src/routes/__root.tsx +73 -0
  130. package/_stack/apps/tanstack-base/src/routes/_authed/dashboard.tsx +12 -0
  131. package/_stack/apps/tanstack-base/src/routes/_authed.tsx +21 -0
  132. package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +14 -0
  133. package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +31 -0
  134. package/_stack/apps/tanstack-base/src/routes/auth/forgot-password.tsx +89 -0
  135. package/_stack/apps/tanstack-base/src/routes/auth/reset-password.tsx +111 -0
  136. package/_stack/apps/tanstack-base/src/routes/auth/sign-in.tsx +117 -0
  137. package/_stack/apps/tanstack-base/src/routes/auth/sign-up.tsx +119 -0
  138. package/_stack/apps/tanstack-base/src/routes/auth/verify-email.tsx +72 -0
  139. package/_stack/apps/tanstack-base/src/routes/auth.tsx +22 -0
  140. package/_stack/apps/tanstack-base/src/routes/index.tsx +18 -0
  141. package/_stack/apps/tanstack-base/src/server/api/root.ts +10 -0
  142. package/_stack/apps/tanstack-base/src/server/api/routers/health.router.ts +8 -0
  143. package/_stack/apps/tanstack-base/src/server/api/trpc.ts +61 -0
  144. package/_stack/apps/tanstack-base/src/server/better-auth/client.ts +9 -0
  145. package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +68 -0
  146. package/_stack/apps/tanstack-base/src/server/better-auth/emails.tsx +25 -0
  147. package/_stack/apps/tanstack-base/src/server/better-auth/index.ts +1 -0
  148. package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +9 -0
  149. package/_stack/apps/tanstack-base/src/server/db/index.ts +6 -0
  150. package/_stack/apps/tanstack-base/src/server/db/keyset.ts +63 -0
  151. package/_stack/apps/tanstack-base/src/server/db/schemas/auth.schema.ts +71 -0
  152. package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +2 -0
  153. package/_stack/apps/tanstack-base/src/server/db/seed.ts +27 -0
  154. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/config.ts +7 -0
  155. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +75 -0
  156. package/_stack/apps/tanstack-base/src/server/email/core/address.ts +21 -0
  157. package/_stack/apps/tanstack-base/src/server/email/core/port.ts +89 -0
  158. package/_stack/apps/tanstack-base/src/server/email/core/render.ts +16 -0
  159. package/_stack/apps/tanstack-base/src/server/email/factory.ts +47 -0
  160. package/_stack/apps/tanstack-base/src/server/email/index.ts +36 -0
  161. package/_stack/apps/tanstack-base/src/styles.css +171 -0
  162. package/_stack/apps/tanstack-base/src/trpc/devtools.tsx +6 -0
  163. package/_stack/apps/tanstack-base/src/trpc/query-client.ts +19 -0
  164. package/_stack/apps/tanstack-base/src/trpc/react.tsx +49 -0
  165. package/_stack/apps/tanstack-base/src/trpc/server.ts +11 -0
  166. package/_stack/apps/tanstack-base/tsconfig.json +27 -0
  167. package/_stack/apps/tanstack-base/tsr.config.json +3 -0
  168. package/_stack/apps/tanstack-base/vite.config.ts +15 -0
  169. package/_stack/packages/analytics/capability.json +26 -0
  170. package/_stack/packages/cache/capability.json +21 -0
  171. package/_stack/packages/error-tracking/capability.json +21 -0
  172. package/_stack/packages/jobs/capability.json +26 -0
  173. package/_stack/packages/logger/capability.json +21 -0
  174. package/_stack/packages/mailer/capability.json +28 -0
  175. package/_stack/packages/mailer/package.json +37 -0
  176. package/_stack/packages/mailer/src/adapters/brevo/config.ts +7 -0
  177. package/_stack/packages/mailer/src/adapters/brevo/index.ts +90 -0
  178. package/_stack/packages/mailer/src/adapters/resend/config.ts +7 -0
  179. package/_stack/packages/mailer/src/adapters/resend/index.ts +75 -0
  180. package/_stack/packages/mailer/src/adapters/ses/config.ts +13 -0
  181. package/_stack/packages/mailer/src/adapters/ses/index.ts +103 -0
  182. package/_stack/packages/storage/capability.json +32 -0
  183. package/_stack/patterns/README.md +58 -0
  184. package/_stack/patterns/_baseline/README-author.md +10 -0
  185. package/_stack/patterns/_baseline/biome.jsonc +119 -0
  186. package/_stack/patterns/_baseline/env.ts +31 -0
  187. package/_stack/patterns/_baseline/tsconfig.json +27 -0
  188. package/_stack/patterns/better-auth/pattern.json +73 -0
  189. package/_stack/patterns/better-auth-next/pattern.json +76 -0
  190. package/_stack/patterns/data-table/pattern.json +43 -0
  191. package/_stack/patterns/drizzle/pattern.json +61 -0
  192. package/_stack/patterns/trpc/pattern.json +61 -0
  193. package/_stack/patterns/trpc-next/pattern.json +64 -0
  194. package/index.mjs +216 -0
  195. package/lib/build.mjs +64 -0
  196. package/lib/env.mjs +56 -0
  197. package/lib/identity.mjs +33 -0
  198. package/lib/mailer.mjs +95 -0
  199. package/lib/manifests.mjs +61 -0
  200. package/lib/scaffold.mjs +49 -0
  201. package/lib/strip.mjs +132 -0
  202. package/lib/util.mjs +82 -0
  203. package/package.json +51 -0
  204. package/templates/next/layout.no-trpc.tsx +22 -0
  205. package/templates/tanstack/__root.no-trpc.tsx +63 -0
  206. package/templates/tanstack/router.no-trpc.tsx +24 -0
@@ -0,0 +1,8 @@
1
+ import { Loader2 } from 'lucide-react'
2
+ import type { ComponentProps } from 'react'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ /** Loading indicator — the lucide `loader` icon, spinning. */
6
+ export function Spinner({ className, ...props }: ComponentProps<typeof Loader2>) {
7
+ return <Loader2 aria-hidden="true" className={cn('size-4 animate-spin', className)} {...props} />
8
+ }
@@ -0,0 +1,87 @@
1
+ import type * as React from 'react'
2
+
3
+ import { cn } from '~/lib/utils'
4
+
5
+ function Table({ className, ...props }: React.ComponentProps<'table'>) {
6
+ return (
7
+ <div className="relative w-full overflow-x-auto" data-slot="table-container">
8
+ <table
9
+ className={cn('w-full caption-bottom text-sm', className)}
10
+ data-slot="table"
11
+ {...props}
12
+ />
13
+ </div>
14
+ )
15
+ }
16
+
17
+ function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
18
+ return <thead className={cn('[&_tr]:border-b', className)} data-slot="table-header" {...props} />
19
+ }
20
+
21
+ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
22
+ return (
23
+ <tbody
24
+ className={cn('[&_tr:last-child]:border-0', className)}
25
+ data-slot="table-body"
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
32
+ return (
33
+ <tfoot
34
+ className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)}
35
+ data-slot="table-footer"
36
+ {...props}
37
+ />
38
+ )
39
+ }
40
+
41
+ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
42
+ return (
43
+ <tr
44
+ className={cn(
45
+ 'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
46
+ className,
47
+ )}
48
+ data-slot="table-row"
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
55
+ return (
56
+ <th
57
+ className={cn(
58
+ 'h-12 whitespace-nowrap px-3 text-left align-middle font-medium text-foreground [&:has([role=checkbox])]:pr-0',
59
+ className,
60
+ )}
61
+ data-slot="table-head"
62
+ {...props}
63
+ />
64
+ )
65
+ }
66
+
67
+ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
68
+ return (
69
+ <td
70
+ className={cn('whitespace-nowrap p-3 align-middle [&:has([role=checkbox])]:pr-0', className)}
71
+ data-slot="table-cell"
72
+ {...props}
73
+ />
74
+ )
75
+ }
76
+
77
+ function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
78
+ return (
79
+ <caption
80
+ className={cn('mt-4 text-muted-foreground text-sm', className)}
81
+ data-slot="table-caption"
82
+ {...props}
83
+ />
84
+ )
85
+ }
86
+
87
+ export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
@@ -0,0 +1,199 @@
1
+ import { Body, Container, Head, Html, Link, Preview, Section, Tailwind, Text } from 'react-email'
2
+ import { EmailThemeProvider, useEmailTheme } from './context'
3
+ import { createEmailTheme, type EmailTheme, type EmailThemeOverride } from './theme'
4
+
5
+ export interface EmailLayoutProps {
6
+ preview: string
7
+ children: React.ReactNode
8
+ /** Per-email theme override, merged onto the default theme. */
9
+ theme?: EmailTheme | EmailThemeOverride
10
+ /** Footer year. Pass explicitly to keep rendered output deterministic. */
11
+ year?: number
12
+ }
13
+
14
+ function resolveTheme(theme: EmailLayoutProps['theme']): EmailTheme {
15
+ if (!theme) return createEmailTheme()
16
+ if ('colors' in theme && 'brand' in theme && 'fontFamily' in theme) {
17
+ return theme as EmailTheme
18
+ }
19
+ return createEmailTheme(theme as EmailThemeOverride)
20
+ }
21
+
22
+ export function EmailLayout({ preview, children, theme, year }: EmailLayoutProps) {
23
+ const resolved = resolveTheme(theme)
24
+ const { colors, brand, fontFamily } = resolved
25
+ const footerYear = year ?? new Date().getFullYear()
26
+
27
+ return (
28
+ <EmailThemeProvider theme={resolved}>
29
+ <Html lang="en">
30
+ <Head />
31
+ <Preview>{preview}</Preview>
32
+ <Tailwind>
33
+ <Body style={{ backgroundColor: colors.pageBg, fontFamily, margin: 0, padding: 0 }}>
34
+ <Container
35
+ style={{
36
+ backgroundColor: colors.cardBg,
37
+ border: `1px solid ${colors.border}`,
38
+ borderRadius: 16,
39
+ margin: '40px auto',
40
+ maxWidth: 520,
41
+ padding: 0,
42
+ }}
43
+ >
44
+ <Section
45
+ style={{ borderBottom: `1px solid ${colors.borderSubtle}`, padding: '20px 36px' }}
46
+ >
47
+ <Text
48
+ style={{
49
+ color: colors.fgStrong,
50
+ fontSize: 14,
51
+ fontWeight: 700,
52
+ letterSpacing: '0.3em',
53
+ margin: 0,
54
+ }}
55
+ >
56
+ {brand.name}
57
+ </Text>
58
+ </Section>
59
+
60
+ <Section style={{ padding: '32px 36px 28px' }}>{children}</Section>
61
+
62
+ <Section
63
+ style={{ borderTop: `1px solid ${colors.borderSubtle}`, padding: '20px 36px 24px' }}
64
+ >
65
+ <Text style={{ color: colors.fgFaint, fontSize: 11, margin: 0 }}>
66
+ © {footerYear} {brand.footer}
67
+ </Text>
68
+ </Section>
69
+ </Container>
70
+ </Body>
71
+ </Tailwind>
72
+ </Html>
73
+ </EmailThemeProvider>
74
+ )
75
+ }
76
+
77
+ export interface EmailHeadingProps {
78
+ children: React.ReactNode
79
+ kicker?: string
80
+ }
81
+
82
+ export function EmailHeading({ children, kicker }: EmailHeadingProps) {
83
+ const { colors } = useEmailTheme()
84
+ return (
85
+ <>
86
+ {kicker && (
87
+ <Text
88
+ style={{
89
+ color: colors.fgMuted,
90
+ fontSize: 11,
91
+ fontWeight: 500,
92
+ letterSpacing: '0.18em',
93
+ margin: '0 0 12px',
94
+ textTransform: 'uppercase',
95
+ }}
96
+ >
97
+ {kicker}
98
+ </Text>
99
+ )}
100
+ <Text
101
+ style={{
102
+ color: colors.fgStrong,
103
+ fontSize: 26,
104
+ fontWeight: 600,
105
+ letterSpacing: '-0.02em',
106
+ lineHeight: '1.15',
107
+ margin: 0,
108
+ }}
109
+ >
110
+ {children}
111
+ </Text>
112
+ </>
113
+ )
114
+ }
115
+
116
+ export interface EmailBodyTextProps {
117
+ children: React.ReactNode
118
+ muted?: boolean
119
+ size?: 'sm' | 'base'
120
+ style?: React.CSSProperties
121
+ }
122
+
123
+ export function EmailBodyText({ children, muted, size = 'base', style }: EmailBodyTextProps) {
124
+ const { colors } = useEmailTheme()
125
+ return (
126
+ <Text
127
+ style={{
128
+ color: muted ? colors.fgMuted : colors.fg,
129
+ fontSize: size === 'sm' ? 14 : 15,
130
+ lineHeight: size === 'sm' ? '22px' : '24px',
131
+ margin: '14px 0 0',
132
+ ...style,
133
+ }}
134
+ >
135
+ {children}
136
+ </Text>
137
+ )
138
+ }
139
+
140
+ export interface EmailButtonProps {
141
+ href: string
142
+ children: React.ReactNode
143
+ variant?: 'primary' | 'destructive'
144
+ }
145
+
146
+ export function EmailButton({ href, children, variant = 'primary' }: EmailButtonProps) {
147
+ const { colors } = useEmailTheme()
148
+ const backgroundColor = variant === 'destructive' ? colors.destructive : colors.fgStrong
149
+
150
+ return (
151
+ <Section style={{ margin: '28px 0 0' }}>
152
+ <Link
153
+ href={href}
154
+ style={{
155
+ backgroundColor,
156
+ borderRadius: 10,
157
+ color: colors.onAccent,
158
+ display: 'inline-block',
159
+ fontSize: 14,
160
+ fontWeight: 500,
161
+ padding: '12px 24px',
162
+ textDecoration: 'none',
163
+ }}
164
+ >
165
+ {children}
166
+ </Link>
167
+ </Section>
168
+ )
169
+ }
170
+
171
+ export interface EmailFallbackProps {
172
+ url: string
173
+ label?: string
174
+ }
175
+
176
+ export function EmailFallback({
177
+ url,
178
+ label = 'If the button does not work, copy this link:',
179
+ }: EmailFallbackProps) {
180
+ const { colors } = useEmailTheme()
181
+ return (
182
+ <Section style={{ margin: '32px 0 0' }}>
183
+ <Text style={{ color: colors.fgMuted, fontSize: 12, margin: '0 0 6px' }}>{label}</Text>
184
+ <Link
185
+ href={url}
186
+ style={{
187
+ color: colors.fg,
188
+ fontSize: 12,
189
+ overflowWrap: 'anywhere',
190
+ textDecoration: 'underline',
191
+ textUnderlineOffset: 2,
192
+ wordBreak: 'break-all',
193
+ }}
194
+ >
195
+ {url}
196
+ </Link>
197
+ </Section>
198
+ )
199
+ }
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext } from 'react'
2
+ import { defaultTheme, type EmailTheme } from './theme'
3
+
4
+ const EmailThemeContext = createContext<EmailTheme>(defaultTheme)
5
+
6
+ export interface EmailThemeProviderProps {
7
+ theme: EmailTheme
8
+ children: React.ReactNode
9
+ }
10
+
11
+ export function EmailThemeProvider({ theme, children }: EmailThemeProviderProps) {
12
+ return <EmailThemeContext.Provider value={theme}>{children}</EmailThemeContext.Provider>
13
+ }
14
+
15
+ /** Read the active email theme. Falls back to {@link defaultTheme}. */
16
+ export function useEmailTheme(): EmailTheme {
17
+ return useContext(EmailThemeContext)
18
+ }
@@ -0,0 +1,23 @@
1
+ export {
2
+ EmailBodyText,
3
+ type EmailBodyTextProps,
4
+ EmailButton,
5
+ type EmailButtonProps,
6
+ EmailFallback,
7
+ type EmailFallbackProps,
8
+ EmailHeading,
9
+ type EmailHeadingProps,
10
+ EmailLayout,
11
+ type EmailLayoutProps,
12
+ } from './components'
13
+ export {
14
+ EmailThemeProvider,
15
+ type EmailThemeProviderProps,
16
+ useEmailTheme,
17
+ } from './context'
18
+ export {
19
+ createEmailTheme,
20
+ defaultTheme,
21
+ type EmailTheme,
22
+ type EmailThemeOverride,
23
+ } from './theme'
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The swappable design tokens for emails. Override any subset via
3
+ * {@link createEmailTheme} and pass the result to `<EmailLayout theme={...}>`,
4
+ * or wrap a tree in `<EmailThemeProvider>`.
5
+ */
6
+ export interface EmailTheme {
7
+ brand: {
8
+ /** Shown in the header band and footer. */
9
+ name: string
10
+ /** Footer line (the year is prefixed automatically). */
11
+ footer: string
12
+ }
13
+ fontFamily: string
14
+ colors: {
15
+ pageBg: string
16
+ cardBg: string
17
+ fg: string
18
+ fgStrong: string
19
+ fgMuted: string
20
+ fgFaint: string
21
+ border: string
22
+ borderSubtle: string
23
+ destructive: string
24
+ /** Text color on top of accent/destructive buttons. */
25
+ onAccent: string
26
+ }
27
+ }
28
+
29
+ export const defaultTheme: EmailTheme = {
30
+ brand: {
31
+ name: 'ACME',
32
+ footer: 'ACME · All rights reserved.',
33
+ },
34
+ fontFamily: "Geist, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
35
+ colors: {
36
+ pageBg: '#f4f4f5',
37
+ cardBg: '#ffffff',
38
+ fg: '#18181b',
39
+ fgStrong: '#09090b',
40
+ fgMuted: '#71717a',
41
+ fgFaint: '#a1a1aa',
42
+ border: '#e4e4e7',
43
+ borderSubtle: '#f4f4f5',
44
+ destructive: '#dc2626',
45
+ onAccent: '#ffffff',
46
+ },
47
+ }
48
+
49
+ export type EmailThemeOverride = {
50
+ brand?: Partial<EmailTheme['brand']>
51
+ fontFamily?: string
52
+ colors?: Partial<EmailTheme['colors']>
53
+ }
54
+
55
+ /** Deep-merge an override onto a base theme (defaults to {@link defaultTheme}). */
56
+ export function createEmailTheme(
57
+ override: EmailThemeOverride = {},
58
+ base: EmailTheme = defaultTheme,
59
+ ): EmailTheme {
60
+ return {
61
+ brand: { ...base.brand, ...override.brand },
62
+ fontFamily: override.fontFamily ?? base.fontFamily,
63
+ colors: { ...base.colors, ...override.colors },
64
+ }
65
+ }
@@ -0,0 +1,16 @@
1
+ import { EmailBodyText, EmailButton, EmailHeading, EmailLayout } from '~/emails/components'
2
+
3
+ export function ResetPasswordEmail({ name, url }: { name?: string; url: string }) {
4
+ return (
5
+ <EmailLayout preview="Reset your password">
6
+ <EmailHeading>Reset your password</EmailHeading>
7
+ <EmailBodyText>
8
+ {name ? `Hi ${name}, ` : ''}we received a request to reset your password. This link expires
9
+ soon.
10
+ </EmailBodyText>
11
+ <EmailButton href={url}>Reset password</EmailButton>
12
+ </EmailLayout>
13
+ )
14
+ }
15
+
16
+ export default ResetPasswordEmail
@@ -0,0 +1,15 @@
1
+ import { EmailBodyText, EmailButton, EmailHeading, EmailLayout } from '~/emails/components'
2
+
3
+ export function VerifyEmail({ name, url }: { name?: string; url: string }) {
4
+ return (
5
+ <EmailLayout preview="Confirm your email address">
6
+ <EmailHeading>Confirm your email address</EmailHeading>
7
+ <EmailBodyText>
8
+ {name ? `Hi ${name}, ` : ''}confirm your email address to finish setting up your account.
9
+ </EmailBodyText>
10
+ <EmailButton href={url}>Confirm email</EmailButton>
11
+ </EmailLayout>
12
+ )
13
+ }
14
+
15
+ export default VerifyEmail
@@ -0,0 +1,41 @@
1
+ import { createEnv } from '@t3-oss/env-core'
2
+ import * as v from 'valibot'
3
+
4
+ /** Makes a var required only in production (optional in dev/test). */
5
+ export const requiredInProduction = <T extends v.GenericSchema>(schema: T) =>
6
+ process.env.NODE_ENV === 'production' ? schema : v.optional(schema)
7
+
8
+ /**
9
+ * Typed environment. Start minimal — patterns (drizzle, better-auth, …) and
10
+ * capabilities (add-capability) extend the `server` block and `runtimeEnv` with
11
+ * the keys they need.
12
+ */
13
+ export const env = createEnv({
14
+ shared: {
15
+ NODE_ENV: v.optional(v.picklist(['development', 'test', 'production']), 'development'),
16
+ },
17
+
18
+ server: {
19
+ DATABASE_URL: v.pipe(v.string(), v.url()),
20
+ BETTER_AUTH_URL: v.pipe(v.string(), v.url()),
21
+ BETTER_AUTH_SECRET: v.pipe(v.string(), v.minLength(1)),
22
+ BETTER_AUTH_GOOGLE_CLIENT_ID: v.optional(v.pipe(v.string(), v.minLength(1))),
23
+ BETTER_AUTH_GOOGLE_CLIENT_SECRET: v.optional(v.pipe(v.string(), v.minLength(1))),
24
+ EMAIL_FROM: v.optional(v.pipe(v.string(), v.email()), 'no-reply@example.com'),
25
+ RESEND_API_KEY: v.optional(v.pipe(v.string(), v.minLength(1))),
26
+ },
27
+
28
+ runtimeEnv: {
29
+ NODE_ENV: process.env.NODE_ENV,
30
+ DATABASE_URL: process.env.DATABASE_URL,
31
+ BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
32
+ BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
33
+ BETTER_AUTH_GOOGLE_CLIENT_ID: process.env.BETTER_AUTH_GOOGLE_CLIENT_ID,
34
+ BETTER_AUTH_GOOGLE_CLIENT_SECRET: process.env.BETTER_AUTH_GOOGLE_CLIENT_SECRET,
35
+ EMAIL_FROM: process.env.EMAIL_FROM,
36
+ RESEND_API_KEY: process.env.RESEND_API_KEY,
37
+ },
38
+
39
+ skipValidation: !!process.env.SKIP_ENV_VALIDATION,
40
+ emptyStringAsUndefined: true,
41
+ })
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card'
3
+
4
+ interface AuthCardProps {
5
+ title: string
6
+ description?: ReactNode
7
+ children: ReactNode
8
+ footer?: ReactNode
9
+ }
10
+
11
+ export function AuthCard({ title, description, children, footer }: AuthCardProps) {
12
+ return (
13
+ <Card className="gap-0 rounded-2xl border-border/60 py-0 shadow-[0_2px_8px_-2px_var(--color-border),0_12px_32px_-12px_var(--color-border)]">
14
+ <CardHeader className="px-8 pt-8 pb-0">
15
+ <CardTitle className="font-semibold text-2xl tracking-tight">{title}</CardTitle>
16
+ {description ? (
17
+ <CardDescription className="pt-1 leading-relaxed">{description}</CardDescription>
18
+ ) : null}
19
+ </CardHeader>
20
+
21
+ <CardContent className="px-8 pt-6 pb-8">{children}</CardContent>
22
+
23
+ {footer ? (
24
+ <div className="border-border/60 border-t px-8 py-5 text-center text-muted-foreground text-sm">
25
+ {footer}
26
+ </div>
27
+ ) : null}
28
+ </Card>
29
+ )
30
+ }
@@ -0,0 +1,27 @@
1
+ import { CircleAlert, CircleCheck } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import { cn } from '~/lib/utils'
4
+
5
+ interface FormAlertProps {
6
+ variant?: 'error' | 'success'
7
+ children: ReactNode
8
+ }
9
+
10
+ export function FormAlert({ variant = 'error', children }: FormAlertProps) {
11
+ const Icon = variant === 'success' ? CircleCheck : CircleAlert
12
+
13
+ return (
14
+ <div
15
+ className={cn(
16
+ 'flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-sm leading-relaxed',
17
+ variant === 'error'
18
+ ? 'border-destructive/20 bg-destructive/8 text-destructive'
19
+ : 'border-border bg-muted/60 text-foreground',
20
+ )}
21
+ role="alert"
22
+ >
23
+ <Icon className="mt-0.5 size-4 shrink-0" />
24
+ <span>{children}</span>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,64 @@
1
+ import { useState } from 'react'
2
+ import { Button } from '~/components/ui/button'
3
+ import { Spinner } from '~/components/ui/spinner'
4
+ import { authClient } from '~/server/better-auth/client'
5
+
6
+ function GoogleIcon() {
7
+ return (
8
+ <svg aria-hidden="true" className="size-4" viewBox="0 0 24 24">
9
+ <path
10
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
11
+ fill="#4285F4"
12
+ />
13
+ <path
14
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
15
+ fill="#34A853"
16
+ />
17
+ <path
18
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
19
+ fill="#FBBC05"
20
+ />
21
+ <path
22
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
23
+ fill="#EA4335"
24
+ />
25
+ </svg>
26
+ )
27
+ }
28
+
29
+ export function GoogleButton({
30
+ label,
31
+ callbackURL = '/',
32
+ }: {
33
+ label: string
34
+ callbackURL?: string
35
+ }) {
36
+ const [loading, setLoading] = useState(false)
37
+
38
+ return (
39
+ <Button
40
+ className="w-full cursor-pointer"
41
+ disabled={loading}
42
+ onClick={async () => {
43
+ setLoading(true)
44
+ await authClient.signIn.social({ provider: 'google', callbackURL })
45
+ setLoading(false)
46
+ }}
47
+ type="button"
48
+ variant="outline"
49
+ >
50
+ {loading ? <Spinner /> : <GoogleIcon />}
51
+ {label}
52
+ </Button>
53
+ )
54
+ }
55
+
56
+ export function AuthDivider() {
57
+ return (
58
+ <div className="flex items-center gap-3 text-muted-foreground text-xs">
59
+ <span className="h-px flex-1 bg-border" />
60
+ or
61
+ <span className="h-px flex-1 bg-border" />
62
+ </div>
63
+ )
64
+ }
@@ -0,0 +1,35 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const SignInSchema = v.object({
4
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
5
+ password: v.pipe(v.string(), v.nonEmpty('Password is required')),
6
+ })
7
+
8
+ export const SignUpSchema = v.object({
9
+ name: v.pipe(v.string(), v.nonEmpty('Name is required')),
10
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
11
+ password: v.pipe(v.string(), v.minLength(8, 'At least 8 characters')),
12
+ })
13
+
14
+ export const ForgotPasswordSchema = v.object({
15
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
16
+ })
17
+
18
+ export const ResetPasswordSchema = v.pipe(
19
+ v.object({
20
+ password: v.pipe(v.string(), v.minLength(8, 'At least 8 characters')),
21
+ confirmPassword: v.pipe(v.string(), v.nonEmpty('Confirmation is required')),
22
+ }),
23
+ v.forward(
24
+ v.check(
25
+ ({ password, confirmPassword }) => password === confirmPassword,
26
+ 'Passwords do not match',
27
+ ),
28
+ ['confirmPassword'],
29
+ ),
30
+ )
31
+
32
+ export type SignInInput = v.InferInput<typeof SignInSchema>
33
+ export type SignUpInput = v.InferInput<typeof SignUpSchema>
34
+ export type ForgotPasswordInput = v.InferInput<typeof ForgotPasswordSchema>
35
+ export type ResetPasswordInput = v.InferInput<typeof ResetPasswordSchema>
@@ -0,0 +1,4 @@
1
+ import { format } from 'date-fns'
2
+
3
+ /** Format a Date as an ISO date string (yyyy-MM-dd). */
4
+ export const toISODate = (date: Date): string => format(date, 'yyyy-MM-dd')
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }