nextjs-hackathon-stack 0.1.19 → 0.1.20

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
@@ -165,7 +165,7 @@ async function scaffold(projectName, skipInstall) {
165
165
  console.log(` ${pc2.yellow("Edita .env.local y completa tus credenciales:")}`);
166
166
  console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_URL")} \u2014 supabase.com > Project Settings > API`);
167
167
  console.log(` ${pc2.dim("NEXT_PUBLIC_SUPABASE_ANON_KEY")} \u2014 supabase.com > Project Settings > API`);
168
- console.log(` ${pc2.dim("DATABASE_URL")} \u2014 supabase.com > Project Settings > Database`);
168
+ console.log(` ${pc2.dim("DATABASE_URL")} \u2014 supabase.com > Project Settings > Database > Connection string (Session mode)`);
169
169
  console.log(` ${pc2.cyan("pnpm db:migrate")} ${pc2.dim("\u2014 aplica las migraciones a la base de datos")}`);
170
170
  console.log(` ${pc2.cyan("pnpm dev")}
171
171
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextjs-hackathon-stack",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Scaffold a full-stack Next.js hackathon starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@ Full-stack Next.js 15 hackathon starter.
27
27
  # Supabase — https://supabase.com > Project Settings > API
28
28
  NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
29
29
  NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
30
- DATABASE_URL=postgresql://postgres:[password]@db.your-project-id.supabase.co:5432/postgres
30
+ DATABASE_URL=postgresql://postgres.your-project-id:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
31
31
 
32
32
  # AI — Vercel AI Gateway (default: MiniMax M2.1)
33
33
  # Create gateway at: https://vercel.com > AI > Gateways
@@ -67,7 +67,7 @@ pnpm db:migrate # Apply migrations
67
67
  |---|---|
68
68
  | `NEXT_PUBLIC_SUPABASE_URL` | Supabase > Project Settings > API |
69
69
  | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase > Project Settings > API |
70
- | `DATABASE_URL` | Supabase > Project Settings > Database > URI |
70
+ | `DATABASE_URL` | Supabase > Project Settings > Database > Connection string > Session mode |
71
71
  | `AI_GATEWAY_URL` | Vercel > AI > Gateways |
72
72
  | `AI_API_KEY` | Your AI provider API key |
73
73
  | `NEXT_PUBLIC_APP_URL` | Your deployment URL (default: `http://localhost:3000`) |
@@ -6,8 +6,8 @@
6
6
  NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
7
7
  NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
8
8
 
9
- # Supabase DB — https://supabase.com > Project Settings > Database > Connection string (URI)
10
- DATABASE_URL=postgresql://postgres:[password]@db.your-project-id.supabase.co:5432/postgres
9
+ # Supabase DB — https://supabase.com > Project Settings > Database > Connection string > Session mode (URI)
10
+ DATABASE_URL=postgresql://postgres.your-project-id:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
11
11
 
12
12
  # =============================================================================
13
13
  # OPTIONAL
@@ -36,7 +36,8 @@
36
36
  "tailwind-merge": "^2",
37
37
  "class-variance-authority": "^0.7",
38
38
  "@radix-ui/react-slot": "^1",
39
- "@radix-ui/react-label": "^2"
39
+ "@radix-ui/react-label": "^2",
40
+ "lucide-react": "^0.475"
40
41
  },
41
42
  "devDependencies": {
42
43
  "typescript": "^5",
@@ -80,8 +80,8 @@ export function LoginForm() {
80
80
  )}
81
81
  </div>
82
82
 
83
- <Button type="submit" className="w-full" disabled={isPending}>
84
- {isPending ? "Iniciando sesión…" : "Iniciar sesión"}
83
+ <Button type="submit" className="w-full" loading={isPending}>
84
+ Iniciar sesión
85
85
  </Button>
86
86
  </form>
87
87
  </CardContent>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useRef, useTransition } from "react";
4
4
  import { addTodoAction } from "../actions/todos.action";
5
+ import { Button } from "@/shared/components/ui/button";
5
6
 
6
7
  export function AddTodoForm() {
7
8
  const [isPending, startTransition] = useTransition();
@@ -28,13 +29,9 @@ export function AddTodoForm() {
28
29
  className="flex-1 rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
29
30
  aria-label="Nueva tarea"
30
31
  />
31
- <button
32
- type="submit"
33
- disabled={isPending}
34
- className="rounded bg-black px-4 py-2 text-sm text-white hover:bg-gray-800 disabled:opacity-50"
35
- >
36
- {isPending ? "Agregando…" : "Agregar"}
37
- </button>
32
+ <Button type="submit" loading={isPending}>
33
+ Agregar
34
+ </Button>
38
35
  </form>
39
36
  );
40
37
  }
@@ -3,6 +3,7 @@
3
3
  import { useTransition } from "react";
4
4
  import { toggleTodoAction, deleteTodoAction } from "../actions/todos.action";
5
5
  import type { SelectTodo } from "@/shared/db/schema";
6
+ import { Spinner } from "@/shared/components/ui/spinner";
6
7
 
7
8
  interface TodoListProps {
8
9
  items: SelectTodo[];
@@ -39,6 +40,7 @@ export function TodoList({ items }: TodoListProps) {
39
40
  <span className={todo.completed ? "line-through text-gray-400" : ""}>
40
41
  {todo.title}
41
42
  </span>
43
+ {isPending && <Spinner className="size-4 text-gray-400" />}
42
44
  </label>
43
45
  <button
44
46
  type="button"
@@ -49,4 +49,31 @@ describe("Button", () => {
49
49
  expect(btn).toBeInTheDocument();
50
50
  expect(btn.className).toContain("destructive");
51
51
  });
52
+
53
+ it("is disabled and renders spinner SVG when loading is true", () => {
54
+ // Arrange + Act
55
+ render(<Button loading>Save</Button>);
56
+
57
+ // Assert
58
+ const btn = screen.getByRole("button");
59
+ expect(btn).toBeDisabled();
60
+ expect(btn.querySelector("svg")).toBeInTheDocument();
61
+ });
62
+
63
+ it("renders children alongside spinner when loading is true", () => {
64
+ // Arrange + Act
65
+ render(<Button loading>Save</Button>);
66
+
67
+ // Assert
68
+ expect(screen.getByText("Save")).toBeInTheDocument();
69
+ });
70
+
71
+ it("does not render spinner when loading is false", () => {
72
+ // Arrange + Act
73
+ render(<Button>Save</Button>);
74
+
75
+ // Assert
76
+ const btn = screen.getByRole("button");
77
+ expect(btn.querySelector("svg")).not.toBeInTheDocument();
78
+ });
52
79
  });
@@ -0,0 +1,22 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, it, expect } from "vitest";
3
+
4
+ import { Spinner } from "../components/ui/spinner";
5
+
6
+ describe("Spinner", () => {
7
+ it("renders with animate-spin class", () => {
8
+ // Arrange + Act
9
+ const { container } = render(<Spinner />);
10
+
11
+ // Assert
12
+ expect(container.firstChild).toHaveClass("animate-spin");
13
+ });
14
+
15
+ it("applies custom className alongside animate-spin", () => {
16
+ // Arrange + Act
17
+ const { container } = render(<Spinner className="size-4" />);
18
+
19
+ // Assert
20
+ expect(container.firstChild).toHaveClass("animate-spin", "size-4");
21
+ });
22
+ });
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"
3
3
  import * as React from "react"
4
4
 
5
5
  import { cn } from "@/shared/lib/utils"
6
+ import { Spinner } from "./spinner"
6
7
 
7
8
  const buttonVariants = cva(
8
9
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -40,10 +41,14 @@ function Button({
40
41
  variant,
41
42
  size,
42
43
  asChild = false,
44
+ loading = false,
45
+ children,
46
+ disabled,
43
47
  ...props
44
48
  }: React.ComponentProps<"button"> &
45
49
  VariantProps<typeof buttonVariants> & {
46
50
  asChild?: boolean
51
+ loading?: boolean
47
52
  }) {
48
53
  const Comp = asChild ? Slot : "button"
49
54
 
@@ -51,8 +56,11 @@ function Button({
51
56
  <Comp
52
57
  data-slot="button"
53
58
  className={cn(buttonVariants({ variant, size, className }))}
59
+ disabled={disabled ?? loading}
54
60
  {...props}
55
- />
61
+ >
62
+ {asChild ? children : <>{loading && <Spinner className="size-4" />}{children}</>}
63
+ </Comp>
56
64
  )
57
65
  }
58
66
 
@@ -0,0 +1,6 @@
1
+ import { LoaderCircle } from "lucide-react";
2
+ import { cn } from "@/shared/lib/utils";
3
+
4
+ export function Spinner({ className }: { className?: string }) {
5
+ return <LoaderCircle className={cn("animate-spin", className)} />;
6
+ }