nextjs-hackathon-stack 0.1.19 → 0.1.21
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 +1 -1
- package/package.json +1 -1
- package/template/README.md +2 -2
- package/template/_env.example +2 -2
- package/template/package.json.tmpl +3 -1
- package/template/src/features/auth/components/login-form.tsx +2 -2
- package/template/src/features/todos/components/add-todo-form.tsx +4 -7
- package/template/src/features/todos/components/todo-list.tsx +2 -0
- package/template/src/shared/__tests__/ui-button.test.tsx +27 -0
- package/template/src/shared/__tests__/ui-spinner.test.tsx +22 -0
- package/template/src/shared/components/ui/button.tsx +9 -1
- package/template/src/shared/components/ui/spinner.tsx +6 -0
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
package/template/README.md
CHANGED
|
@@ -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]@
|
|
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 >
|
|
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`) |
|
package/template/_env.example
CHANGED
|
@@ -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]@
|
|
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",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"@testing-library/jest-dom": "^6",
|
|
51
52
|
"@testing-library/user-event": "^14",
|
|
52
53
|
"@vitest/coverage-v8": "^3",
|
|
54
|
+
"jsdom": "^26",
|
|
53
55
|
"@playwright/test": "^1.49",
|
|
54
56
|
"tailwindcss": "^4",
|
|
55
57
|
"@tailwindcss/postcss": "^4",
|
|
@@ -80,8 +80,8 @@ export function LoginForm() {
|
|
|
80
80
|
)}
|
|
81
81
|
</div>
|
|
82
82
|
|
|
83
|
-
<Button type="submit" className="w-full"
|
|
84
|
-
|
|
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
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|