nextjs-hackathon-stack 0.1.28 → 0.1.30

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/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.28",
3
+ "version": "0.1.30",
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
@@ -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
  });
@@ -5,31 +5,49 @@ import { revalidatePath } from "next/cache";
5
5
 
6
6
  import { db } from "@/shared/db";
7
7
  import { todos } from "@/shared/db/schema";
8
+ import type { ActionResult } from "@/shared/lib/action-result";
8
9
  import { createClient } from "@/shared/lib/supabase/server";
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 };