nextjs-hackathon-stack 0.1.27 → 0.1.29

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # nextjs-hackathon-stack
2
2
 
3
- > Scaffold a full-stack Next.js 15 hackathon starter in one command.
3
+ > Scaffold a full-stack Next.js hackathon starter in one command.
4
4
 
5
5
  ```bash
6
6
  npx nextjs-hackathon-stack my-app
@@ -10,68 +10,57 @@ npx nextjs-hackathon-stack my-app
10
10
 
11
11
  | Layer | Tool |
12
12
  |---|---|
13
- | Framework | Next.js 15 + App Router |
13
+ | Framework | Next.js 16 + App Router |
14
14
  | Database | Supabase (PostgreSQL) |
15
15
  | Auth | Supabase Auth (`@supabase/ssr`) |
16
16
  | ORM | Drizzle + drizzle-zod (schema + migrations) |
17
17
  | Runtime Queries | supabase-js (RLS active) |
18
18
  | Client State | TanStack Query v5 |
19
- | Validation | Zod (auto-generated via drizzle-zod) |
20
19
  | Forms | React Hook Form + Zod resolver |
21
20
  | UI | shadcn/ui + Tailwind CSS v4 |
22
- | AI Streaming | Vercel AI SDK + AI Gateway |
23
- | LLM | Google Gemini 2.0 Flash (`google/gemini-2.0-flash`) |
24
- | Testing | Vitest + React Testing Library + Playwright |
21
+ | Testing | Vitest + Playwright (100% coverage enforced) |
22
+ | Code Quality | ESLint 9 + Husky + lint-staged |
23
+ | AI Tooling | Cursor rules/agents/skills + Claude Code instructions |
25
24
 
26
25
  ## Quick start
27
26
 
28
27
  ```bash
29
- # Create project
30
28
  npx nextjs-hackathon-stack my-app
31
29
  cd my-app
32
30
 
33
- # .env.local was created automatically — fill in your keys:
31
+ # Fill in .env.local (created automatically):
34
32
  # NEXT_PUBLIC_SUPABASE_URL → supabase.com > Project Settings > API
35
33
  # NEXT_PUBLIC_SUPABASE_ANON_KEY → supabase.com > Project Settings > API
36
34
  # DATABASE_URL → supabase.com > Project Settings > Database
37
- # AI_GATEWAY_URL → vercel.com > AI > Gateways
38
35
 
39
- npm run dev
36
+ pnpm db:migrate
37
+ pnpm dev
40
38
  ```
41
39
 
42
40
  ## Options
43
41
 
44
- ```bash
45
- npx nextjs-hackathon-stack my-app --skip-install
46
- ```
47
-
48
- | Flag | Description |
42
+ | Argument / Flag | Description |
49
43
  |---|---|
50
- | `--skip-install` | Skip `npm install` and `shadcn/ui` init |
44
+ | `[project-name]` | Directory name for the new project |
45
+ | `--skip-install` | Skip `pnpm install` and `shadcn/ui` init |
51
46
 
52
- ## Features
47
+ ## What the CLI does
53
48
 
54
- - **Auth** Email/password login with Supabase Auth, Server Actions, protected routes
55
- - **AI Chat** Streaming chat with Gemini 2.0 Flash via Vercel AI Gateway (Edge runtime)
56
- - **TDD-ready** 100% coverage enforced, Vitest + Playwright preconfigured
57
- - **Cursor AI** — Rules, agents, and skills preconfigured for the full stack
49
+ 1. Copies the template into `[project-name]/`
50
+ 2. Replaces `{{projectName}}` placeholders with the actual project name
51
+ 3. Installs dependencies with `pnpm`
52
+ 4. Initialises shadcn/ui components
53
+ 5. Sets up a git repo with Husky pre-commit hooks
58
54
 
59
- ## Architecture
55
+ ## Requirements
60
56
 
61
- Feature-based structure:
57
+ - Node >= 22
58
+ - pnpm
62
59
 
63
- ```
64
- src/
65
- ├── app/ # Next.js routing + layouts
66
- ├── features/
67
- │ ├── auth/ # Login form, server actions, session hook
68
- │ └── chat/ # AI chat (streaming)
69
- ├── shared/
70
- │ ├── lib/ # Supabase clients, AI
71
- │ ├── db/ # Drizzle schema + migrations
72
- │ └── components/# Providers + shadcn/ui
73
- └── e2e/ # Playwright e2e tests
74
- ```
60
+ ## Pre-built features
61
+
62
+ - **Auth** — Email/password login, logout, `useSession` hook, protected routes
63
+ - **Todos** — Full CRUD with server actions, add form, todo list
75
64
 
76
65
  ## License
77
66
 
package/dist/index.js CHANGED
@@ -122,7 +122,7 @@ async function scaffold(projectName, skipInstall) {
122
122
  } catch {
123
123
  spinner2.stop("shadcn/ui init failed \u2014 run manually: npx shadcn@latest init");
124
124
  }
125
- const components = ["button", "input", "card", "form", "label"];
125
+ const components = ["button", "input", "card", "form", "label", "sonner"];
126
126
  for (const component of components) {
127
127
  spinner2.start(`Adding shadcn/ui component: ${component}`);
128
128
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextjs-hackathon-stack",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Scaffold a full-stack Next.js hackathon starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,3 +81,4 @@ export async function createUser(formData: FormData) {
81
81
  - Never expose `DATABASE_URL` to the browser
82
82
  - RLS policies are required before any table goes to production
83
83
  - Validate all mutation inputs with Zod on the server side
84
+ - Server actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` — never return `void`. This enables the frontend to show toast feedback.
@@ -39,6 +39,15 @@ export function InteractiveComponent() {
39
39
  }
40
40
  ```
41
41
 
42
+ ## Toast Feedback
43
+
44
+ All user-initiated actions that modify the database must show a toast (success or error) via `sonner`. Never let a mutation complete silently.
45
+
46
+ - Call `toast.success(message)` on success, `toast.error(message)` on failure
47
+ - For direct action calls: read the returned `ActionResult` and show the toast immediately
48
+ - For `useActionState` forms: use a `useEffect` on state changes to trigger toasts
49
+ - Exception: if the action redirects to another page, no success toast is needed
50
+
42
51
  ## Guardrails
43
52
  - Write RTL tests for every component
44
53
  - Verify a11y before marking work done
@@ -54,6 +54,7 @@ The technical-lead is the orchestrator. Every request passes through here first.
54
54
  - [ ] Proper layer separation
55
55
  - [ ] Edge runtime on all AI routes (Risk 3)
56
56
  - [ ] TanStack Query is NOT used for mutations (Risk 1)
57
+ - [ ] Every DB mutation shows toast feedback to the user (success or error via `sonner`)
57
58
 
58
59
  ## Guardrails
59
60
  - Reject any PR without test coverage
@@ -40,3 +40,5 @@ If two features share logic, move it to `shared/`.
40
40
  - Always `"use server"` at top of file
41
41
  - Validate input with Zod before any business logic
42
42
  - Return typed result objects, use `redirect()` for navigation
43
+ - Actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` — never return `void`
44
+ - The calling component is responsible for showing `toast.success()` / `toast.error()` based on the result
@@ -51,3 +51,10 @@ Never write implementation before tests exist.
51
51
  - Prefer `type` over `interface` for data shapes
52
52
  - Use `satisfies` for type-checked literals
53
53
  - Use `as const` for immutable literals
54
+
55
+ ## Pre-commit Quality Gates
56
+ Every commit must pass two sequential gates:
57
+ 1. **lint-staged** — runs `eslint --max-warnings 0` on staged `.ts/.tsx` files. Any lint warning or error blocks the commit immediately (before the slower test suite runs).
58
+ 2. **Full test suite** — runs `vitest run --coverage` with 100% branch/function/line/statement coverage required.
59
+
60
+ Code that fails either gate cannot be committed.
@@ -57,3 +57,37 @@ export async function myAction(_prev: State, formData: FormData): Promise<State>
57
57
  redirect("/success");
58
58
  }
59
59
  ```
60
+
61
+ ## Toast Feedback
62
+
63
+ Every action that affects the database MUST show a toast to the user. No mutation should complete silently.
64
+
65
+ - Use `toast.success(message)` for successful operations
66
+ - Use `toast.error(message)` for failed operations
67
+ - Import `toast` from `sonner`
68
+ - Server actions that mutate data must return `ActionResult` from `@/shared/lib/action-result` (not `void`)
69
+ - Exception: if the action redirects to another page on success, no success toast is needed (the user sees the new page)
70
+
71
+ ### Pattern A — Direct action call (button onClick, non-`useActionState` forms)
72
+ ```typescript
73
+ import { toast } from "sonner";
74
+ import type { ActionResult } from "@/shared/lib/action-result";
75
+
76
+ startTransition(async () => {
77
+ const result = await myAction(id);
78
+ if (result.status === "success") toast.success(result.message);
79
+ else toast.error(result.message);
80
+ });
81
+ ```
82
+
83
+ ### Pattern B — `useActionState` forms
84
+ ```typescript
85
+ import { useEffect } from "react";
86
+ import { toast } from "sonner";
87
+
88
+ const [state, formAction] = useActionState(myAction, { status: "idle" });
89
+
90
+ useEffect(() => {
91
+ if (state.status === "error") toast.error(state.message);
92
+ }, [state]);
93
+ ```
@@ -1 +1,2 @@
1
+ npx lint-staged
1
2
  vitest run --coverage
@@ -1,6 +1,6 @@
1
1
  # {{projectName}}
2
2
 
3
- Full-stack Next.js 16 hackathon starter.
3
+ Full-stack Next.js 16 hackathon starter with pre-built auth and todos.
4
4
 
5
5
  ## Stack
6
6
 
@@ -14,8 +14,8 @@ Full-stack Next.js 16 hackathon starter.
14
14
  | State | TanStack Query v5 |
15
15
  | Forms | React Hook Form + Zod |
16
16
  | UI | shadcn/ui + Tailwind CSS v4 |
17
- | AI | Vercel AI SDK + MiniMax M2.1 |
18
17
  | Testing | Vitest + Playwright |
18
+ | Code Quality | ESLint 9 + Husky + lint-staged |
19
19
 
20
20
  ## Getting Started
21
21
 
@@ -28,17 +28,12 @@ Full-stack Next.js 16 hackathon starter.
28
28
  NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
29
29
  NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
30
30
  DATABASE_URL=postgresql://postgres.your-project-id:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
31
-
32
- # AI — Vercel AI Gateway (default: MiniMax M2.1)
33
- # Create gateway at: https://vercel.com > AI > Gateways
34
- AI_GATEWAY_URL=https://gateway.ai.vercel.app/v1/your-team-id/your-gateway-id
35
- AI_API_KEY=
36
31
  ```
37
32
 
38
33
  ### 2. Apply database migrations
39
34
 
40
35
  ```bash
41
- pnpm db:migrate # aplica las migraciones a la base de datos
36
+ pnpm db:migrate
42
37
  ```
43
38
 
44
39
  ### 3. Run the dev server
@@ -53,12 +48,18 @@ pnpm dev
53
48
  pnpm dev # Start dev server
54
49
  pnpm build # Production build
55
50
  pnpm lint # Lint (0 warnings allowed)
51
+ pnpm lint:fix # Lint and auto-fix
56
52
  pnpm typecheck # TypeScript check
57
53
  pnpm test # Unit tests
54
+ pnpm test:unit # Unit tests (verbose)
55
+ pnpm test:watch # Watch mode
58
56
  pnpm test:coverage # Tests with coverage (100% required)
59
57
  pnpm test:e2e # Playwright e2e tests
60
- pnpm db:generate # Generate Drizzle migrations
61
- pnpm db:migrate # Apply migrations
58
+ pnpm db:generate # Generate Drizzle migrations from schema
59
+ pnpm db:migrate # Apply migrations to database
60
+ pnpm db:pull # Introspect DB and generate schema
61
+ pnpm db:push # Push schema directly (dev only)
62
+ pnpm db:studio # Open Drizzle Studio GUI
62
63
  ```
63
64
 
64
65
  ## Environment Variables
@@ -68,8 +69,6 @@ pnpm db:migrate # Apply migrations
68
69
  | `NEXT_PUBLIC_SUPABASE_URL` | Supabase > Project Settings > API |
69
70
  | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase > Project Settings > API |
70
71
  | `DATABASE_URL` | Supabase > Project Settings > Database > Connection string > Session mode |
71
- | `AI_GATEWAY_URL` | Vercel > AI > Gateways |
72
- | `AI_API_KEY` | Your AI provider API key |
73
72
  | `NEXT_PUBLIC_APP_URL` | Your deployment URL (default: `http://localhost:3000`) |
74
73
 
75
74
  See `.env.example` for all required variables with comments.
@@ -77,20 +76,27 @@ See `.env.example` for all required variables with comments.
77
76
  ## Architecture
78
77
 
79
78
  Feature-based structure:
79
+
80
80
  ```
81
81
  src/
82
82
  ├── app/ # Next.js routing + layouts
83
- ├── features/ # auth | chat
83
+ ├── features/
84
+ │ ├── auth/ # Email+password login, logout, session hook, protected routes
85
+ │ └── todos/ # CRUD server actions, add form, todo list
84
86
  ├── shared/ # lib | db | components/ui
85
87
  └── e2e/ # Playwright tests
86
88
  ```
87
89
 
88
90
  Dependency direction: `features/* → shared/*` (never reverse).
89
91
 
92
+ ## Pre-commit Hooks
93
+
94
+ Husky runs `vitest --coverage` on every commit. The commit is blocked if coverage drops below 100%.
95
+
90
96
  ## Cursor AI Setup
91
97
 
92
- Cursor rules, agents, and skills are preconfigured in `.cursor/`.
98
+ Cursor rules, agents, and skills are preconfigured in `.cursor/`:
93
99
 
94
- - **Rules**: always-on guardrails for coding standards, architecture, security
95
- - **Agents**: specialized roles (technical-lead, frontend, test-qa, etc.)
96
- - **Skills**: repeatable workflows (`/create-feature`, `/create-api-route`, etc.)
100
+ - **Rules** (11): always-on guardrails for coding standards, architecture, security, and more
101
+ - **Agents** (7): specialized roles (technical-lead, frontend, backend, test-qa, etc.)
102
+ - **Skills** (4): repeatable workflows (`/create-feature`, `/create-api-route`, `/review-branch`, `/security-audit`)
@@ -39,7 +39,8 @@
39
39
  "class-variance-authority": "^0.7",
40
40
  "@radix-ui/react-slot": "^1",
41
41
  "@radix-ui/react-label": "^2",
42
- "lucide-react": "^0.475"
42
+ "lucide-react": "^0.475",
43
+ "sonner": "^2"
43
44
  },
44
45
  "devDependencies": {
45
46
  "typescript": "^5",
@@ -72,6 +73,6 @@
72
73
  "lint-staged": "^15"
73
74
  },
74
75
  "lint-staged": {
75
- "*.{ts,tsx}": ["vitest related --run"]
76
+ "*.{ts,tsx}": ["eslint --max-warnings 0"]
76
77
  }
77
78
  }
@@ -4,6 +4,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
4
4
 
5
5
  import { LoginForm } from "../components/login-form";
6
6
 
7
+ const mockToastError = vi.fn();
8
+
9
+ vi.mock("sonner", () => ({
10
+ toast: {
11
+ success: vi.fn(),
12
+ error: (...args: unknown[]): unknown => mockToastError(...args),
13
+ },
14
+ Toaster: () => null,
15
+ }));
16
+
7
17
  const mockLoginAction = vi.fn().mockResolvedValue({ status: "idle" });
8
18
 
9
19
  vi.mock("../actions/login.action", () => ({
@@ -75,7 +85,7 @@ describe("LoginForm", () => {
75
85
  });
76
86
  });
77
87
 
78
- it("displays server-side error message when action returns error", async () => {
88
+ it("displays server-side error message and toast when action returns error", async () => {
79
89
  // Arrange
80
90
  mockLoginAction.mockResolvedValue({ status: "error", message: "Invalid credentials" });
81
91
  const user = userEvent.setup();
@@ -90,6 +100,7 @@ describe("LoginForm", () => {
90
100
  await waitFor(() => {
91
101
  expect(screen.getByRole("alert")).toBeInTheDocument();
92
102
  expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
103
+ expect(mockToastError).toHaveBeenCalledWith("Invalid credentials");
93
104
  });
94
105
  });
95
106
  });
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { zodResolver } from "@hookform/resolvers/zod";
4
- import { useActionState, useTransition } from "react";
4
+ import { useActionState, useEffect, useTransition } from "react";
5
5
  import { useForm } from "react-hook-form";
6
+ import { toast } from "sonner";
6
7
  import { z } from "zod";
7
8
 
8
9
  import { loginAction, type LoginActionState } from "../actions/login.action";
@@ -31,6 +32,10 @@ export function LoginForm() {
31
32
  formState: { errors },
32
33
  } = useForm<FormValues>({ resolver: zodResolver(schema) });
33
34
 
35
+ useEffect(() => {
36
+ if (state.status === "error") toast.error(state.message);
37
+ }, [state]);
38
+
34
39
  const onSubmit = handleSubmit((data) => {
35
40
  const fd = new FormData();
36
41
  fd.set("email", data.email);
@@ -4,7 +4,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
4
4
 
5
5
  import { AddTodoForm } from "../components/add-todo-form";
6
6
 
7
- const mockAdd = vi.fn().mockResolvedValue(undefined);
7
+ const mockToastSuccess = vi.fn();
8
+ const mockToastError = vi.fn();
9
+
10
+ vi.mock("sonner", () => ({
11
+ toast: {
12
+ success: (...args: unknown[]): unknown => mockToastSuccess(...args),
13
+ error: (...args: unknown[]): unknown => mockToastError(...args),
14
+ },
15
+ Toaster: () => null,
16
+ }));
17
+
18
+ const mockAdd = vi.fn();
8
19
 
9
20
  vi.mock("../actions/todos.action", () => ({
10
21
  addTodoAction: (...args: unknown[]): unknown => mockAdd(...args),
@@ -26,6 +37,7 @@ describe("AddTodoForm", () => {
26
37
 
27
38
  it("llama a addTodoAction con el título al enviar el formulario", async () => {
28
39
  // Arrange
40
+ mockAdd.mockResolvedValue({ status: "success", message: "Tarea agregada" });
29
41
  const user = userEvent.setup();
30
42
  render(<AddTodoForm />);
31
43
 
@@ -39,8 +51,9 @@ describe("AddTodoForm", () => {
39
51
  });
40
52
  });
41
53
 
42
- it("limpia el input después de enviar", async () => {
54
+ it("muestra toast success y limpia el input después de enviar con éxito", async () => {
43
55
  // Arrange
56
+ mockAdd.mockResolvedValue({ status: "success", message: "Tarea agregada" });
44
57
  const user = userEvent.setup();
45
58
  render(<AddTodoForm />);
46
59
  const input = screen.getByRole("textbox", { name: /nueva tarea/i });
@@ -51,10 +64,27 @@ describe("AddTodoForm", () => {
51
64
 
52
65
  // Assert
53
66
  await waitFor(() => {
67
+ expect(mockToastSuccess).toHaveBeenCalledWith("Tarea agregada");
54
68
  expect(input).toHaveValue("");
55
69
  });
56
70
  });
57
71
 
72
+ it("muestra toast error cuando la acción falla", async () => {
73
+ // Arrange
74
+ mockAdd.mockResolvedValue({ status: "error", message: "Error al agregar la tarea" });
75
+ const user = userEvent.setup();
76
+ render(<AddTodoForm />);
77
+
78
+ // Act
79
+ await user.type(screen.getByRole("textbox", { name: /nueva tarea/i }), "Tarea fallida");
80
+ await user.click(screen.getByRole("button", { name: /agregar/i }));
81
+
82
+ // Assert
83
+ await waitFor(() => {
84
+ expect(mockToastError).toHaveBeenCalledWith("Error al agregar la tarea");
85
+ });
86
+ });
87
+
58
88
  it("no envía el formulario si el input está vacío (required)", async () => {
59
89
  // Arrange
60
90
  const user = userEvent.setup();
@@ -1,11 +1,22 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  import { describe, it, expect, vi, beforeEach } from "vitest";
4
4
 
5
5
  import { TodoList } from "../components/todo-list";
6
6
 
7
- const mockToggle = vi.fn().mockResolvedValue(undefined);
8
- const mockDelete = vi.fn().mockResolvedValue(undefined);
7
+ const mockToastSuccess = vi.fn();
8
+ const mockToastError = vi.fn();
9
+
10
+ vi.mock("sonner", () => ({
11
+ toast: {
12
+ success: (...args: unknown[]): unknown => mockToastSuccess(...args),
13
+ error: (...args: unknown[]): unknown => mockToastError(...args),
14
+ },
15
+ Toaster: () => null,
16
+ }));
17
+
18
+ const mockToggle = vi.fn();
19
+ const mockDelete = vi.fn();
9
20
 
10
21
  vi.mock("../actions/todos.action", () => ({
11
22
  toggleTodoAction: (...args: unknown[]): unknown => mockToggle(...args),
@@ -59,8 +70,9 @@ describe("TodoList", () => {
59
70
  expect(screen.getByText("Comprar leche")).toHaveClass("line-through");
60
71
  });
61
72
 
62
- it("llama a toggleTodoAction al cambiar el checkbox", async () => {
73
+ it("llama a toggleTodoAction al cambiar el checkbox y muestra toast success", async () => {
63
74
  // Arrange
75
+ mockToggle.mockResolvedValue({ status: "success", message: "Tarea actualizada" });
64
76
  const user = userEvent.setup();
65
77
  render(<TodoList items={[baseTodo]} />);
66
78
 
@@ -68,11 +80,46 @@ describe("TodoList", () => {
68
80
  await user.click(screen.getByRole("checkbox"));
69
81
 
70
82
  // Assert
71
- expect(mockToggle).toHaveBeenCalledWith("1");
83
+ await waitFor(() => {
84
+ expect(mockToggle).toHaveBeenCalledWith("1");
85
+ expect(mockToastSuccess).toHaveBeenCalledWith("Tarea actualizada");
86
+ });
87
+ });
88
+
89
+ it("muestra toast error cuando toggleTodoAction falla", async () => {
90
+ // Arrange
91
+ mockToggle.mockResolvedValue({ status: "error", message: "Error al actualizar la tarea" });
92
+ const user = userEvent.setup();
93
+ render(<TodoList items={[baseTodo]} />);
94
+
95
+ // Act
96
+ await user.click(screen.getByRole("checkbox"));
97
+
98
+ // Assert
99
+ await waitFor(() => {
100
+ expect(mockToastError).toHaveBeenCalledWith("Error al actualizar la tarea");
101
+ });
102
+ });
103
+
104
+ it("llama a deleteTodoAction al pulsar Eliminar y muestra toast success", async () => {
105
+ // Arrange
106
+ mockDelete.mockResolvedValue({ status: "success", message: "Tarea eliminada" });
107
+ const user = userEvent.setup();
108
+ render(<TodoList items={[baseTodo]} />);
109
+
110
+ // Act
111
+ await user.click(screen.getByRole("button", { name: /eliminar/i }));
112
+
113
+ // Assert
114
+ await waitFor(() => {
115
+ expect(mockDelete).toHaveBeenCalledWith("1");
116
+ expect(mockToastSuccess).toHaveBeenCalledWith("Tarea eliminada");
117
+ });
72
118
  });
73
119
 
74
- it("llama a deleteTodoAction al pulsar Eliminar", async () => {
120
+ it("muestra toast error cuando deleteTodoAction falla", async () => {
75
121
  // Arrange
122
+ mockDelete.mockResolvedValue({ status: "error", message: "Error al eliminar la tarea" });
76
123
  const user = userEvent.setup();
77
124
  render(<TodoList items={[baseTodo]} />);
78
125
 
@@ -80,6 +127,8 @@ describe("TodoList", () => {
80
127
  await user.click(screen.getByRole("button", { name: /eliminar/i }));
81
128
 
82
129
  // Assert
83
- expect(mockDelete).toHaveBeenCalledWith("1");
130
+ await waitFor(() => {
131
+ expect(mockToastError).toHaveBeenCalledWith("Error al eliminar la tarea");
132
+ });
84
133
  });
85
134
  });
@@ -37,20 +37,21 @@ describe("addTodoAction", () => {
37
37
  vi.clearAllMocks();
38
38
  });
39
39
 
40
- it("no hace nada si el título está vacío", async () => {
40
+ it("retorna error si el título está vacío", async () => {
41
41
  // Arrange
42
42
  const { addTodoAction } = await import("../actions/todos.action");
43
43
  const fd = new FormData();
44
44
  fd.set("title", " ");
45
45
 
46
46
  // Act
47
- await addTodoAction(fd);
47
+ const result = await addTodoAction(fd);
48
48
 
49
49
  // Assert
50
+ expect(result.status).toBe("error");
50
51
  expect(mockInsert).not.toHaveBeenCalled();
51
52
  });
52
53
 
53
- it("no hace nada si no hay usuario autenticado", async () => {
54
+ it("retorna error si no hay usuario autenticado", async () => {
54
55
  // Arrange
55
56
  mockGetUser.mockResolvedValue({ data: { user: null } });
56
57
  const { addTodoAction } = await import("../actions/todos.action");
@@ -58,13 +59,14 @@ describe("addTodoAction", () => {
58
59
  fd.set("title", "Mi tarea");
59
60
 
60
61
  // Act
61
- await addTodoAction(fd);
62
+ const result = await addTodoAction(fd);
62
63
 
63
64
  // Assert
65
+ expect(result.status).toBe("error");
64
66
  expect(mockInsert).not.toHaveBeenCalled();
65
67
  });
66
68
 
67
- it("inserta la tarea y llama a revalidatePath cuando el usuario existe", async () => {
69
+ it("inserta la tarea, llama a revalidatePath y retorna success cuando el usuario existe", async () => {
68
70
  // Arrange
69
71
  mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
70
72
  mockInsert.mockResolvedValue(undefined);
@@ -73,24 +75,41 @@ describe("addTodoAction", () => {
73
75
  fd.set("title", "Nueva tarea");
74
76
 
75
77
  // Act
76
- await addTodoAction(fd);
78
+ const result = await addTodoAction(fd);
77
79
 
78
80
  // Assert
81
+ expect(result.status).toBe("success");
79
82
  expect(mockInsert).toHaveBeenCalledWith({ userId: "u1", title: "Nueva tarea" });
80
83
  expect(mockRevalidatePath).toHaveBeenCalledWith("/");
81
84
  });
82
85
 
83
- it("no hace nada si el campo title no está presente", async () => {
86
+ it("retorna error si el campo title no está presente", async () => {
84
87
  // Arrange
85
88
  const { addTodoAction } = await import("../actions/todos.action");
86
89
  const fd = new FormData();
87
90
 
88
91
  // Act
89
- await addTodoAction(fd);
92
+ const result = await addTodoAction(fd);
90
93
 
91
94
  // Assert
95
+ expect(result.status).toBe("error");
92
96
  expect(mockInsert).not.toHaveBeenCalled();
93
97
  });
98
+
99
+ it("retorna error si la inserción lanza una excepción", async () => {
100
+ // Arrange
101
+ mockGetUser.mockResolvedValue({ data: { user: { id: "u1" } } });
102
+ mockInsert.mockRejectedValue(new Error("DB error"));
103
+ const { addTodoAction } = await import("../actions/todos.action");
104
+ const fd = new FormData();
105
+ fd.set("title", "Tarea fallida");
106
+
107
+ // Act
108
+ const result = await addTodoAction(fd);
109
+
110
+ // Assert
111
+ expect(result.status).toBe("error");
112
+ });
94
113
  });
95
114
 
96
115
  describe("toggleTodoAction", () => {
@@ -98,31 +117,45 @@ describe("toggleTodoAction", () => {
98
117
  vi.clearAllMocks();
99
118
  });
100
119
 
101
- it("no hace nada si la tarea no existe", async () => {
120
+ it("retorna error si la tarea no existe", async () => {
102
121
  // Arrange
103
122
  mockSelectWhere.mockResolvedValue([]);
104
123
  const { toggleTodoAction } = await import("../actions/todos.action");
105
124
 
106
125
  // Act
107
- await toggleTodoAction("non-existent-id");
126
+ const result = await toggleTodoAction("non-existent-id");
108
127
 
109
128
  // Assert
129
+ expect(result.status).toBe("error");
110
130
  expect(mockUpdate).not.toHaveBeenCalled();
111
131
  });
112
132
 
113
- it("invierte completed y llama a revalidatePath", async () => {
133
+ it("invierte completed, llama a revalidatePath y retorna success", async () => {
114
134
  // Arrange
115
135
  mockSelectWhere.mockResolvedValue([{ id: "1", completed: false }]);
116
136
  mockUpdate.mockResolvedValue(undefined);
117
137
  const { toggleTodoAction } = await import("../actions/todos.action");
118
138
 
119
139
  // Act
120
- await toggleTodoAction("1");
140
+ const result = await toggleTodoAction("1");
121
141
 
122
142
  // Assert
143
+ expect(result.status).toBe("success");
123
144
  expect(mockUpdate).toHaveBeenCalled();
124
145
  expect(mockRevalidatePath).toHaveBeenCalledWith("/");
125
146
  });
147
+
148
+ it("retorna error si la actualización lanza una excepción", async () => {
149
+ // Arrange
150
+ mockSelectWhere.mockRejectedValue(new Error("DB error"));
151
+ const { toggleTodoAction } = await import("../actions/todos.action");
152
+
153
+ // Act
154
+ const result = await toggleTodoAction("1");
155
+
156
+ // Assert
157
+ expect(result.status).toBe("error");
158
+ });
126
159
  });
127
160
 
128
161
  describe("deleteTodoAction", () => {
@@ -130,16 +163,29 @@ describe("deleteTodoAction", () => {
130
163
  vi.clearAllMocks();
131
164
  });
132
165
 
133
- it("elimina la tarea y llama a revalidatePath", async () => {
166
+ it("elimina la tarea, llama a revalidatePath y retorna success", async () => {
134
167
  // Arrange
135
168
  mockDelete.mockResolvedValue(undefined);
136
169
  const { deleteTodoAction } = await import("../actions/todos.action");
137
170
 
138
171
  // Act
139
- await deleteTodoAction("1");
172
+ const result = await deleteTodoAction("1");
140
173
 
141
174
  // Assert
175
+ expect(result.status).toBe("success");
142
176
  expect(mockDelete).toHaveBeenCalled();
143
177
  expect(mockRevalidatePath).toHaveBeenCalledWith("/");
144
178
  });
179
+
180
+ it("retorna error si la eliminación lanza una excepción", async () => {
181
+ // Arrange
182
+ mockDelete.mockRejectedValue(new Error("DB error"));
183
+ const { deleteTodoAction } = await import("../actions/todos.action");
184
+
185
+ // Act
186
+ const result = await deleteTodoAction("1");
187
+
188
+ // Assert
189
+ expect(result.status).toBe("error");
190
+ });
145
191
  });
@@ -6,30 +6,48 @@ import { revalidatePath } from "next/cache";
6
6
  import { db } from "@/shared/db";
7
7
  import { todos } from "@/shared/db/schema";
8
8
  import { createClient } from "@/shared/lib/supabase/server";
9
+ import type { ActionResult } from "@/shared/lib/action-result";
9
10
 
10
- export async function addTodoAction(formData: FormData): Promise<void> {
11
+ export async function addTodoAction(formData: FormData): Promise<ActionResult> {
11
12
  const title = formData.get("title");
12
- if (!title || typeof title !== "string" || title.trim() === "") return;
13
+ if (!title || typeof title !== "string" || title.trim() === "") {
14
+ return { status: "error", message: "El título no puede estar vacío" };
15
+ }
13
16
 
14
17
  const supabase = await createClient();
15
18
  const {
16
19
  data: { user },
17
20
  } = await supabase.auth.getUser();
18
- if (!user) return;
19
-
20
- await db.insert(todos).values({ userId: user.id, title: title.trim() });
21
- revalidatePath("/");
21
+ if (!user) return { status: "error", message: "No autenticado" };
22
+
23
+ try {
24
+ await db.insert(todos).values({ userId: user.id, title: title.trim() });
25
+ revalidatePath("/");
26
+ return { status: "success", message: "Tarea agregada" };
27
+ } catch {
28
+ return { status: "error", message: "Error al agregar la tarea" };
29
+ }
22
30
  }
23
31
 
24
- export async function toggleTodoAction(id: string): Promise<void> {
25
- const [todo] = await db.select().from(todos).where(eq(todos.id, id));
26
- if (!todo) return;
27
-
28
- await db.update(todos).set({ completed: !todo.completed }).where(eq(todos.id, id));
29
- revalidatePath("/");
32
+ export async function toggleTodoAction(id: string): Promise<ActionResult> {
33
+ try {
34
+ const [todo] = await db.select().from(todos).where(eq(todos.id, id));
35
+ if (!todo) return { status: "error", message: "Tarea no encontrada" };
36
+
37
+ await db.update(todos).set({ completed: !todo.completed }).where(eq(todos.id, id));
38
+ revalidatePath("/");
39
+ return { status: "success", message: "Tarea actualizada" };
40
+ } catch {
41
+ return { status: "error", message: "Error al actualizar la tarea" };
42
+ }
30
43
  }
31
44
 
32
- export async function deleteTodoAction(id: string): Promise<void> {
33
- await db.delete(todos).where(eq(todos.id, id));
34
- revalidatePath("/");
45
+ export async function deleteTodoAction(id: string): Promise<ActionResult> {
46
+ try {
47
+ await db.delete(todos).where(eq(todos.id, id));
48
+ revalidatePath("/");
49
+ return { status: "success", message: "Tarea eliminada" };
50
+ } catch {
51
+ return { status: "error", message: "Error al eliminar la tarea" };
52
+ }
35
53
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useRef, useTransition } from "react";
4
+ import { toast } from "sonner";
4
5
 
5
6
  import { addTodoAction } from "../actions/todos.action";
6
7
 
@@ -14,8 +15,13 @@ export function AddTodoForm() {
14
15
  e.preventDefault();
15
16
  const formData = new FormData(e.currentTarget);
16
17
  startTransition(async () => {
17
- await addTodoAction(formData);
18
- if (inputRef.current) inputRef.current.value = "";
18
+ const result = await addTodoAction(formData);
19
+ if (result.status === "success") {
20
+ toast.success(result.message);
21
+ if (inputRef.current) inputRef.current.value = "";
22
+ } else {
23
+ toast.error(result.message);
24
+ }
19
25
  });
20
26
  }
21
27
 
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useTransition } from "react";
4
+ import { toast } from "sonner";
4
5
 
5
6
  import { deleteTodoAction, toggleTodoAction } from "../actions/todos.action";
6
7
 
@@ -35,7 +36,11 @@ export function TodoList({ items }: TodoListProps) {
35
36
  checked={todo.completed}
36
37
  disabled={isPending}
37
38
  onChange={() => {
38
- startTransition(() => { void toggleTodoAction(todo.id); });
39
+ startTransition(async () => {
40
+ const result = await toggleTodoAction(todo.id);
41
+ if (result.status === "error") toast.error(result.message);
42
+ else toast.success(result.message);
43
+ });
39
44
  }}
40
45
  aria-label={`Marcar "${todo.title}" como completada`}
41
46
  />
@@ -48,7 +53,11 @@ export function TodoList({ items }: TodoListProps) {
48
53
  type="button"
49
54
  disabled={isPending}
50
55
  onClick={() => {
51
- startTransition(() => { void deleteTodoAction(todo.id); });
56
+ startTransition(async () => {
57
+ const result = await deleteTodoAction(todo.id);
58
+ if (result.status === "error") toast.error(result.message);
59
+ else toast.success(result.message);
60
+ });
52
61
  }}
53
62
  className="rounded px-2 py-1 text-sm text-red-500 hover:bg-red-50"
54
63
  aria-label={`Eliminar "${todo.title}"`}
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { useState } from "react";
5
+ import { Toaster } from "sonner";
5
6
 
6
7
  export function Providers({ children }: { children: React.ReactNode }) {
7
8
  const [queryClient] = useState(
@@ -15,5 +16,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
15
16
  })
16
17
  );
17
18
 
18
- return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
19
+ return (
20
+ <QueryClientProvider client={queryClient}>
21
+ {children}
22
+ <Toaster />
23
+ </QueryClientProvider>
24
+ );
19
25
  }
@@ -0,0 +1,3 @@
1
+ export type ActionResult =
2
+ | { status: "success"; message: string }
3
+ | { status: "error"; message: string };