@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.
- package/README.md +56 -0
- package/_stack/apps/next-base/.dockerignore +10 -0
- package/_stack/apps/next-base/Dockerfile +34 -0
- package/_stack/apps/next-base/README.md +32 -0
- package/_stack/apps/next-base/components.json +25 -0
- package/_stack/apps/next-base/drizzle.config.ts +16 -0
- package/_stack/apps/next-base/next.config.ts +8 -0
- package/_stack/apps/next-base/package.json +70 -0
- package/_stack/apps/next-base/postcss.config.mjs +7 -0
- package/_stack/apps/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/_stack/apps/next-base/src/app/api/trpc/[trpc]/route.ts +23 -0
- package/_stack/apps/next-base/src/app/auth/_components/forgot-password-form.tsx +92 -0
- package/_stack/apps/next-base/src/app/auth/_components/reset-password-form.tsx +105 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-in-form.tsx +126 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-up-form.tsx +139 -0
- package/_stack/apps/next-base/src/app/auth/_components/verify-email-actions.tsx +45 -0
- package/_stack/apps/next-base/src/app/auth/forgot-password/page.tsx +19 -0
- package/_stack/apps/next-base/src/app/auth/layout.tsx +9 -0
- package/_stack/apps/next-base/src/app/auth/reset-password/page.tsx +26 -0
- package/_stack/apps/next-base/src/app/auth/sign-in/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/sign-up/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/verify-email/page.tsx +30 -0
- package/_stack/apps/next-base/src/app/dashboard/page.tsx +12 -0
- package/_stack/apps/next-base/src/app/globals.css +171 -0
- package/_stack/apps/next-base/src/app/layout.tsx +23 -0
- package/_stack/apps/next-base/src/app/page.tsx +15 -0
- package/_stack/apps/next-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/next-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/next-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/next-base/src/components/theme-provider.tsx +8 -0
- package/_stack/apps/next-base/src/components/theme-toggle.tsx +37 -0
- package/_stack/apps/next-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/next-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/next-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/next-base/src/components/ui/date-picker.tsx +85 -0
- package/_stack/apps/next-base/src/components/ui/date-range-picker.tsx +138 -0
- package/_stack/apps/next-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/next-base/src/components/ui/form.tsx +149 -0
- package/_stack/apps/next-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/next-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/popover.tsx +76 -0
- package/_stack/apps/next-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/next-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/next-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/next-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/next-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/next-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/next-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/next-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/next-base/src/env.ts +41 -0
- package/_stack/apps/next-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/next-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/next-base/src/features/auth/google-button.tsx +66 -0
- package/_stack/apps/next-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/next-base/src/lib/date.ts +4 -0
- package/_stack/apps/next-base/src/lib/utils.ts +6 -0
- package/_stack/apps/next-base/src/server/api/root.ts +10 -0
- package/_stack/apps/next-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/next-base/src/server/api/trpc.ts +56 -0
- package/_stack/apps/next-base/src/server/auth/guards.ts +10 -0
- package/_stack/apps/next-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/next-base/src/server/better-auth/config.ts +60 -0
- package/_stack/apps/next-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/next-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/next-base/src/server/better-auth/server.ts +14 -0
- package/_stack/apps/next-base/src/server/db/index.ts +6 -0
- package/_stack/apps/next-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/next-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/next-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/next-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/next-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/next-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/next-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/next-base/src/server/email/index.ts +36 -0
- package/_stack/apps/next-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/next-base/src/trpc/react.tsx +62 -0
- package/_stack/apps/next-base/src/trpc/server.ts +23 -0
- package/_stack/apps/next-base/tsconfig.json +37 -0
- package/_stack/apps/tanstack-base/.dockerignore +13 -0
- package/_stack/apps/tanstack-base/Dockerfile +28 -0
- package/_stack/apps/tanstack-base/README.md +31 -0
- package/_stack/apps/tanstack-base/components.json +25 -0
- package/_stack/apps/tanstack-base/drizzle.config.ts +16 -0
- package/_stack/apps/tanstack-base/package.json +85 -0
- package/_stack/apps/tanstack-base/public/favicon.ico +0 -0
- package/_stack/apps/tanstack-base/public/logo192.png +0 -0
- package/_stack/apps/tanstack-base/public/logo512.png +0 -0
- package/_stack/apps/tanstack-base/public/manifest.json +25 -0
- package/_stack/apps/tanstack-base/public/robots.txt +3 -0
- package/_stack/apps/tanstack-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/tanstack-base/src/components/form/field-error.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +47 -0
- package/_stack/apps/tanstack-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/tanstack-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/tanstack-base/src/components/theme-provider.tsx +69 -0
- package/_stack/apps/tanstack-base/src/components/theme-toggle.tsx +35 -0
- package/_stack/apps/tanstack-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/tanstack-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-picker.tsx +83 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-range-picker.tsx +136 -0
- package/_stack/apps/tanstack-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/tanstack-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/tanstack-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/popover.tsx +74 -0
- package/_stack/apps/tanstack-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/tanstack-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/tanstack-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/tanstack-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/tanstack-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/tanstack-base/src/env.ts +41 -0
- package/_stack/apps/tanstack-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/tanstack-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/tanstack-base/src/features/auth/google-button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/tanstack-base/src/lib/date.ts +4 -0
- package/_stack/apps/tanstack-base/src/lib/utils.ts +6 -0
- package/_stack/apps/tanstack-base/src/router.tsx +40 -0
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +73 -0
- package/_stack/apps/tanstack-base/src/routes/_authed/dashboard.tsx +12 -0
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +21 -0
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +14 -0
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +31 -0
- package/_stack/apps/tanstack-base/src/routes/auth/forgot-password.tsx +89 -0
- package/_stack/apps/tanstack-base/src/routes/auth/reset-password.tsx +111 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-in.tsx +117 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-up.tsx +119 -0
- package/_stack/apps/tanstack-base/src/routes/auth/verify-email.tsx +72 -0
- package/_stack/apps/tanstack-base/src/routes/auth.tsx +22 -0
- package/_stack/apps/tanstack-base/src/routes/index.tsx +18 -0
- package/_stack/apps/tanstack-base/src/server/api/root.ts +10 -0
- package/_stack/apps/tanstack-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/tanstack-base/src/server/api/trpc.ts +61 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +68 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/db/index.ts +6 -0
- package/_stack/apps/tanstack-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/tanstack-base/src/server/email/index.ts +36 -0
- package/_stack/apps/tanstack-base/src/styles.css +171 -0
- package/_stack/apps/tanstack-base/src/trpc/devtools.tsx +6 -0
- package/_stack/apps/tanstack-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/tanstack-base/src/trpc/react.tsx +49 -0
- package/_stack/apps/tanstack-base/src/trpc/server.ts +11 -0
- package/_stack/apps/tanstack-base/tsconfig.json +27 -0
- package/_stack/apps/tanstack-base/tsr.config.json +3 -0
- package/_stack/apps/tanstack-base/vite.config.ts +15 -0
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/mailer/capability.json +28 -0
- package/_stack/packages/mailer/package.json +37 -0
- package/_stack/packages/mailer/src/adapters/brevo/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +90 -0
- package/_stack/packages/mailer/src/adapters/resend/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/resend/index.ts +75 -0
- package/_stack/packages/mailer/src/adapters/ses/config.ts +13 -0
- package/_stack/packages/mailer/src/adapters/ses/index.ts +103 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/patterns/README.md +58 -0
- package/_stack/patterns/_baseline/README-author.md +10 -0
- package/_stack/patterns/_baseline/biome.jsonc +119 -0
- package/_stack/patterns/_baseline/env.ts +31 -0
- package/_stack/patterns/_baseline/tsconfig.json +27 -0
- package/_stack/patterns/better-auth/pattern.json +73 -0
- package/_stack/patterns/better-auth-next/pattern.json +76 -0
- package/_stack/patterns/data-table/pattern.json +43 -0
- package/_stack/patterns/drizzle/pattern.json +61 -0
- package/_stack/patterns/trpc/pattern.json +61 -0
- package/_stack/patterns/trpc-next/pattern.json +64 -0
- package/index.mjs +216 -0
- package/lib/build.mjs +64 -0
- package/lib/env.mjs +56 -0
- package/lib/identity.mjs +33 -0
- package/lib/mailer.mjs +95 -0
- package/lib/manifests.mjs +61 -0
- package/lib/scaffold.mjs +49 -0
- package/lib/strip.mjs +132 -0
- package/lib/util.mjs +82 -0
- package/package.json +51 -0
- package/templates/next/layout.no-trpc.tsx +22 -0
- package/templates/tanstack/__root.no-trpc.tsx +63 -0
- package/templates/tanstack/router.no-trpc.tsx +24 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
|
|
3
|
+
/** A structured email address. */
|
|
4
|
+
export interface MailAddress {
|
|
5
|
+
email: string
|
|
6
|
+
name?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A recipient, either as a plain string (`"hi@acme.com"` or
|
|
11
|
+
* `"Acme <hi@acme.com>"`) or a structured {@link MailAddress}.
|
|
12
|
+
*/
|
|
13
|
+
export type MailRecipient = string | MailAddress
|
|
14
|
+
|
|
15
|
+
export interface MailAttachment {
|
|
16
|
+
filename: string
|
|
17
|
+
/** Raw bytes or a base64 string. */
|
|
18
|
+
content: Uint8Array | string
|
|
19
|
+
contentType?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A message as authored by application code. We never send raw HTML strings:
|
|
24
|
+
* the body is always a React Email component, rendered to HTML + plain text by
|
|
25
|
+
* the mailer before it reaches the adapter.
|
|
26
|
+
*/
|
|
27
|
+
export interface MailMessage {
|
|
28
|
+
to: MailRecipient | MailRecipient[]
|
|
29
|
+
subject: string
|
|
30
|
+
/** A React Email component. Rendered to HTML and plain text automatically. */
|
|
31
|
+
react: ReactElement
|
|
32
|
+
/** Overrides the default `from` configured on the mailer. */
|
|
33
|
+
from?: MailRecipient
|
|
34
|
+
replyTo?: MailRecipient
|
|
35
|
+
cc?: MailRecipient | MailRecipient[]
|
|
36
|
+
bcc?: MailRecipient | MailRecipient[]
|
|
37
|
+
headers?: Record<string, string>
|
|
38
|
+
/** Provider-agnostic key/value tags for analytics & filtering. */
|
|
39
|
+
tags?: Record<string, string>
|
|
40
|
+
attachments?: MailAttachment[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SentMail {
|
|
44
|
+
/** Provider message id. */
|
|
45
|
+
id: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The port the application depends on. Swapping providers means swapping the
|
|
50
|
+
* adapter passed to {@link createMailer}; this interface never changes.
|
|
51
|
+
*/
|
|
52
|
+
export interface Mailer {
|
|
53
|
+
send(message: MailMessage): Promise<SentMail>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The message shape an adapter receives: addresses are normalized and the body
|
|
58
|
+
* is already rendered to `html` + `text`. Adapters do no rendering.
|
|
59
|
+
*/
|
|
60
|
+
export interface RenderedMessage {
|
|
61
|
+
to: MailAddress[]
|
|
62
|
+
from: MailAddress
|
|
63
|
+
subject: string
|
|
64
|
+
html: string
|
|
65
|
+
text: string
|
|
66
|
+
replyTo?: MailAddress
|
|
67
|
+
cc?: MailAddress[]
|
|
68
|
+
bcc?: MailAddress[]
|
|
69
|
+
headers?: Record<string, string>
|
|
70
|
+
tags?: Record<string, string>
|
|
71
|
+
attachments?: MailAttachment[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The contract each provider implements. Kept intentionally tiny. */
|
|
75
|
+
export interface MailerAdapter {
|
|
76
|
+
readonly name: string
|
|
77
|
+
send(message: RenderedMessage): Promise<SentMail>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Normalized error thrown by adapters so callers never catch provider types. */
|
|
81
|
+
export class MailerError extends Error {
|
|
82
|
+
readonly adapter: string
|
|
83
|
+
|
|
84
|
+
constructor(message: string, options: { adapter: string; cause?: unknown }) {
|
|
85
|
+
super(message, { cause: options.cause })
|
|
86
|
+
this.name = 'MailerError'
|
|
87
|
+
this.adapter = options.adapter
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
import { render } from 'react-email'
|
|
3
|
+
|
|
4
|
+
export interface RenderedBody {
|
|
5
|
+
html: string
|
|
6
|
+
text: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A function that turns a React Email component into HTML + plain text. */
|
|
10
|
+
export type EmailRenderer = (react: ReactElement) => Promise<RenderedBody>
|
|
11
|
+
|
|
12
|
+
/** Default renderer backed by `@react-email/render`. */
|
|
13
|
+
export const renderEmail: EmailRenderer = async (react) => {
|
|
14
|
+
const [html, text] = await Promise.all([render(react), render(react, { plainText: true })])
|
|
15
|
+
return { html, text }
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { normalizeAddress, normalizeRecipients } from './core/address'
|
|
2
|
+
import type { Mailer, MailerAdapter, MailRecipient, RenderedMessage } from './core/port'
|
|
3
|
+
import { type EmailRenderer, renderEmail } from './core/render'
|
|
4
|
+
|
|
5
|
+
export interface CreateMailerOptions {
|
|
6
|
+
/** The provider implementation (Resend, Brevo, …). */
|
|
7
|
+
adapter: MailerAdapter
|
|
8
|
+
/** Default sender used when a message omits `from`. */
|
|
9
|
+
from: MailRecipient
|
|
10
|
+
/** Override the React Email renderer (mostly for tests). */
|
|
11
|
+
render?: EmailRenderer
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build a {@link Mailer}. This is the composition root: pick the adapter here,
|
|
16
|
+
* the rest of the app depends only on the `Mailer` port.
|
|
17
|
+
*
|
|
18
|
+
* The mailer renders the React body to HTML + plain text, normalizes every
|
|
19
|
+
* address, applies the default sender, then hands a {@link RenderedMessage} to
|
|
20
|
+
* the adapter.
|
|
21
|
+
*/
|
|
22
|
+
export function createMailer(options: CreateMailerOptions): Mailer {
|
|
23
|
+
const defaultFrom = normalizeAddress(options.from)
|
|
24
|
+
const render = options.render ?? renderEmail
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async send(message) {
|
|
28
|
+
const { html, text } = await render(message.react)
|
|
29
|
+
|
|
30
|
+
const rendered: RenderedMessage = {
|
|
31
|
+
to: normalizeRecipients(message.to),
|
|
32
|
+
from: message.from ? normalizeAddress(message.from) : defaultFrom,
|
|
33
|
+
subject: message.subject,
|
|
34
|
+
html,
|
|
35
|
+
text,
|
|
36
|
+
replyTo: message.replyTo ? normalizeAddress(message.replyTo) : undefined,
|
|
37
|
+
cc: message.cc ? normalizeRecipients(message.cc) : undefined,
|
|
38
|
+
bcc: message.bcc ? normalizeRecipients(message.bcc) : undefined,
|
|
39
|
+
headers: message.headers,
|
|
40
|
+
tags: message.tags,
|
|
41
|
+
attachments: message.attachments,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return options.adapter.send(rendered)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactElement } from 'react'
|
|
2
|
+
import { env } from '~/env'
|
|
3
|
+
import { resendAdapter } from './adapters/resend/index'
|
|
4
|
+
import type { MailAddress, Mailer } from './core/port'
|
|
5
|
+
import { createMailer } from './factory'
|
|
6
|
+
|
|
7
|
+
export type EmailRecipient = MailAddress
|
|
8
|
+
|
|
9
|
+
function required(value: string | undefined, name: string): string {
|
|
10
|
+
if (!value) throw new Error(`${name} is required to send email`)
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Lazy so the app boots without RESEND_API_KEY; the adapter is built on first send.
|
|
15
|
+
let mailer: Mailer | null = null
|
|
16
|
+
function getMailer(): Mailer {
|
|
17
|
+
if (!mailer) {
|
|
18
|
+
mailer = createMailer({
|
|
19
|
+
from: env.EMAIL_FROM,
|
|
20
|
+
adapter: resendAdapter({ apiKey: required(env.RESEND_API_KEY, 'RESEND_API_KEY') }),
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
return mailer
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function sendEmail(params: {
|
|
27
|
+
to: EmailRecipient
|
|
28
|
+
subject: string
|
|
29
|
+
template: ReactElement
|
|
30
|
+
}) {
|
|
31
|
+
return getMailer().send({
|
|
32
|
+
to: params.to,
|
|
33
|
+
subject: params.subject,
|
|
34
|
+
react: params.template,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import SuperJSON from 'superjson'
|
|
3
|
+
|
|
4
|
+
export const createQueryClient = () =>
|
|
5
|
+
new QueryClient({
|
|
6
|
+
defaultOptions: {
|
|
7
|
+
queries: {
|
|
8
|
+
staleTime: 30 * 1000,
|
|
9
|
+
},
|
|
10
|
+
dehydrate: {
|
|
11
|
+
serializeData: SuperJSON.serialize,
|
|
12
|
+
shouldDehydrateQuery: (query) =>
|
|
13
|
+
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
|
14
|
+
},
|
|
15
|
+
hydrate: {
|
|
16
|
+
deserializeData: SuperJSON.deserialize,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
|
+
import { httpBatchStreamLink, loggerLink } from '@trpc/client'
|
|
5
|
+
import { createTRPCReact } from '@trpc/react-query'
|
|
6
|
+
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import SuperJSON from 'superjson'
|
|
10
|
+
import type { AppRouter } from '~/server/api/root'
|
|
11
|
+
import { createQueryClient } from './query-client'
|
|
12
|
+
|
|
13
|
+
let clientQueryClientSingleton: QueryClient | undefined
|
|
14
|
+
const getQueryClient = () => {
|
|
15
|
+
if (typeof window === 'undefined') {
|
|
16
|
+
// Server: always make a new query client.
|
|
17
|
+
return createQueryClient()
|
|
18
|
+
}
|
|
19
|
+
// Browser: reuse the same query client across renders.
|
|
20
|
+
clientQueryClientSingleton ??= createQueryClient()
|
|
21
|
+
return clientQueryClientSingleton
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const api = createTRPCReact<AppRouter>()
|
|
25
|
+
|
|
26
|
+
export type RouterInputs = inferRouterInputs<AppRouter>
|
|
27
|
+
|
|
28
|
+
export type RouterOutputs = inferRouterOutputs<AppRouter>
|
|
29
|
+
|
|
30
|
+
function getBaseUrl() {
|
|
31
|
+
if (typeof window !== 'undefined') return window.location.origin
|
|
32
|
+
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
|
|
33
|
+
return `http://localhost:${process.env.PORT ?? 3000}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function TRPCReactProvider(props: { children: ReactNode }) {
|
|
37
|
+
const queryClient = getQueryClient()
|
|
38
|
+
|
|
39
|
+
const [trpcClient] = useState(() =>
|
|
40
|
+
api.createClient({
|
|
41
|
+
links: [
|
|
42
|
+
loggerLink({
|
|
43
|
+
enabled: (op) =>
|
|
44
|
+
process.env.NODE_ENV === 'development' ||
|
|
45
|
+
(op.direction === 'down' && op.result instanceof Error),
|
|
46
|
+
}),
|
|
47
|
+
httpBatchStreamLink({
|
|
48
|
+
transformer: SuperJSON,
|
|
49
|
+
url: `${getBaseUrl()}/api/trpc`,
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<QueryClientProvider client={queryClient}>
|
|
57
|
+
<api.Provider client={trpcClient} queryClient={queryClient}>
|
|
58
|
+
{props.children}
|
|
59
|
+
</api.Provider>
|
|
60
|
+
</QueryClientProvider>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { createHydrationHelpers } from '@trpc/react-query/rsc'
|
|
4
|
+
import { headers } from 'next/headers'
|
|
5
|
+
import { cache } from 'react'
|
|
6
|
+
import { type AppRouter, createCaller } from '~/server/api/root'
|
|
7
|
+
import { createTRPCContext } from '~/server/api/trpc'
|
|
8
|
+
import { createQueryClient } from './query-client'
|
|
9
|
+
|
|
10
|
+
/** Context for tRPC calls made from React Server Components. */
|
|
11
|
+
const createContext = cache(async () => {
|
|
12
|
+
const heads = new Headers(await headers())
|
|
13
|
+
heads.set('x-trpc-source', 'rsc')
|
|
14
|
+
return createTRPCContext({ headers: heads })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const getQueryClient = cache(createQueryClient)
|
|
18
|
+
const caller = createCaller(createContext)
|
|
19
|
+
|
|
20
|
+
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
|
|
21
|
+
caller,
|
|
22
|
+
getQueryClient,
|
|
23
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"noUnusedLocals": true,
|
|
17
|
+
"noUnusedParameters": true,
|
|
18
|
+
"noFallthroughCasesInSwitch": true,
|
|
19
|
+
"plugins": [
|
|
20
|
+
{
|
|
21
|
+
"name": "next"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"paths": {
|
|
25
|
+
"~/*": ["./src/*"]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"include": [
|
|
29
|
+
"next-env.d.ts",
|
|
30
|
+
"**/*.ts",
|
|
31
|
+
"**/*.tsx",
|
|
32
|
+
".next/types/**/*.ts",
|
|
33
|
+
".next/dev/types/**/*.ts",
|
|
34
|
+
"**/*.mts"
|
|
35
|
+
],
|
|
36
|
+
"exclude": ["node_modules"]
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
# Optimized multi-stage build for VPS deployment (TanStack Start + Nitro).
|
|
3
|
+
# `vite build` emits a self-contained Nitro server under .output/ (deps bundled),
|
|
4
|
+
# so the runner image carries only that — no node_modules, no dev deps.
|
|
5
|
+
|
|
6
|
+
FROM node:22-alpine AS base
|
|
7
|
+
RUN corepack enable
|
|
8
|
+
WORKDIR /app
|
|
9
|
+
|
|
10
|
+
# ---- build (needs dev deps for vite/nitro) ----
|
|
11
|
+
FROM base AS build
|
|
12
|
+
COPY package.json pnpm-lock.yaml* ./
|
|
13
|
+
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
|
14
|
+
pnpm install --frozen-lockfile
|
|
15
|
+
COPY . .
|
|
16
|
+
# Env is validated at runtime, not build — skip it so the build needs no secrets.
|
|
17
|
+
ENV SKIP_ENV_VALIDATION=1
|
|
18
|
+
RUN pnpm build
|
|
19
|
+
|
|
20
|
+
# ---- runner: just the Nitro output ----
|
|
21
|
+
FROM base AS runner
|
|
22
|
+
ENV NODE_ENV=production
|
|
23
|
+
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S tanstack
|
|
24
|
+
COPY --from=build --chown=tanstack:nodejs /app/.output ./.output
|
|
25
|
+
USER tanstack
|
|
26
|
+
EXPOSE 3000
|
|
27
|
+
ENV PORT=3000 HOST=0.0.0.0
|
|
28
|
+
CMD ["node", ".output/server/index.mjs"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# tanstack-base
|
|
2
|
+
|
|
3
|
+
Reference TanStack Start app — fork it to start a new project. Comes wired with
|
|
4
|
+
the full personal foundation:
|
|
5
|
+
|
|
6
|
+
- **baseline** — `~/*` alias, typed `env.ts`, strict Biome, Tailwind v4 + shadcn
|
|
7
|
+
(Geist, dark mode)
|
|
8
|
+
- **data** — Drizzle (Postgres) + drizzle-kit, faker seed harness
|
|
9
|
+
- **auth** — better-auth (email/password + verification, optional Google) + a full
|
|
10
|
+
auth UI (sign-in/up, forgot/reset, verify) with `@tanstack/react-form`
|
|
11
|
+
- **email** — mailer (Resend) + email-kit templates (`email:dev` studio)
|
|
12
|
+
- **API** — tRPC (`useTRPC`) + TanStack React Query with SSR hydration
|
|
13
|
+
- **UI utilities** — theme toggle (light/dark/system), DataTable, DatePicker
|
|
14
|
+
- **deploy** — multi-stage `Dockerfile` (Nitro output) for a VPS
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm --filter @alfredmouelle/tanstack-base dev # http://localhost:3000
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Add more swappable tools with **add-capability**.
|
|
21
|
+
|
|
22
|
+
# Author
|
|
23
|
+
|
|
24
|
+
Alfred MOUELLE | FullStack Developer
|
|
25
|
+
|
|
26
|
+
[](https://comeup.com/@alfredmouelle)
|
|
27
|
+
[](https://github.com/alfredmouelle)
|
|
28
|
+
[](https://www.linkedin.com/in/alfredmouelle)
|
|
29
|
+
[](https://twitter.com/kali47_)
|
|
30
|
+
[](mailto:alfredmouelle@gmail.com)
|
|
31
|
+
[](https://alfredmouelle.com)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "radix-luma",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "src/styles.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"aliases": {
|
|
14
|
+
"components": "~/components",
|
|
15
|
+
"utils": "~/lib/utils",
|
|
16
|
+
"ui": "~/components/ui",
|
|
17
|
+
"lib": "~/lib",
|
|
18
|
+
"hooks": "~/hooks"
|
|
19
|
+
},
|
|
20
|
+
"iconLibrary": "lucide",
|
|
21
|
+
"rtl": false,
|
|
22
|
+
"menuColor": "default-translucent",
|
|
23
|
+
"menuAccent": "subtle",
|
|
24
|
+
"registries": {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { config } from 'dotenv'
|
|
2
|
+
import { defineConfig } from 'drizzle-kit'
|
|
3
|
+
|
|
4
|
+
config({ path: ['.env.local', '.env'] })
|
|
5
|
+
|
|
6
|
+
const url = process.env.DATABASE_URL
|
|
7
|
+
if (!url) {
|
|
8
|
+
throw new Error('DATABASE_URL is not set')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
out: './drizzle',
|
|
13
|
+
schema: './src/server/db/schemas/*.schema.ts',
|
|
14
|
+
dialect: 'postgresql',
|
|
15
|
+
dbCredentials: { url },
|
|
16
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alfredmouelle/tanstack-base",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Alfred MOUELLE",
|
|
7
|
+
"email": "alfredmouelle@gmail.com",
|
|
8
|
+
"url": "https://alfredmouelle.com"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "vite dev --port 3000",
|
|
12
|
+
"generate-routes": "tsr generate",
|
|
13
|
+
"build": "vite build",
|
|
14
|
+
"preview": "vite preview",
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"email:dev": "email dev --dir src/emails --port 3001",
|
|
17
|
+
"typecheck": "tsr generate && tsc --noEmit",
|
|
18
|
+
"check": "biome check .",
|
|
19
|
+
"check:unsafe": "biome check --write --unsafe .",
|
|
20
|
+
"check:write": "biome check --write .",
|
|
21
|
+
"db:generate": "drizzle-kit generate",
|
|
22
|
+
"db:migrate": "drizzle-kit migrate",
|
|
23
|
+
"db:push": "drizzle-kit push",
|
|
24
|
+
"db:studio": "drizzle-kit studio",
|
|
25
|
+
"db:seed": "tsx src/server/db/seed.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@fontsource-variable/geist": "^5.2.9",
|
|
29
|
+
"@t3-oss/env-core": "^0.13.11",
|
|
30
|
+
"@tailwindcss/vite": "^4.3.1",
|
|
31
|
+
"@tanstack/react-devtools": "^0.10.7",
|
|
32
|
+
"@tanstack/react-router": "^1.170.16",
|
|
33
|
+
"@tanstack/react-router-devtools": "^1.167.0",
|
|
34
|
+
"@tanstack/react-router-ssr-query": "^1.167.1",
|
|
35
|
+
"@tanstack/react-form": "^1.33.0",
|
|
36
|
+
"@tanstack/react-query": "^5.101.0",
|
|
37
|
+
"@tanstack/react-query-devtools": "^5.101.0",
|
|
38
|
+
"@tanstack/react-table": "^8.21.3",
|
|
39
|
+
"@tanstack/react-start": "^1.168.26",
|
|
40
|
+
"@tanstack/router-plugin": "^1.132.0",
|
|
41
|
+
"@trpc/client": "^11.17.0",
|
|
42
|
+
"@trpc/server": "^11.17.0",
|
|
43
|
+
"@trpc/tanstack-react-query": "^11.17.0",
|
|
44
|
+
"better-auth": "^1.6.19",
|
|
45
|
+
"class-variance-authority": "^0.7.1",
|
|
46
|
+
"clsx": "^2.1.1",
|
|
47
|
+
"date-fns": "^4.4.0",
|
|
48
|
+
"drizzle-orm": "^0.45.2",
|
|
49
|
+
"lucide-react": "^0.545.0",
|
|
50
|
+
"nitro": "3.0.260522-beta",
|
|
51
|
+
"pg": "^8.21.0",
|
|
52
|
+
"radix-ui": "^1.6.0",
|
|
53
|
+
"react": "^19.2.0",
|
|
54
|
+
"react-dom": "^19.2.0",
|
|
55
|
+
"react-day-picker": "^10.0.1",
|
|
56
|
+
"react-email": "^6.6.3",
|
|
57
|
+
"resend": "^6.14.0",
|
|
58
|
+
"superjson": "^2.2.2",
|
|
59
|
+
"tailwind-merge": "^3.6.0",
|
|
60
|
+
"tailwindcss": "^4.3.1",
|
|
61
|
+
"valibot": "^1.4.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@biomejs/biome": "^2.5.0",
|
|
65
|
+
"@faker-js/faker": "^10.5.0",
|
|
66
|
+
"@tailwindcss/typography": "^0.5.16",
|
|
67
|
+
"@tanstack/devtools-vite": "^0.8.0",
|
|
68
|
+
"@tanstack/router-cli": "^1.132.0",
|
|
69
|
+
"@testing-library/dom": "^10.4.1",
|
|
70
|
+
"@testing-library/react": "^16.3.0",
|
|
71
|
+
"@types/node": "^22.10.2",
|
|
72
|
+
"@types/pg": "^8.15.6",
|
|
73
|
+
"@types/react": "^19.2.0",
|
|
74
|
+
"@types/react-dom": "^19.2.0",
|
|
75
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
76
|
+
"drizzle-kit": "^0.31.10",
|
|
77
|
+
"dotenv": "^17.2.4",
|
|
78
|
+
"jsdom": "^28.1.0",
|
|
79
|
+
"tsx": "^4.22.4",
|
|
80
|
+
"tw-animate-css": "^1.4.0",
|
|
81
|
+
"typescript": "^6.0.2",
|
|
82
|
+
"vite": "^8.0.0",
|
|
83
|
+
"vitest": "^4.1.5"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"short_name": "TanStack App",
|
|
3
|
+
"name": "Create TanStack App Sample",
|
|
4
|
+
"icons": [
|
|
5
|
+
{
|
|
6
|
+
"src": "favicon.ico",
|
|
7
|
+
"sizes": "64x64 32x32 24x24 16x16",
|
|
8
|
+
"type": "image/x-icon"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"src": "logo192.png",
|
|
12
|
+
"type": "image/png",
|
|
13
|
+
"sizes": "192x192"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"src": "logo512.png",
|
|
17
|
+
"type": "image/png",
|
|
18
|
+
"sizes": "512x512"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"start_url": ".",
|
|
22
|
+
"display": "standalone",
|
|
23
|
+
"theme_color": "#000000",
|
|
24
|
+
"background_color": "#ffffff"
|
|
25
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { flexRender, type Table as ReactTable } from '@tanstack/react-table'
|
|
2
|
+
import { Skeleton } from '~/components/ui/skeleton'
|
|
3
|
+
import {
|
|
4
|
+
Table,
|
|
5
|
+
TableBody,
|
|
6
|
+
TableCell,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from '~/components/ui/table'
|
|
11
|
+
|
|
12
|
+
interface DataTableProps<TData> {
|
|
13
|
+
table: ReactTable<TData>
|
|
14
|
+
columnCount: number
|
|
15
|
+
isLoading: boolean
|
|
16
|
+
emptyLabel: string
|
|
17
|
+
skeletonRows?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DataTable<TData>({
|
|
21
|
+
table,
|
|
22
|
+
columnCount,
|
|
23
|
+
isLoading,
|
|
24
|
+
emptyLabel,
|
|
25
|
+
skeletonRows = 3,
|
|
26
|
+
}: DataTableProps<TData>) {
|
|
27
|
+
const rows = table.getRowModel().rows
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="overflow-hidden rounded-lg border">
|
|
31
|
+
<Table>
|
|
32
|
+
<TableHeader>
|
|
33
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
34
|
+
<TableRow key={headerGroup.id}>
|
|
35
|
+
{headerGroup.headers.map((header) => (
|
|
36
|
+
<TableHead key={header.id}>
|
|
37
|
+
{header.isPlaceholder
|
|
38
|
+
? null
|
|
39
|
+
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
40
|
+
</TableHead>
|
|
41
|
+
))}
|
|
42
|
+
</TableRow>
|
|
43
|
+
))}
|
|
44
|
+
</TableHeader>
|
|
45
|
+
<TableBody>
|
|
46
|
+
{isLoading ? (
|
|
47
|
+
Array.from({ length: skeletonRows }, (_, rowIndex) => (
|
|
48
|
+
<TableRow key={rowIndex}>
|
|
49
|
+
{Array.from({ length: columnCount }, (_, cellIndex) => (
|
|
50
|
+
<TableCell key={cellIndex}>
|
|
51
|
+
<Skeleton className="h-6 w-full" />
|
|
52
|
+
</TableCell>
|
|
53
|
+
))}
|
|
54
|
+
</TableRow>
|
|
55
|
+
))
|
|
56
|
+
) : rows.length === 0 ? (
|
|
57
|
+
<TableRow>
|
|
58
|
+
<TableCell className="h-24 text-center text-muted-foreground" colSpan={columnCount}>
|
|
59
|
+
{emptyLabel}
|
|
60
|
+
</TableCell>
|
|
61
|
+
</TableRow>
|
|
62
|
+
) : (
|
|
63
|
+
rows.map((row) => (
|
|
64
|
+
<TableRow className="group" key={row.id}>
|
|
65
|
+
{row.getVisibleCells().map((cell) => (
|
|
66
|
+
<TableCell key={cell.id}>
|
|
67
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
68
|
+
</TableCell>
|
|
69
|
+
))}
|
|
70
|
+
</TableRow>
|
|
71
|
+
))
|
|
72
|
+
)}
|
|
73
|
+
</TableBody>
|
|
74
|
+
</Table>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyFieldApi } from '@tanstack/react-form'
|
|
2
|
+
|
|
3
|
+
const errorMessage = (error: unknown): string =>
|
|
4
|
+
typeof error === 'string' ? error : ((error as { message?: string } | undefined)?.message ?? '')
|
|
5
|
+
|
|
6
|
+
export function hasFieldError(field: AnyFieldApi): boolean {
|
|
7
|
+
return field.state.meta.isTouched && field.state.meta.errors.length > 0
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function FieldError({ field }: { field: AnyFieldApi }) {
|
|
11
|
+
const { isTouched, errors } = field.state.meta
|
|
12
|
+
if (!isTouched || errors.length === 0) return null
|
|
13
|
+
|
|
14
|
+
const message = errors.map(errorMessage).filter(Boolean).join(', ')
|
|
15
|
+
if (!message) return null
|
|
16
|
+
|
|
17
|
+
return <p className="text-destructive text-xs">{message}</p>
|
|
18
|
+
}
|