├── public └── .gitkeep ├── .node-version ├── .npmrc ├── .internal ├── .gitignore ├── site │ ├── src │ │ ├── public │ │ │ ├── favicon.ico │ │ │ └── images │ │ │ │ ├── icon.png │ │ │ │ ├── libs │ │ │ │ ├── knip.png │ │ │ │ ├── otel.png │ │ │ │ ├── biome.png │ │ │ │ ├── copilot.png │ │ │ │ ├── docker.png │ │ │ │ ├── nextjs.png │ │ │ │ ├── prisma.png │ │ │ │ ├── stripe.png │ │ │ │ ├── vitest.png │ │ │ │ ├── vscode.png │ │ │ │ ├── lefthook.png │ │ │ │ ├── next-auth.png │ │ │ │ ├── prettier.png │ │ │ │ ├── renovate.png │ │ │ │ ├── tailwind.png │ │ │ │ ├── editorconfig.png │ │ │ │ ├── playwright.png │ │ │ │ ├── postgresql.png │ │ │ │ ├── typescript.png │ │ │ │ ├── github-actions.png │ │ │ │ ├── testcontainers.png │ │ │ │ ├── react-hook-form.png │ │ │ │ ├── testing-library.png │ │ │ │ └── pnpm.svg │ │ │ │ ├── otel │ │ │ │ ├── query.png │ │ │ │ └── root-metric.png │ │ │ │ └── mermaid │ │ │ │ ├── stripe-cancel-flow.png │ │ │ │ ├── stripe-webhook-flow.png │ │ │ │ └── stripe-checkout-flow.png │ │ ├── introduction │ │ │ ├── dotenv.md │ │ │ ├── tasks.md │ │ │ ├── getting-started.md │ │ │ ├── challenges-solved.md │ │ │ └── routing.md │ │ ├── index.md │ │ └── features │ │ │ ├── observability.md │ │ │ ├── unit-testing.md │ │ │ ├── code-quality-automation.md │ │ │ ├── prisma.md │ │ │ ├── nextjs.md │ │ │ ├── next-auth.md │ │ │ └── e2e-testing.md │ ├── package.json │ └── .vitepress │ │ └── config.mts ├── create-app-foundation │ ├── pnpm-lock.yaml │ ├── README.md │ ├── package.json │ └── src │ │ └── git.mjs ├── setup │ ├── package-json.mjs │ ├── format.mjs │ ├── code │ │ ├── app-(public)-page.tsx │ │ └── app-layout.tsx │ ├── questions │ │ ├── index.mjs │ │ ├── docker.mjs │ │ ├── e2e.mjs │ │ ├── otel.mjs │ │ ├── stripe.mjs │ │ └── sample-code.mjs │ ├── init.mjs │ ├── db.mjs │ └── common-processing.mjs ├── tests │ ├── all-opt-out.test.mjs │ ├── no-docker.test.mjs │ ├── no-otel.test.mjs │ ├── no-stripe.test.mjs │ ├── no-sample-code.test.mjs │ ├── no-e2e.test.mjs │ ├── makefile │ ├── common.test.mjs │ └── no-docker.test.mjs.snapshot └── README.md ├── .dockerignore ├── src ├── app │ ├── robots.txt │ ├── favicon.ico │ ├── globals.css │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── health │ │ │ ├── route.ts │ │ │ └── route.test.ts │ │ └── payment │ │ │ └── webhook │ │ │ └── route.ts │ ├── not-found.tsx │ ├── (public) │ │ ├── layout.tsx │ │ ├── signin │ │ │ └── page.tsx │ │ ├── items │ │ │ └── [itemId] │ │ │ │ └── page.tsx │ │ ├── _components │ │ │ └── ItemManager.tsx │ │ └── page.tsx │ ├── (private) │ │ ├── layout.tsx │ │ └── me │ │ │ ├── page.tsx │ │ │ ├── payment │ │ │ ├── _components │ │ │ │ └── PaymentButton.tsx │ │ │ └── page.tsx │ │ │ └── _components │ │ │ └── UpdateMyInfo.tsx │ ├── error.tsx │ ├── _hooks │ │ ├── useFormId.ts │ │ ├── useOnlineStatus.ts │ │ ├── useFormId.test.ts │ │ └── useOnlineStatus.test.ts │ ├── _utils │ │ ├── date.ts │ │ ├── zod.ts │ │ ├── date.test.ts │ │ ├── db.ts │ │ ├── auth.ts │ │ ├── db.test.ts │ │ ├── zod.test.ts │ │ ├── auth.test.ts │ │ └── payment.ts │ ├── _schemas │ │ ├── items.ts │ │ ├── users.ts │ │ ├── items.test.ts │ │ └── users.test.ts │ ├── _clients │ │ ├── stripe.ts │ │ ├── prisma.ts │ │ ├── nextAuth.ts │ │ └── nextAuthConfig.ts │ ├── _components │ │ ├── AuthButtons.tsx │ │ ├── Button.tsx │ │ ├── Footer.tsx │ │ ├── FormBox.tsx │ │ ├── Container.tsx │ │ ├── ErrorPageTemplate.tsx │ │ ├── Input.tsx │ │ └── Header.tsx │ ├── opengraph-image.tsx │ ├── global-error.tsx │ ├── _types │ │ └── result.ts │ ├── globals.d.ts │ ├── layout.tsx │ └── _actions │ │ ├── users.ts │ │ ├── items.ts │ │ ├── users.test.ts │ │ └── items.test.ts ├── instrumentation.ts ├── middleware.ts ├── otel │ └── node.ts └── middleware.test.ts ├── tests ├── vitest.setup.ts ├── db.setup.ts └── vitest.helper.ts ├── postcss.config.mjs ├── .github ├── actions │ ├── setup-db │ │ └── action.yml │ └── setup-node │ │ └── action.yml └── workflows │ ├── internal.yml │ ├── site.yml │ ├── claude.yml │ ├── claude-code-review.yml │ ├── ci.yml │ └── update-internal-e2e.yml ├── prisma.config.ts ├── .editorconfig ├── .env.test ├── pnpm-workspace.yaml ├── .vscode ├── extensions.json ├── mcp.json └── settings.json ├── prisma └── schema │ ├── schema.prisma │ ├── item.prisma │ └── user.prisma ├── .gitignore ├── e2e ├── helpers │ ├── prisma.ts │ ├── getRandomPort.ts │ ├── app.ts │ ├── waitForHealth.ts │ └── users.ts ├── a11y │ ├── signInPage.test.ts │ ├── notFoundPage.test.ts │ ├── mePage.test.ts │ ├── itemPage.test.ts │ └── topPage.test.ts ├── setup │ └── auth.ts ├── models │ ├── SignInPage.ts │ ├── NotFoundPage.ts │ ├── ItemPage.ts │ ├── MePage.ts │ ├── Base.ts │ └── TopPage.ts ├── dummyUsers.ts ├── integrations │ ├── auth.test.ts │ ├── user.test.ts │ └── item.test.ts └── fixtures.ts ├── knip.config.ts ├── lefthook.yml ├── playwright.config.ts ├── biome.json ├── vitest.config.ts ├── otel-collector-config.yml ├── tsconfig.json ├── LICENSE ├── .env.sample ├── compose.yml ├── next.config.ts ├── env.ts ├── Dockerfile ├── renovate.json └── package.json /public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 24.11.1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.internal/.gitignore: -------------------------------------------------------------------------------- 1 | cache 2 | dist 3 | internal-tests-output-* 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .githooks 2 | node_modules 3 | .next 4 | .git 5 | .gitignore 6 | dist 7 | -------------------------------------------------------------------------------- /src/app/robots.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Allow: / 3 | Disallow: /signin/ 4 | Disallow: /me/ 5 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /tests/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | export function setup() { 2 | process.env.TZ = "Europe/London"; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --color-main: oklch(65.31% 0.1347 242.69); 5 | } 6 | -------------------------------------------------------------------------------- /.internal/site/src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/favicon.ico -------------------------------------------------------------------------------- /.internal/site/src/public/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/icon.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "../../../_clients/nextAuth"; 2 | 3 | export const { GET, POST } = handlers; 4 | -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/knip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/knip.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/otel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/otel.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/biome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/biome.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/copilot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/copilot.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/docker.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/nextjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/nextjs.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/prisma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/prisma.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/stripe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/stripe.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/vitest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/vitest.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/vscode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/vscode.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/otel/query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/otel/query.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/lefthook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/lefthook.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/next-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/next-auth.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/prettier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/prettier.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/renovate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/renovate.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/tailwind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/tailwind.png -------------------------------------------------------------------------------- /.github/actions/setup-db/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup DB 2 | runs: 3 | using: composite 4 | steps: 5 | - run: pnpm db:up && pnpm db:migrate 6 | shell: bash 7 | -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/editorconfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/editorconfig.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/playwright.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/playwright.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/postgresql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/postgresql.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/typescript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/typescript.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/otel/root-metric.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/otel/root-metric.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/github-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/github-actions.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/testcontainers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/testcontainers.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/react-hook-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/react-hook-form.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/testing-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/libs/testing-library.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/mermaid/stripe-cancel-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/mermaid/stripe-cancel-flow.png -------------------------------------------------------------------------------- /.internal/site/src/public/images/mermaid/stripe-webhook-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/mermaid/stripe-webhook-flow.png -------------------------------------------------------------------------------- /src/app/api/health/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | export async function GET() { 4 | return NextResponse.json({ 5 | status: "ok", 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /.internal/site/src/public/images/mermaid/stripe-checkout-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiroppy/web-app-template/HEAD/.internal/site/src/public/images/mermaid/stripe-checkout-flow.png -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "prisma/config"; 2 | import { config } from "./env"; 3 | 4 | config(); 5 | 6 | export default defineConfig({ 7 | schema: "./prisma/schema", 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPageTemplate } from "./_components/ErrorPageTemplate"; 2 | 3 | export default function NotFound() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /.internal/create-app-foundation/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: {} 10 | -------------------------------------------------------------------------------- /src/app/(public)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "../_components/Container"; 2 | 3 | export default function Layout({ children }: LayoutProps<"/">) { 4 | return {children}; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/(private)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Container } from "../_components/Container"; 2 | 3 | export default function Layout({ children }: LayoutProps<"/">) { 4 | return {children}; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ErrorPageTemplate } from "./_components/ErrorPageTemplate"; 4 | 5 | export default function ErrorPage() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/_hooks/useFormId.ts: -------------------------------------------------------------------------------- 1 | import { useId } from "react"; 2 | 3 | export function useFormId() { 4 | const id = useId(); 5 | const errorId = `${id}-error`; 6 | 7 | return { 8 | id, 9 | errorId, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/instrumentation.ts: -------------------------------------------------------------------------------- 1 | export async function register() { 2 | if (process.env.NODE_ENV !== "production") { 3 | return; 4 | } 5 | 6 | if (process.env.NEXT_RUNTIME === "nodejs") { 7 | await import("./otel/node"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | end_of_line = lf 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /src/app/_utils/date.ts: -------------------------------------------------------------------------------- 1 | export function format(date: Date) { 2 | return date.toLocaleDateString("ja-JP", { 3 | month: "long", 4 | day: "numeric", 5 | hour: "numeric", 6 | minute: "numeric", 7 | timeZone: "Asia/Tokyo", 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # Google OAuth 2 | GOOGLE_CLIENT_ID=dummy 3 | GOOGLE_CLIENT_SECRET=dummy 4 | 5 | # NextAuth.js 6 | NEXTAUTH_TEST_MODE=true 7 | 8 | # start: stripe # 9 | # Stripe 10 | STRIPE_SECRET_KEY=dummy 11 | STRIPE_WEBHOOK_SECRET=dummy 12 | STRIPE_PRICE_ID=dummy 13 | # end: stripe # 14 | -------------------------------------------------------------------------------- /src/app/_schemas/items.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const itemSchema = z.object({ 4 | content: z 5 | .string() 6 | .min(1, "content is too short") 7 | .max(20, "content is too long"), 8 | }); 9 | 10 | export type ItemSchema = z.infer; 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@prisma/client' 3 | - '@prisma/engines' 4 | - '@tailwindcss/oxide' 5 | - cpu-features 6 | - esbuild 7 | - lefthook 8 | - oxc-resolver 9 | - prisma 10 | - protobufjs 11 | - puppeteer 12 | - sharp 13 | - ssh2 14 | - vue-demi 15 | -------------------------------------------------------------------------------- /src/app/_utils/zod.ts: -------------------------------------------------------------------------------- 1 | import type { ZodSafeParseResult } from "zod"; 2 | import { flattenError } from "zod"; 3 | 4 | export function getFieldErrors(parsedValues: ZodSafeParseResult) { 5 | if (!parsedValues.success) { 6 | return flattenError(parsedValues.error).fieldErrors; 7 | } 8 | 9 | return {}; 10 | } 11 | -------------------------------------------------------------------------------- /.internal/create-app-foundation/README.md: -------------------------------------------------------------------------------- 1 | ## Create App Foundation 2 | 3 | This is a way to use [web-app-template](https://github.com/hiroppy/web-app-template) via CLI instead of using GitHub template. This CLI sets everything from DB to testing based on Next.js. 4 | 5 | ### Usage 6 | 7 | ```sh 8 | $ npx create-app-foundation 9 | ``` 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "vitest.explorer", 5 | "ms-playwright.playwright", 6 | "Prisma.prisma", 7 | "esbenp.prettier-vscode", 8 | "biomejs.biome", 9 | "EditorConfig.EditorConfig", 10 | "streetsidesoftware.code-spell-checker" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/app/_clients/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | export const stripe = new Stripe(`${process.env.STRIPE_SECRET_KEY}`); 4 | 5 | export const paymentPage = `${process.env.NEXT_PUBLIC_SITE_URL}/me/payment`; 6 | export const successUrl = `${paymentPage}?sessionId={CHECKOUT_SESSION_ID}`; 7 | export const cancelUrl = `${process.env.NEXT_PUBLIC_SITE_URL}`; 8 | -------------------------------------------------------------------------------- /src/app/_components/AuthButtons.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signIn, signOut } from "next-auth/react"; 4 | import { Button } from "./Button"; 5 | 6 | export function SignInButton() { 7 | return ; 8 | } 9 | 10 | export function SignOutButton() { 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /prisma/schema/schema.prisma: -------------------------------------------------------------------------------- 1 | // https://pris.ly/d/prisma-schema 2 | 3 | datasource db { 4 | provider = "postgresql" 5 | url = env("DATABASE_URL") 6 | } 7 | 8 | generator client { 9 | provider = "prisma-client-js" 10 | previewFeatures = ["fullTextSearchPostgres"] 11 | output = "../../src/app/__generated__/prisma" 12 | moduleFormat = "esm" 13 | } 14 | -------------------------------------------------------------------------------- /prisma/schema/item.prisma: -------------------------------------------------------------------------------- 1 | model Item { 2 | id String @id @default(cuid()) 3 | content String 4 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 5 | userId String @map("user_id") 6 | createdAt DateTime @default(now()) @map("created_at") 7 | updatedAt DateTime @updatedAt @map("updated_at") 8 | 9 | @@map("items") 10 | } 11 | -------------------------------------------------------------------------------- /src/app/_utils/date.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { format } from "./date"; 3 | 4 | describe("utils/date", () => { 5 | describe("format", () => { 6 | test("should format date", () => { 7 | expect(format(new Date("2024-02-29T10:30:10"))).toMatchInlineSnapshot( 8 | `"2月29日 19:30"`, 9 | ); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .githooks 2 | 3 | node_modules 4 | coverage 5 | 6 | /.next/ 7 | 8 | # start: e2e # 9 | .auth 10 | playwright-report 11 | test-results 12 | # end: e2e # 13 | 14 | .DS_Store 15 | *.pem 16 | 17 | .env 18 | 19 | .vercel 20 | .claude 21 | 22 | *.tsbuildinfo 23 | next-env.d.ts 24 | 25 | __generated__ 26 | 27 | ####### 👉 remove ####### 28 | migrations 29 | ######################## 30 | -------------------------------------------------------------------------------- /src/app/(public)/signin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signIn } from "next-auth/react"; 4 | import { Button } from "../../_components/Button"; 5 | 6 | export default function SignIn() { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /.internal/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "vitepress dev", 6 | "build": "vitepress build", 7 | "preview": "vitepress preview" 8 | }, 9 | "author": "hiroppy (https://hiroppy.me/)", 10 | "license": "MIT", 11 | "description": "", 12 | "devDependencies": { 13 | "vitepress": "1.6.4" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /e2e/helpers/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "../../src/app/__generated__/prisma"; 2 | 3 | export async function generatePrismaClient(url: string) { 4 | const prisma = new PrismaClient({ 5 | datasources: { 6 | db: { 7 | url, 8 | }, 9 | }, 10 | }); 11 | 12 | return { 13 | prisma, 14 | async [Symbol.asyncDispose]() { 15 | await prisma.$disconnect(); 16 | }, 17 | } as const; 18 | } 19 | -------------------------------------------------------------------------------- /e2e/a11y/signInPage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | import { test } from "../fixtures"; 3 | 4 | test.describe("SignIn Page", () => { 5 | test("should not have any automatically detectable accessibility issues", async ({ 6 | signInPage, 7 | a11y, 8 | }) => { 9 | await signInPage.goTo(); 10 | 11 | const res = await a11y().analyze(); 12 | 13 | expect(res.violations).toEqual([]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /e2e/a11y/notFoundPage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | import { test } from "../fixtures"; 3 | 4 | test.describe("NotFound Page", () => { 5 | test("should not have any automatically detectable accessibility issues", async ({ 6 | notFoundPage, 7 | a11y, 8 | }) => { 9 | await notFoundPage.goTo(); 10 | 11 | const res = await a11y().analyze(); 12 | 13 | expect(res.violations).toEqual([]); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/_schemas/users.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const meSchema = z.object({ 4 | name: z.string().min(1, "name is too short").max(20, "name is too long"), 5 | email: z 6 | .email({ error: "email is invalid" }) 7 | .min(1, "email is too short") 8 | .max(50, "email is too long"), 9 | image: z.url({ error: "image is invalid" }).max(2000, "image is too long"), 10 | }); 11 | 12 | export type MeSchema = z.infer; 13 | -------------------------------------------------------------------------------- /e2e/setup/auth.ts: -------------------------------------------------------------------------------- 1 | import { test as setup } from "@playwright/test"; 2 | import { admin1, user1 } from "../dummyUsers"; 3 | import { createUserAuthState } from "../helpers/users"; 4 | 5 | setup("Create user1 auth", async ({ context }) => { 6 | await createUserAuthState(context, { 7 | user: user1, 8 | }); 9 | }); 10 | 11 | setup("Create admin1 auth", async ({ context }) => { 12 | await createUserAuthState(context, { 13 | user: admin1, 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/(private)/me/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { getSessionOrReject } from "../../_utils/auth"; 3 | import { UpdateMyInfo } from "./_components/UpdateMyInfo"; 4 | 5 | export default async function Page() { 6 | const session = await getSessionOrReject(); 7 | 8 | if (!session.success) { 9 | notFound(); 10 | } 11 | 12 | const { user } = session.data; 13 | 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /src/app/api/health/route.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { GET } from "./route"; 3 | 4 | describe("api/health", () => { 5 | describe("GET /", () => { 6 | it("should return 200", async () => { 7 | const res = await GET(); 8 | 9 | expect(await res.json()).toMatchInlineSnapshot(` 10 | { 11 | "status": "ok", 12 | } 13 | `); 14 | expect(res.status).toBe(200); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /.internal/setup/package-json.mjs: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import { basename } from "node:path"; 3 | import { getPackageJson } from "./utils.mjs"; 4 | 5 | export async function updatePackageJson() { 6 | const { path, data } = await getPackageJson(); 7 | const currentDirectoryName = basename(process.cwd()); 8 | 9 | data.name = currentDirectoryName; 10 | data.version = "0.0.1"; 11 | 12 | await writeFile(path, JSON.stringify(data, null, 2)); 13 | } 14 | -------------------------------------------------------------------------------- /.internal/tests/all-opt-out.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { BaseTest } from "./Basetest.mjs"; 3 | 4 | const outputDir = "all-opt-out"; 5 | const baseTest = new BaseTest({ outputDir }); 6 | 7 | describe("all-opt-out", async () => { 8 | baseTest.globalHook({ 9 | noSampleCode: true, 10 | noE2e: true, 11 | noDocker: true, 12 | noOtel: true, 13 | noStripe: true, 14 | }); 15 | 16 | await baseTest.allTests({ hasE2e: false }); 17 | }); 18 | -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node 2 | runs: 3 | using: composite 4 | steps: 5 | - uses: pnpm/action-setup@v4 6 | - uses: actions/setup-node@v6 7 | with: 8 | node-version-file: .node-version 9 | cache: pnpm 10 | - run: cp ./.env.sample ./.env 11 | shell: bash 12 | - run: npm run setup 13 | shell: bash 14 | - run: pnpm i && pnpm rebuild 15 | shell: bash 16 | - run: pnpm generate:client 17 | shell: bash 18 | -------------------------------------------------------------------------------- /e2e/helpers/getRandomPort.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "node:http"; 2 | 3 | export async function getRandomPort() { 4 | return new Promise((resolve) => { 5 | const server = createServer(); 6 | 7 | server.listen(0, () => { 8 | const address = server.address(); 9 | const port = address && typeof address === "object" ? address.port : null; 10 | 11 | if (port) { 12 | server.close(); 13 | resolve(port); 14 | } 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /.internal/README.md: -------------------------------------------------------------------------------- 1 | # Internal Stuff 2 | 3 | ## setup 4 | 5 | The script will clean up all the files that are unnecessary for the initial setup and users when used. 6 | 7 | ## create-app-foundation 8 | 9 | The CLI script for userland to build this repo to own local. 10 | 11 | ## tests 12 | 13 | Test to check if this repository is actually deployed as expected when using the CLI. 14 | 15 | ```sh 16 | $ cd .tests 17 | 18 | $ make run 19 | # # update snapshot or when adding a new test 20 | $ make update 21 | ``` 22 | -------------------------------------------------------------------------------- /.internal/setup/format.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import { title } from "./utils.mjs"; 3 | 4 | export async function format() { 5 | title("Formatting"); 6 | 7 | await new Promise((resolve, reject) => { 8 | const child = spawn("pnpm", ["fmt"], { stdio: "overlapped" }); 9 | 10 | child.on("exit", (code) => { 11 | if (code === 0) { 12 | resolve(); 13 | } else { 14 | reject(new Error(`command failed with code ${code}`)); 15 | } 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /knip.config.ts: -------------------------------------------------------------------------------- 1 | import type { KnipConfig } from "knip"; 2 | 3 | const config: KnipConfig = { 4 | ignore: [ 5 | // 👉 remove 6 | ".internal/**", 7 | /////////// 8 | "tests/build.mjs", 9 | "prisma.config.ts", 10 | ], 11 | playwright: { 12 | config: ["playwright.config.ts"], 13 | entry: ["e2e/**/*.ts"], 14 | }, 15 | vitest: { 16 | entry: ["src/**/*.test.ts"], 17 | }, 18 | ignoreDependencies: ["postcss", "tailwindcss"], 19 | ignoreBinaries: ["stripe"], 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | biome: 5 | glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" 6 | run: | 7 | npx biome check --write --no-errors-on-unmatched {staged_files} 8 | stage_fixed: true 9 | prettier: 10 | glob: "*.{md,yml}" 11 | run: | 12 | npx prettier -w {staged_files} 13 | stage_fixed: true 14 | prisma: 15 | glob: "*.prisma" 16 | run: | 17 | npx prisma format {staged_files} 18 | stage_fixed: true 19 | -------------------------------------------------------------------------------- /src/app/_components/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { JSX } from "react"; 3 | 4 | type Props = JSX.IntrinsicElements["button"]; 5 | 6 | export function Button({ children, className, ...rest }: Props) { 7 | return ( 8 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/_utils/db.ts: -------------------------------------------------------------------------------- 1 | export function createDBUrl({ 2 | user = process.env.DATABASE_USER, 3 | password = process.env.DATABASE_PASSWORD, 4 | host = process.env.DATABASE_HOST, 5 | port = Number(process.env.DATABASE_PORT), 6 | db = process.env.DATABASE_DB, 7 | schema = process.env.DATABASE_SCHEMA, 8 | }: { 9 | user?: string; 10 | password?: string; 11 | host?: string; 12 | port?: number; 13 | db?: string; 14 | schema?: string; 15 | }) { 16 | return `postgresql://${user}:${password}@${host}:${port}/${db}?schema=${schema}`; 17 | } 18 | -------------------------------------------------------------------------------- /e2e/a11y/mePage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | import { user1 } from "../dummyUsers"; 3 | import { test } from "../fixtures"; 4 | import { useUser } from "../helpers/users"; 5 | 6 | test.describe("Me Page", () => { 7 | useUser(test, user1); 8 | 9 | test("should not have any automatically detectable accessibility issues", async ({ 10 | a11y, 11 | mePage, 12 | }) => { 13 | await mePage.goTo(); 14 | 15 | const res = await a11y().analyze(); 16 | 17 | expect(res.violations).toEqual([]); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { ImageResponse } from "next/og"; 2 | 3 | export const alt = "🤓"; 4 | export const size = { 5 | width: 1200, 6 | height: 630, 7 | }; 8 | export const contentType = "image/png"; 9 | 10 | export default async function Image() { 11 | return new ImageResponse( 12 |
21 |

🎃

22 |
, 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.internal/setup/code/app-(public)-page.tsx: -------------------------------------------------------------------------------- 1 | import { getSessionOrReject } from "../_utils/auth"; 2 | 3 | export default async function Page() { 4 | const session = await getSessionOrReject(); 5 | 6 | return ( 7 |
8 |

Hello World 😄

9 | 10 | {session?.data?.user 11 | ? `you are signed in as ${session.data.user.name} 😄` 12 | : "you are not signed in 🥲"} 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "./_components/Button"; 4 | import { Container } from "./_components/Container"; 5 | 6 | type Props = { 7 | error: Error & { digest?: string }; 8 | reset: () => void; 9 | }; 10 | 11 | export default function GlobalError({ reset }: Props) { 12 | return ( 13 | 14 | 15 | 16 |

Something went wrong!

17 | 18 |
19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | import { config } from "./env"; 3 | 4 | config(); 5 | 6 | export default defineConfig({ 7 | testDir: "./e2e", 8 | fullyParallel: true, 9 | projects: [ 10 | { 11 | name: "setup", 12 | testMatch: /.\/e2e\/setup\/.*.ts/, 13 | }, 14 | { 15 | name: "chrome", 16 | use: { 17 | ...devices["Desktop Chrome"], 18 | headless: true, 19 | launchOptions: { 20 | args: [], 21 | }, 22 | }, 23 | dependencies: ["setup"], 24 | }, 25 | ], 26 | }); 27 | -------------------------------------------------------------------------------- /.vscode/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "inputs": [ 3 | { 4 | "type": "promptString", 5 | "id": "github_token", 6 | "description": "GitHub Personal Access Token", 7 | "password": true 8 | } 9 | ], 10 | "servers": { 11 | "github": { 12 | "command": "docker", 13 | "args": [ 14 | "run", 15 | "-i", 16 | "--rm", 17 | "-e", 18 | "GITHUB_PERSONAL_ACCESS_TOKEN", 19 | "ghcr.io/github/github-mcp-server" 20 | ], 21 | "env": { 22 | "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /e2e/models/SignInPage.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Base } from "./Base"; 3 | 4 | export class SignInPage extends Base { 5 | buttonSignInWithGoogleLocator: Locator; 6 | 7 | constructor(page: Page) { 8 | super(page); 9 | 10 | this.buttonSignInWithGoogleLocator = this.page.getByRole("button", { 11 | name: "Sign in with Google", 12 | }); 13 | } 14 | 15 | async goTo() { 16 | return await this.page.goto("/signin"); 17 | } 18 | 19 | async expectUI() { 20 | await expect(this.buttonSignInWithGoogleLocator).toBeVisible(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareSourceConfig } from "next/experimental/testing/server"; 2 | import { NextResponse } from "next/server"; 3 | import NextAuth from "next-auth"; 4 | import { config as authConfig } from "./app/_clients/nextAuthConfig"; 5 | 6 | const { auth } = NextAuth(authConfig); 7 | 8 | export const config: MiddlewareSourceConfig = { 9 | matcher: ["/me(.*)"], 10 | }; 11 | 12 | export default auth(async function middleware(req) { 13 | if (req.auth?.user.role === "USER") { 14 | return NextResponse.next(); 15 | } 16 | 17 | return NextResponse.rewrite(new URL("/signin", req.url)); 18 | }); 19 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "formatter": { 11 | "enabled": true, 12 | "indentStyle": "space", 13 | "lineWidth": 80 14 | }, 15 | "linter": { 16 | "enabled": true, 17 | "rules": { 18 | "recommended": true 19 | } 20 | }, 21 | "files": { 22 | "ignoreUnknown": true, 23 | "includes": [ 24 | "**", 25 | "!**/.next", 26 | "!**/dist", 27 | "!**/cache", 28 | "!**/__generated__" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/_hooks/useOnlineStatus.ts: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from "react"; 2 | 3 | export function useOnlineStatus() { 4 | const isOnline = useSyncExternalStore( 5 | subscribe, 6 | () => navigator.onLine, 7 | () => /* assume online on the server */ true, 8 | ); 9 | 10 | // don't need memoization 11 | function subscribe(cb: (event: Event) => void) { 12 | window.addEventListener("online", cb); 13 | window.addEventListener("offline", cb); 14 | 15 | return () => { 16 | window.removeEventListener("online", cb); 17 | window.removeEventListener("offline", cb); 18 | }; 19 | } 20 | 21 | return { isOnline }; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/_utils/auth.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "next-auth"; 2 | import { auth } from "../_clients/nextAuth"; 3 | import type { Result } from "../_types/result"; 4 | 5 | export async function getSessionOrReject(): Promise> { 6 | try { 7 | const session = await auth(); 8 | 9 | if (!session?.user?.id) { 10 | return { 11 | success: false, 12 | message: "no session token", 13 | }; 14 | } 15 | 16 | return { 17 | success: true, 18 | data: session, 19 | }; 20 | } catch { 21 | return { 22 | success: false, 23 | message: "no session token", 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /e2e/helpers/app.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import { getRandomPort } from "./getRandomPort"; 3 | import { waitForHealth } from "./waitForHealth"; 4 | 5 | export async function setupApp(dbPort: number) { 6 | const appPort = await getRandomPort(); 7 | const baseURL = `http://localhost:${appPort}`; 8 | const cp = exec( 9 | `NEXTAUTH_URL=${baseURL} DATABASE_PORT=${dbPort} pnpm start --port ${appPort}`, 10 | ); 11 | await waitForHealth(baseURL); 12 | 13 | return { 14 | appPort, 15 | baseURL, 16 | async [Symbol.asyncDispose]() { 17 | if (cp.pid) { 18 | process.kill(cp.pid); 19 | } 20 | }, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/_components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Container } from "./Container"; 3 | 4 | export function Footer() { 5 | return ( 6 |
7 | 8 |
    9 |
  1. 10 | 16 | Repository 17 | 18 |
  2. 19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/_components/FormBox.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | 3 | export type Props = PropsWithChildren<{ 4 | id: string; 5 | errorId: string; 6 | error?: string; 7 | label?: string; 8 | }>; 9 | 10 | export function FormBox({ id, errorId, error, label, children }: Props) { 11 | return ( 12 |
13 | {label && } 14 |
{children}
15 | {error && ( 16 | 17 | {error} 18 | 19 | )} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /.internal/tests/no-docker.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { 3 | modifiedFiles, 4 | removedDirs, 5 | removedFiles, 6 | } from "../setup/questions/docker.mjs"; 7 | import { BaseTest } from "./Basetest.mjs"; 8 | 9 | const outputDir = "no-docker"; 10 | const baseTest = new BaseTest({ outputDir }); 11 | 12 | describe("no-docker", async () => { 13 | baseTest.globalHook({ 14 | noDocker: true, 15 | }); 16 | 17 | await baseTest.testFileList(); 18 | await baseTest.testFileContent(modifiedFiles); 19 | await baseTest.testRemovedDirs(removedDirs); 20 | await baseTest.testRemovedFiles(removedFiles); 21 | await baseTest.allTests({ hasE2e: true }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/_components/Container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { PropsWithChildren } from "react"; 3 | 4 | type Props = PropsWithChildren< 5 | React.JSX.IntrinsicElements["div"] & { 6 | size?: "sm" | "md" | "lg"; 7 | } 8 | >; 9 | 10 | export function Container({ 11 | children, 12 | className, 13 | size = "lg", 14 | ...rest 15 | }: Props) { 16 | return ( 17 |
27 | {children} 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/_types/result.ts: -------------------------------------------------------------------------------- 1 | import type { ZodFlattenedError } from "zod"; 2 | 3 | type SuccessResult = { 4 | success: true; 5 | data: T; 6 | message?: string; 7 | }; 8 | 9 | type FailureResult = { 10 | success: false; 11 | message?: string; 12 | data?: U; 13 | zodErrors?: T extends Record 14 | ? ZodFlattenedError["fieldErrors"] 15 | : never; 16 | }; 17 | 18 | /** 19 | * @typeParam T - data to be returned if successful 20 | * @typeParam U - validation error by zod 21 | * @typeParam P - data to be returned if failed 22 | */ 23 | export type Result, P = never> = 24 | | SuccessResult 25 | | FailureResult; 26 | -------------------------------------------------------------------------------- /src/app/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { Schema } from "../../env"; 2 | import type { Role } from "./__generated__/prisma"; 3 | 4 | declare global { 5 | namespace NodeJS { 6 | interface ProcessEnv extends Schema {} 7 | } 8 | 9 | type PartialWithNullable = { 10 | [P in keyof T]?: T[P] | null; 11 | }; 12 | } 13 | 14 | declare module "next-auth" { 15 | interface User { 16 | id: string; 17 | name: string; 18 | email: string; 19 | image: string; 20 | role: Role; 21 | } 22 | 23 | interface Session { 24 | user: User; 25 | } 26 | } 27 | 28 | declare module "next-auth/jwt" { 29 | interface JWT { 30 | user: import("next-auth").Session["user"]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vitest/config"; 3 | import { config } from "./env"; 4 | 5 | config(); 6 | 7 | export default defineConfig(async () => { 8 | return { 9 | plugins: [react()], 10 | test: { 11 | globals: true, 12 | mockReset: true, 13 | restoreMocks: true, 14 | clearMocks: true, 15 | include: ["./src/**/*.test.{ts,tsx}"], 16 | globalSetup: "./tests/vitest.setup.ts", 17 | environment: "jsdom", 18 | // https://github.com/nextauthjs/next-auth/discussions/9385 19 | server: { 20 | deps: { 21 | inline: ["next"], 22 | }, 23 | }, 24 | }, 25 | }; 26 | }); 27 | -------------------------------------------------------------------------------- /.internal/tests/no-otel.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { 3 | modifiedFiles, 4 | removedDirs, 5 | removedFiles, 6 | } from "../setup/questions/otel.mjs"; 7 | import { BaseTest } from "./Basetest.mjs"; 8 | 9 | const outputDir = "no-otel"; 10 | const baseTest = new BaseTest({ outputDir }); 11 | 12 | describe("no-otel", async () => { 13 | baseTest.globalHook({ 14 | noOtel: true, 15 | }); 16 | 17 | await baseTest.testFileList(); 18 | await baseTest.testFileContent(modifiedFiles); 19 | await baseTest.testRemovedDirs(removedDirs); 20 | await baseTest.testRemovedFiles(removedFiles); 21 | await baseTest.testDependencies(); 22 | await baseTest.allTests({ hasE2e: true }); 23 | }); 24 | -------------------------------------------------------------------------------- /otel-collector-config.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/examples/demo/otel-collector-config.yaml 2 | 3 | receivers: 4 | otlp: 5 | protocols: 6 | grpc: 7 | endpoint: 0.0.0.0:4317 8 | exporters: 9 | otlp: 10 | endpoint: jaeger:4317 11 | tls: 12 | insecure: true 13 | processors: 14 | batch: 15 | extensions: 16 | health_check: 17 | service: 18 | extensions: [health_check] 19 | telemetry: 20 | logs: 21 | level: "debug" 22 | pipelines: 23 | traces: 24 | receivers: [otlp] 25 | processors: [batch] 26 | exporters: [otlp] 27 | metrics: 28 | receivers: [otlp] 29 | processors: [batch] 30 | exporters: [otlp] 31 | -------------------------------------------------------------------------------- /src/app/_components/ErrorPageTemplate.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import type { PropsWithChildren } from "react"; 3 | import { Container } from "./Container"; 4 | 5 | type Props = PropsWithChildren<{ 6 | title: string; 7 | message?: string; 8 | hasHomeLink?: boolean; 9 | }>; 10 | 11 | export function ErrorPageTemplate({ 12 | title, 13 | message, 14 | hasHomeLink = true, 15 | children, 16 | }: Props) { 17 | return ( 18 | 19 | {title} 20 | {message &&

{message}

} 21 | {hasHomeLink && Go to Home} 22 | {children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /.internal/site/src/public/images/libs/pnpm.svg: -------------------------------------------------------------------------------- 1 | 2 | file_type_light_pnpm -------------------------------------------------------------------------------- /e2e/helpers/waitForHealth.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers/promises"; 2 | 3 | export async function waitForHealth(baseUrl: string) { 4 | const maxAttempts = 30; 5 | const interval = 100; 6 | const healthUrl = `${baseUrl}/api/health`; 7 | let attempts = 0; 8 | 9 | while (attempts < maxAttempts) { 10 | try { 11 | const response = await fetch(healthUrl); 12 | 13 | if (response.ok) { 14 | const data = await response.json(); 15 | 16 | if (data.status === "ok") { 17 | return; 18 | } 19 | } 20 | } catch {} 21 | 22 | attempts++; 23 | await setTimeout(interval); 24 | } 25 | 26 | throw new Error(`Server health check failed after ${maxAttempts} attempts`); 27 | } 28 | -------------------------------------------------------------------------------- /.internal/tests/no-stripe.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { 3 | modifiedFiles, 4 | removedDirs, 5 | removedFiles, 6 | } from "../setup/questions/stripe.mjs"; 7 | import { BaseTest } from "./Basetest.mjs"; 8 | 9 | const outputDir = "no-stripe"; 10 | const baseTest = new BaseTest({ outputDir }); 11 | 12 | describe("no-stripe", async () => { 13 | baseTest.globalHook({ 14 | noStripe: true, 15 | }); 16 | 17 | await baseTest.testFileList({ ignoreSrc: false }); 18 | await baseTest.testFileContent(modifiedFiles); 19 | await baseTest.testRemovedDirs(removedDirs); 20 | await baseTest.testRemovedFiles(removedFiles); 21 | await baseTest.testDependencies(); 22 | await baseTest.allTests({ hasE2e: true }); 23 | }); 24 | -------------------------------------------------------------------------------- /e2e/a11y/itemPage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "@playwright/test"; 2 | import { user1 } from "../dummyUsers"; 3 | import { test } from "../fixtures"; 4 | import { useUser } from "../helpers/users"; 5 | 6 | test.describe("Items Page", () => { 7 | useUser(test, user1); 8 | 9 | test("should not have any automatically detectable accessibility issues", async ({ 10 | a11y, 11 | topPage, 12 | itemPage, 13 | }) => { 14 | const content = "foo"; 15 | 16 | await topPage.goTo(); 17 | await topPage.addItem(content); 18 | await topPage.clickItemByTitle(content); 19 | 20 | await itemPage.expectUI(content); 21 | 22 | const res = await a11y().analyze(); 23 | 24 | expect(res.violations).toEqual([]); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /.internal/tests/no-sample-code.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { 3 | modifiedFiles, 4 | removedDirs, 5 | removedFiles, 6 | } from "../setup/questions/sample-code.mjs"; 7 | import { BaseTest } from "./Basetest.mjs"; 8 | 9 | const outputDir = "no-sample-code"; 10 | const baseTest = new BaseTest({ outputDir }); 11 | 12 | describe("no-sample-code", async () => { 13 | baseTest.globalHook({ 14 | noSampleCode: true, 15 | }); 16 | 17 | await baseTest.testFileList({ ignoreSrc: false, ignoreE2e: false }); 18 | await baseTest.testFileContent(modifiedFiles); 19 | await baseTest.testRemovedDirs(removedDirs); 20 | await baseTest.testRemovedFiles(removedFiles); 21 | 22 | await baseTest.allTests({ hasE2e: true }); 23 | }); 24 | -------------------------------------------------------------------------------- /.internal/create-app-foundation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-app-foundation", 3 | "version": "1.0.21", 4 | "description": "Create a web app template using Next.js, NextAuth, Prisma, etc. An easy way to begin development 🐕", 5 | "bin": { 6 | "create-app-foundation": "src/index.mjs" 7 | }, 8 | "author": "hiroppy (https://hiroppy.me/)", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/hiroppy/create-app-foundation.git" 12 | }, 13 | "files": [ 14 | "src" 15 | ], 16 | "license": "MIT", 17 | "keywords": [ 18 | "react", 19 | "next", 20 | "next.js", 21 | "nextAuth", 22 | "prisma", 23 | "typescript", 24 | "template", 25 | "react hook form" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.internal/setup/questions/index.mjs: -------------------------------------------------------------------------------- 1 | import { docker } from "./docker.mjs"; 2 | import { e2e } from "./e2e.mjs"; 3 | import { otel } from "./otel.mjs"; 4 | import { sampleCode } from "./sample-code.mjs"; 5 | import { stripe } from "./stripe.mjs"; 6 | 7 | export async function askQuestions() { 8 | const [_, __, ...flags] = process.argv; 9 | const isSkipQuestions = flags.includes("--skip-questions"); 10 | 11 | await sampleCode(flags.includes("--remove-sample-code"), isSkipQuestions); 12 | await docker(flags.includes("--remove-docker"), isSkipQuestions); 13 | await e2e(flags.includes("--remove-e2e"), isSkipQuestions); 14 | await otel(flags.includes("--remove-otel"), isSkipQuestions); 15 | await stripe(flags.includes("--remove-stripe"), isSkipQuestions); 16 | } 17 | -------------------------------------------------------------------------------- /.internal/tests/no-e2e.test.mjs: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { 3 | modifiedFiles, 4 | removedDirs, 5 | removedFiles, 6 | } from "../setup/questions/e2e.mjs"; 7 | import { BaseTest } from "./Basetest.mjs"; 8 | 9 | const outputDir = "no-e2e"; 10 | const baseTest = new BaseTest({ outputDir }); 11 | 12 | describe("no-e2e", async () => { 13 | baseTest.globalHook({ 14 | noE2e: true, 15 | }); 16 | 17 | await baseTest.testFileList({ ignoreE2e: true }); 18 | await baseTest.testFileContent(modifiedFiles); 19 | await baseTest.testRemovedDirs(removedDirs); 20 | await baseTest.testRemovedFiles(removedFiles); 21 | await baseTest.testDependencies(); 22 | await baseTest.testNpmScripts(); 23 | await baseTest.allTests({ hasE2e: false }); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/models/NotFoundPage.ts: -------------------------------------------------------------------------------- 1 | import { expect, type Locator, type Page } from "@playwright/test"; 2 | import { Base } from "./Base"; 3 | 4 | export class NotFoundPage extends Base { 5 | textNotFoundLocator: Locator; 6 | linkGoToHomeLocator: Locator; 7 | 8 | constructor(page: Page) { 9 | super(page); 10 | 11 | this.textNotFoundLocator = this.page.locator("span", { 12 | hasText: "Not Found", 13 | }); 14 | this.linkGoToHomeLocator = this.page.locator("a", { 15 | hasText: "Go to Home", 16 | }); 17 | } 18 | 19 | async goTo() { 20 | return await this.page.goto("/404"); 21 | } 22 | 23 | async expectUI() { 24 | await expect(this.textNotFoundLocator).toBeVisible(); 25 | await expect(this.linkGoToHomeLocator).toBeVisible(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/_hooks/useFormId.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { describe, expect, test } from "vitest"; 3 | import { useFormId } from "./useFormId"; 4 | 5 | describe("hooks/useFormId", () => { 6 | test("should return id and errorId", () => { 7 | const { result } = renderHook(() => useFormId()); 8 | 9 | expect(result.current.id).toBeDefined(); 10 | expect(typeof result.current.id).toBe("string"); 11 | 12 | expect(result.current.errorId).toBeDefined(); 13 | expect(typeof result.current.errorId).toBe("string"); 14 | }); 15 | 16 | test("errorId should be derived from id", () => { 17 | const { result } = renderHook(() => useFormId()); 18 | 19 | expect(result.current.errorId).toBe(`${result.current.id}-error`); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /e2e/dummyUsers.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "next-auth"; 2 | 3 | type RemoveNullish = { 4 | [K in keyof T]-?: NonNullable; 5 | }; 6 | 7 | type NonNullableUser = RemoveNullish; 8 | 9 | export const user1: NonNullableUser = { 10 | id: "id1", 11 | name: "user1", 12 | email: "user1@a.com", 13 | image: 14 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAMElEQVR42u3OMQEAAAQAMDrpp4Zuyojh2RIsa7bjUQoICAgICAgICAgICAgICHwHDhv0ROEuXMGUAAAAAElFTkSuQmCC", 15 | role: "USER", 16 | }; 17 | 18 | export const admin1: NonNullableUser = { 19 | id: "id2", 20 | name: "admin1", 21 | email: "admin1@a.com", 22 | image: 23 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8qS5UDwAExgGj/3sspQAAAABJRU5ErkJggg==", 24 | role: "ADMIN", 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/_clients/prisma.ts: -------------------------------------------------------------------------------- 1 | // https://www.prisma.io/docs/orm/more/help-and-troubleshooting/help-articles/nextjs-prisma-client-dev-practices 2 | 3 | import { PrismaClient } from "../__generated__/prisma"; 4 | import { createDBUrl } from "../_utils/db"; 5 | 6 | function prismaClientSingleton() { 7 | return new PrismaClient({ 8 | datasources: { 9 | db: { 10 | url: createDBUrl({}), 11 | }, 12 | }, 13 | }); 14 | } 15 | 16 | // biome-ignore lint: Do not shadow the global "globalThis" property. 17 | declare const globalThis: { 18 | prismaGlobal: ReturnType; 19 | } & typeof global; 20 | 21 | export const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); 22 | 23 | if (process.env.NODE_ENV !== "production") { 24 | globalThis.prismaGlobal = prisma; 25 | } 26 | -------------------------------------------------------------------------------- /.internal/setup/init.mjs: -------------------------------------------------------------------------------- 1 | import { executeCommonProcessing } from "./common-processing.mjs"; 2 | import { format } from "./format.mjs"; 3 | import { askQuestions } from "./questions/index.mjs"; 4 | import { execAsync, removeDirs, title } from "./utils.mjs"; 5 | 6 | await execAsync("npm run setup", { stdio: "ignore" }); 7 | 8 | title("Installing dependencies"); 9 | await execAsync("pnpm i", { stdio: "ignore" }); 10 | 11 | title("Copying .env.sample to .env"); 12 | await execAsync("cp .env.sample .env"); 13 | 14 | await executeCommonProcessing(); 15 | 16 | await askQuestions(); 17 | 18 | // need to execute after asking questions 19 | await removeDirs([".internal"]); 20 | 21 | await format(); 22 | 23 | await execAsync("pnpm rebuild"); 24 | await execAsync("npx lefthook install"); 25 | 26 | console.info("done! please commit them 🐶"); 27 | -------------------------------------------------------------------------------- /.internal/setup/questions/docker.mjs: -------------------------------------------------------------------------------- 1 | import { executeOptionalQuestion, removeFiles } from "../utils.mjs"; 2 | 3 | export const removedFiles = /** @type {const} */ ([ 4 | "Dockerfile", 5 | ".dockerignore", 6 | ]); 7 | 8 | export const removedDirs = /** @type {const} */ ([]); 9 | 10 | export const modifiedFiles = /** @type {const} */ ([ 11 | ".github/workflows/ci.yml", 12 | ]); 13 | 14 | export async function docker(answer, isSkipQuestion) { 15 | const fence = ["# start: docker #", "# end: docker #"]; 16 | 17 | await executeOptionalQuestion({ 18 | question: "> Do you want to remove docker files? (y/N) ", 19 | answer, 20 | isSkipQuestion, 21 | codeAndFenceList: [[modifiedFiles[0], fence]], 22 | yesCallback: async () => { 23 | await Promise.all([removeFiles(removedFiles)]); 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /.internal/setup/db.mjs: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | 3 | export async function generateMigrationFiles() { 4 | const commands = [ 5 | "pnpm db:up", 6 | "pnpm db:migrate --name initial-migration", 7 | "pnpm generate:client", 8 | "docker compose down", 9 | ]; 10 | 11 | for (const command of commands) { 12 | const [cmd, ...args] = command.split(" "); 13 | 14 | await new Promise((resolve, reject) => { 15 | const child = spawn(cmd, args, { stdio: "ignore" }); 16 | 17 | child.on("exit", (code) => { 18 | if (code === 0) { 19 | resolve(); 20 | } else { 21 | reject( 22 | new Error( 23 | `[docker compose]: "${command}" failed with code ${code}`, 24 | ), 25 | ); 26 | } 27 | }); 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/_utils/db.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { createDBUrl } from "./db"; 3 | 4 | describe("utils/db", () => { 5 | describe("createDBUrl", () => { 6 | test("should create url by environment variables", () => { 7 | expect(createDBUrl({})).toMatchInlineSnapshot( 8 | `"postgresql://local:1234@localhost:5432/local?schema=public"`, 9 | ); 10 | }); 11 | 12 | test("should create url by params", () => { 13 | expect( 14 | createDBUrl({ 15 | user: "user", 16 | password: "password", 17 | host: "host", 18 | port: 5432, 19 | db: "db", 20 | schema: "schema", 21 | }), 22 | ).toMatchInlineSnapshot( 23 | `"postgresql://user:password@host:5432/db?schema=schema"`, 24 | ); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "erasableSyntaxOnly": true, 16 | "jsx": "react-jsx", 17 | "incremental": true, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ] 23 | }, 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx", 28 | ".next/types/**/*.ts", 29 | "env.ts", 30 | ".next/dev/types/**/*.ts" 31 | ], 32 | "exclude": ["node_modules"] 33 | } 34 | -------------------------------------------------------------------------------- /src/app/(public)/items/[itemId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { prisma } from "../../../_clients/prisma"; 3 | import { format } from "../../../_utils/date"; 4 | 5 | export default async function Page({ params }: PageProps<"/items/[itemId]">) { 6 | const { itemId } = await params; 7 | const item = await prisma.item.findUnique({ 8 | where: { 9 | id: itemId, 10 | }, 11 | include: { 12 | user: true, 13 | }, 14 | }); 15 | 16 | if (!item) { 17 | notFound(); 18 | } 19 | 20 | return ( 21 |
22 |
23 |

Item Detail

24 | 27 |
28 |

{item.content}

29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /.internal/site/src/introduction/dotenv.md: -------------------------------------------------------------------------------- 1 | # .env 2 | 3 | Dotenv is the de facto standard, but it lacks validation, making it prone to errors. In this template, we use zod to validate the required environment variables in files like `next.config.ts`. If any required variables are missing or invalid, the application will fail to execute. This approach ensures robustness at runtime. 4 | 5 | ::: code-group 6 | 7 | <<< ../../../../.env.sample 8 | 9 | <<< ../../../../env.ts 10 | 11 | ::: 12 | 13 | If you need to add new environment variables, make sure to add them to both `.env` and `.env.test`, and update the `env.ts` with the corresponding zod validation. 14 | 15 | ## Why not use .env.local? 16 | 17 | Prisma can read the `.env` file, and the `DATABASE_URL` is a required key for migration. While Next.js prioritizes `.env.local`, splitting the dotenv files can be inefficient. Therefore, to maintain consistency with Prisma, this template uses `.env`. 18 | -------------------------------------------------------------------------------- /.github/workflows/internal.yml: -------------------------------------------------------------------------------- 1 | name: internal 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | e2e: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | file: 17 | - common 18 | - all-opt-out 19 | - no-docker 20 | - no-e2e 21 | - no-otel 22 | - no-stripe 23 | - no-sample-code 24 | steps: 25 | - uses: actions/checkout@v6 26 | - uses: ./.github/actions/setup-node 27 | - if: ${{ github.ref_name == 'main' }} 28 | run: echo "IS_MAIN_BRANCH=true" >> $GITHUB_ENV 29 | - run: pnpm exec playwright install chromium 30 | - run: cd ./.internal/tests && make run-${{ matrix.file }} 31 | env: 32 | # to fetch this repo via CLI on main branch 33 | IS_MAIN_BRANCH: ${{ env.IS_MAIN_BRANCH }} 34 | IS_LOCAL: false 35 | -------------------------------------------------------------------------------- /e2e/integrations/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { user1 } from "../dummyUsers"; 2 | import { test } from "../fixtures"; 3 | import { useUser } from "../helpers/users"; 4 | 5 | test.describe("no sign in", () => { 6 | test("should redirect to signIn page when accessing /me page", async ({ 7 | mePage, 8 | signInPage, 9 | }) => { 10 | await mePage.goTo(); 11 | 12 | await mePage.expectHeaderUI("signOut"); 13 | await signInPage.expectUI(); 14 | }); 15 | }); 16 | 17 | test.describe("sign in", () => { 18 | useUser(test, user1); 19 | 20 | test.beforeEach(async ({ topPage }) => { 21 | await topPage.goTo(); 22 | await topPage.expectUI("signIn", user1); 23 | await topPage.expectHeaderUI("signIn", user1); 24 | }); 25 | 26 | test("should go to /me page", async ({ mePage }) => { 27 | await mePage.goToMePage(); 28 | 29 | await mePage.expectHeaderUI("signIn", user1); 30 | await mePage.expectUI(user1.name); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports.biome": "explicit", 5 | "source.fixAll.biome": "explicit" 6 | }, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "[javascript]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[javascriptreact]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "biomejs.biome" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "biomejs.biome" 19 | }, 20 | "[prisma]": { 21 | "editor.defaultFormatter": "Prisma.prisma" 22 | }, 23 | "prettier.requireConfig": false, 24 | "cSpell.enabled": true, 25 | "cSpell.words": [ 26 | "dotenv", 27 | "biomejs", 28 | "otel", 29 | "otlp", 30 | "clsx", 31 | "vitest", 32 | "knip", 33 | "cuid" 34 | ], 35 | "cSpell.allowCompoundWords": true, 36 | "cSpell.ignorePaths": ["**/node_modules/**"] 37 | } 38 | -------------------------------------------------------------------------------- /src/app/_components/Input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import clsx from "clsx"; 4 | import type { JSX } from "react"; 5 | import { useFormId } from "../_hooks/useFormId"; 6 | import { FormBox, type Props as FormBoxProps } from "./FormBox"; 7 | 8 | type Props = JSX.IntrinsicElements["input"] & 9 | Omit; 10 | 11 | export function Input({ className, error, label, ...rest }: Props) { 12 | const { id, errorId } = useFormId(); 13 | 14 | return ( 15 | 16 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/_utils/zod.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { z } from "zod"; 3 | import { getFieldErrors } from "./zod"; 4 | 5 | describe("utils/zod", () => { 6 | describe("getFieldErrors", () => { 7 | test("should return field errors", () => { 8 | const schema = z.object({ 9 | name: z.string().min(1, "Name is required"), 10 | }); 11 | 12 | const parsedValues = schema.safeParse({ 13 | name: "", 14 | }); 15 | 16 | expect(getFieldErrors(parsedValues)).toMatchInlineSnapshot(` 17 | { 18 | "name": [ 19 | "Name is required", 20 | ], 21 | } 22 | `); 23 | }); 24 | 25 | test("should return an empty object", () => { 26 | const schema = z.object({ 27 | name: z.string().min(1, "Name is required"), 28 | }); 29 | 30 | const parsedValues = schema.safeParse({ 31 | name: "foo", 32 | }); 33 | 34 | expect(getFieldErrors(parsedValues)).toMatchInlineSnapshot(`{}`); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /.internal/setup/code/app-layout.tsx: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import type { Metadata, Viewport } from "next"; 3 | import { Inter } from "next/font/google"; 4 | import type { PropsWithChildren } from "react"; 5 | import { Footer } from "./_components/Footer"; 6 | import { Header } from "./_components/Header"; 7 | import "./globals.css"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL), 13 | title: "😸", 14 | description: "", 15 | }; 16 | 17 | export const viewport: Viewport = { 18 | maximumScale: 1, 19 | }; 20 | 21 | export default function Layout({ children }: PropsWithChildren) { 22 | return ( 23 | 24 | 30 |
31 |
{children}
32 |