ar-saas 0.1.0
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 +62 -0
- package/dist/cli.js +67 -0
- package/dist/generator.js +242 -0
- package/dist/index.js +13 -0
- package/dist/license.js +71 -0
- package/package.json +46 -0
- package/templates/backend/.env.example +67 -0
- package/templates/backend/.prettierrc +4 -0
- package/templates/backend/README.md +168 -0
- package/templates/backend/eslint.config.mjs +35 -0
- package/templates/backend/nest-cli.json +8 -0
- package/templates/backend/package-lock.json +10979 -0
- package/templates/backend/package.json +88 -0
- package/templates/backend/src/app.controller.spec.ts +24 -0
- package/templates/backend/src/app.controller.ts +15 -0
- package/templates/backend/src/app.module.ts +40 -0
- package/templates/backend/src/app.service.ts +11 -0
- package/templates/backend/src/common/base/base.repository.ts +221 -0
- package/templates/backend/src/common/base/base.schema.ts +24 -0
- package/templates/backend/src/common/decorators/cookie.decorator.ts +9 -0
- package/templates/backend/src/common/decorators/current-user.decorator.ts +20 -0
- package/templates/backend/src/common/decorators/workspace-id.decorator.ts +14 -0
- package/templates/backend/src/common/filters/global-exception.filter.ts +61 -0
- package/templates/backend/src/common/guards/jwt-auth.guard.ts +5 -0
- package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +45 -0
- package/templates/backend/src/main.ts +51 -0
- package/templates/backend/src/modules/auth/auth.controller.ts +158 -0
- package/templates/backend/src/modules/auth/auth.module.ts +20 -0
- package/templates/backend/src/modules/auth/auth.service.ts +257 -0
- package/templates/backend/src/modules/auth/dto/forgot-password.dto.ts +9 -0
- package/templates/backend/src/modules/auth/dto/login.dto.ts +14 -0
- package/templates/backend/src/modules/auth/dto/refresh-token.dto.ts +12 -0
- package/templates/backend/src/modules/auth/dto/register.dto.ts +26 -0
- package/templates/backend/src/modules/auth/dto/reset-password.dto.ts +16 -0
- package/templates/backend/src/modules/auth/dto/verify-email.dto.ts +9 -0
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +43 -0
- package/templates/backend/src/modules/mail/mail.module.ts +9 -0
- package/templates/backend/src/modules/mail/mail.service.ts +141 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +54 -0
- package/templates/backend/src/modules/users/users.module.ts +14 -0
- package/templates/backend/src/modules/users/users.repository.ts +51 -0
- package/templates/backend/src/modules/users/users.service.ts +104 -0
- package/templates/backend/src/modules/workspaces/schemas/workspace.schema.ts +26 -0
- package/templates/backend/src/modules/workspaces/workspaces.module.ts +16 -0
- package/templates/backend/src/modules/workspaces/workspaces.repository.ts +34 -0
- package/templates/backend/src/modules/workspaces/workspaces.service.ts +42 -0
- package/templates/backend/test/app.e2e-spec.ts +25 -0
- package/templates/backend/test/jest-e2e.json +9 -0
- package/templates/backend/tsconfig.build.json +4 -0
- package/templates/backend/tsconfig.json +26 -0
- package/templates/frontend/.env.local.example +1 -0
- package/templates/frontend/components.json +20 -0
- package/templates/frontend/eslint.config.mjs +14 -0
- package/templates/frontend/next.config.ts +5 -0
- package/templates/frontend/package-lock.json +6722 -0
- package/templates/frontend/package.json +40 -0
- package/templates/frontend/postcss.config.mjs +7 -0
- package/templates/frontend/src/app/(auth)/forgot-password/page.tsx +84 -0
- package/templates/frontend/src/app/(auth)/layout.tsx +28 -0
- package/templates/frontend/src/app/(auth)/login/page.tsx +111 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +119 -0
- package/templates/frontend/src/app/(auth)/reset-password/page.tsx +120 -0
- package/templates/frontend/src/app/(auth)/verify-email/page.tsx +78 -0
- package/templates/frontend/src/app/(dashboard)/dashboard/page.tsx +36 -0
- package/templates/frontend/src/app/(dashboard)/layout.tsx +59 -0
- package/templates/frontend/src/app/globals.css +81 -0
- package/templates/frontend/src/app/layout.tsx +26 -0
- package/templates/frontend/src/app/page.tsx +5 -0
- package/templates/frontend/src/app/setup/page.tsx +278 -0
- package/templates/frontend/src/components/ui/button.tsx +52 -0
- package/templates/frontend/src/components/ui/card.tsx +50 -0
- package/templates/frontend/src/components/ui/form.tsx +158 -0
- package/templates/frontend/src/components/ui/input.tsx +21 -0
- package/templates/frontend/src/components/ui/label.tsx +22 -0
- package/templates/frontend/src/components/ui/toast.tsx +109 -0
- package/templates/frontend/src/components/ui/toaster.tsx +30 -0
- package/templates/frontend/src/hooks/use-toast.ts +116 -0
- package/templates/frontend/src/lib/api/auth.ts +39 -0
- package/templates/frontend/src/lib/api/client.ts +66 -0
- package/templates/frontend/src/lib/hooks/use-auth.ts +1 -0
- package/templates/frontend/src/lib/utils.ts +6 -0
- package/templates/frontend/src/providers/auth-provider.tsx +60 -0
- package/templates/frontend/src/types/api.ts +12 -0
- package/templates/frontend/src/types/auth.ts +27 -0
- package/templates/frontend/tsconfig.json +23 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { useRouter } from 'next/navigation'
|
|
6
|
+
import { LayoutDashboard, LogOut } from 'lucide-react'
|
|
7
|
+
import { useAuth } from '@/lib/hooks/use-auth'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
|
|
10
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
11
|
+
const { user, isAuthenticated, isLoading, logout } = useAuth()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!isLoading && !isAuthenticated) {
|
|
16
|
+
router.replace('/login')
|
|
17
|
+
}
|
|
18
|
+
}, [isAuthenticated, isLoading, router])
|
|
19
|
+
|
|
20
|
+
if (isLoading) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
23
|
+
<div className="text-muted-foreground text-sm">Cargando...</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!isAuthenticated) return null
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="min-h-screen flex">
|
|
32
|
+
<aside className="w-60 shrink-0 border-r flex flex-col bg-card">
|
|
33
|
+
<div className="p-4 border-b">
|
|
34
|
+
<p className="text-sm font-semibold truncate">{user?.name}</p>
|
|
35
|
+
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
|
|
36
|
+
</div>
|
|
37
|
+
<nav className="flex-1 p-3 space-y-1">
|
|
38
|
+
<Button asChild variant="ghost" className="w-full justify-start">
|
|
39
|
+
<Link href="/dashboard">
|
|
40
|
+
<LayoutDashboard className="mr-2 size-4" />
|
|
41
|
+
Dashboard
|
|
42
|
+
</Link>
|
|
43
|
+
</Button>
|
|
44
|
+
</nav>
|
|
45
|
+
<div className="p-3 border-t">
|
|
46
|
+
<Button
|
|
47
|
+
variant="ghost"
|
|
48
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
49
|
+
onClick={logout}
|
|
50
|
+
>
|
|
51
|
+
<LogOut className="mr-2 size-4" />
|
|
52
|
+
Cerrar sesión
|
|
53
|
+
</Button>
|
|
54
|
+
</div>
|
|
55
|
+
</aside>
|
|
56
|
+
<main className="flex-1 p-8 overflow-auto">{children}</main>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme inline {
|
|
4
|
+
--color-background: var(--background);
|
|
5
|
+
--color-foreground: var(--foreground);
|
|
6
|
+
--color-card: var(--card);
|
|
7
|
+
--color-card-foreground: var(--card-foreground);
|
|
8
|
+
--color-popover: var(--popover);
|
|
9
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
10
|
+
--color-primary: var(--primary);
|
|
11
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
12
|
+
--color-secondary: var(--secondary);
|
|
13
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
14
|
+
--color-muted: var(--muted);
|
|
15
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
16
|
+
--color-accent: var(--accent);
|
|
17
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
18
|
+
--color-destructive: var(--destructive);
|
|
19
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
20
|
+
--color-border: var(--border);
|
|
21
|
+
--color-input: var(--input);
|
|
22
|
+
--color-ring: var(--ring);
|
|
23
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
24
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
25
|
+
--radius-lg: var(--radius);
|
|
26
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
:root {
|
|
30
|
+
--background: oklch(1 0 0);
|
|
31
|
+
--foreground: oklch(0.145 0 0);
|
|
32
|
+
--card: oklch(1 0 0);
|
|
33
|
+
--card-foreground: oklch(0.145 0 0);
|
|
34
|
+
--popover: oklch(1 0 0);
|
|
35
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
36
|
+
--primary: oklch(0.205 0 0);
|
|
37
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
38
|
+
--secondary: oklch(0.97 0 0);
|
|
39
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
40
|
+
--muted: oklch(0.97 0 0);
|
|
41
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
42
|
+
--accent: oklch(0.97 0 0);
|
|
43
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
44
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
45
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
46
|
+
--border: oklch(0.922 0 0);
|
|
47
|
+
--input: oklch(0.922 0 0);
|
|
48
|
+
--ring: oklch(0.708 0 0);
|
|
49
|
+
--radius: 0.625rem;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.dark {
|
|
53
|
+
--background: oklch(0.145 0 0);
|
|
54
|
+
--foreground: oklch(0.985 0 0);
|
|
55
|
+
--card: oklch(0.205 0 0);
|
|
56
|
+
--card-foreground: oklch(0.985 0 0);
|
|
57
|
+
--popover: oklch(0.205 0 0);
|
|
58
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
59
|
+
--primary: oklch(0.985 0 0);
|
|
60
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
61
|
+
--secondary: oklch(0.269 0 0);
|
|
62
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
63
|
+
--muted: oklch(0.269 0 0);
|
|
64
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
65
|
+
--accent: oklch(0.269 0 0);
|
|
66
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
67
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
68
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
69
|
+
--border: oklch(1 0 0 / 10%);
|
|
70
|
+
--input: oklch(1 0 0 / 15%);
|
|
71
|
+
--ring: oklch(0.556 0 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
* {
|
|
75
|
+
border-color: var(--border);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
body {
|
|
79
|
+
background-color: var(--background);
|
|
80
|
+
color: var(--foreground);
|
|
81
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { Geist, Geist_Mono } from 'next/font/google'
|
|
3
|
+
import './globals.css'
|
|
4
|
+
import { AuthProvider } from '@/providers/auth-provider'
|
|
5
|
+
import { Toaster } from '@/components/ui/toaster'
|
|
6
|
+
|
|
7
|
+
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
|
|
8
|
+
const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] })
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: 'create-saas-ar',
|
|
12
|
+
description: 'Tu SaaS listo para Argentina',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<html lang="es">
|
|
18
|
+
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
|
19
|
+
<AuthProvider>
|
|
20
|
+
{children}
|
|
21
|
+
<Toaster />
|
|
22
|
+
</AuthProvider>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
|
|
6
|
+
const BACKEND_REQUIRED = [
|
|
7
|
+
{
|
|
8
|
+
key: 'MONGODB_URI',
|
|
9
|
+
example: 'mongodb://localhost:27017/mi-saas',
|
|
10
|
+
desc: 'URI de conexión a MongoDB. Para desarrollo local instalar MongoDB o usar Atlas.',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
key: 'JWT_ACCESS_SECRET',
|
|
14
|
+
example: '$(openssl rand -hex 64)',
|
|
15
|
+
desc: 'Secreto para firmar access tokens. Generar con OpenSSL.',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: 'JWT_REFRESH_SECRET',
|
|
19
|
+
example: '$(openssl rand -hex 64)',
|
|
20
|
+
desc: 'Secreto para firmar refresh tokens. Usar un valor distinto al anterior.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
key: 'RESEND_API_KEY',
|
|
24
|
+
example: 're_xxxxxxxxxxxxxxxx',
|
|
25
|
+
desc: 'API Key de Resend para enviar emails. Crear cuenta en resend.com.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'RESEND_FROM_EMAIL',
|
|
29
|
+
example: 'noreply@tudominio.com',
|
|
30
|
+
desc: 'Email remitente. Debe estar verificado en tu cuenta de Resend.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'APP_URL',
|
|
34
|
+
example: 'http://localhost:3001',
|
|
35
|
+
desc: 'URL del frontend. Se usa en los links de los emails de verificación y reset.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'CORS_ORIGINS',
|
|
39
|
+
example: 'http://localhost:3001',
|
|
40
|
+
desc: 'URL del frontend separada por coma. Debe coincidir con APP_URL.',
|
|
41
|
+
},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const BACKEND_OPTIONAL = [
|
|
45
|
+
{ key: 'PORT', example: '3000', desc: 'Puerto del servidor (default: 3000).' },
|
|
46
|
+
{ key: 'JWT_ACCESS_EXPIRES_IN', example: '15m', desc: 'Duración del access token (default: 15m).' },
|
|
47
|
+
{ key: 'JWT_REFRESH_EXPIRES_IN', example: '7d', desc: 'Duración del refresh token (default: 7d).' },
|
|
48
|
+
{ key: 'RESEND_FROM_NAME', example: '"Mi SaaS"', desc: 'Nombre del remitente que ven los destinatarios.' },
|
|
49
|
+
{ key: 'COOKIE_SECRET', example: '$(openssl rand -hex 32)', desc: 'Secreto para firmar cookies.' },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
const FRONTEND_REQUIRED = [
|
|
53
|
+
{
|
|
54
|
+
key: 'NEXT_PUBLIC_API_URL',
|
|
55
|
+
example: 'http://localhost:3000',
|
|
56
|
+
desc: 'URL base del backend. Debe coincidir con el PORT del backend.',
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
function EnvVar({ varKey, example, desc }: { varKey: string; example: string; desc: string }) {
|
|
61
|
+
const [copied, setCopied] = useState(false)
|
|
62
|
+
|
|
63
|
+
const copy = () => {
|
|
64
|
+
navigator.clipboard.writeText(`${varKey}=${example}`)
|
|
65
|
+
setCopied(true)
|
|
66
|
+
setTimeout(() => setCopied(false), 1500)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="py-3 border-b border-zinc-100 last:border-0">
|
|
71
|
+
<div className="flex items-start justify-between gap-4">
|
|
72
|
+
<div className="flex-1 min-w-0">
|
|
73
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
74
|
+
<code className="text-sm font-mono font-semibold text-zinc-900">{varKey}</code>
|
|
75
|
+
</div>
|
|
76
|
+
<p className="text-xs text-zinc-500 leading-relaxed">{desc}</p>
|
|
77
|
+
<code className="text-xs text-zinc-400 font-mono">{example}</code>
|
|
78
|
+
</div>
|
|
79
|
+
<button
|
|
80
|
+
onClick={copy}
|
|
81
|
+
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-700 transition-colors px-2 py-1 rounded border border-zinc-200 hover:border-zinc-300"
|
|
82
|
+
>
|
|
83
|
+
{copied ? '✓' : 'copiar'}
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function CodeBlock({ children }: { children: string }) {
|
|
91
|
+
const [copied, setCopied] = useState(false)
|
|
92
|
+
|
|
93
|
+
const copy = () => {
|
|
94
|
+
navigator.clipboard.writeText(children)
|
|
95
|
+
setCopied(true)
|
|
96
|
+
setTimeout(() => setCopied(false), 1500)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="relative group">
|
|
101
|
+
<pre className="bg-zinc-950 text-zinc-100 rounded-lg px-4 py-3 text-sm font-mono overflow-x-auto">
|
|
102
|
+
{children}
|
|
103
|
+
</pre>
|
|
104
|
+
<button
|
|
105
|
+
onClick={copy}
|
|
106
|
+
className="absolute top-2 right-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors bg-zinc-800 px-2 py-0.5 rounded"
|
|
107
|
+
>
|
|
108
|
+
{copied ? '✓' : 'copiar'}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default function SetupPage() {
|
|
115
|
+
const router = useRouter()
|
|
116
|
+
const [dismissed, setDismissed] = useState(false)
|
|
117
|
+
const [mounted, setMounted] = useState(false)
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setMounted(true)
|
|
121
|
+
if (localStorage.getItem('setup_dismissed') === 'true') {
|
|
122
|
+
router.replace('/login')
|
|
123
|
+
}
|
|
124
|
+
}, [router])
|
|
125
|
+
|
|
126
|
+
const handleDone = () => {
|
|
127
|
+
localStorage.setItem('setup_dismissed', 'true')
|
|
128
|
+
setDismissed(true)
|
|
129
|
+
router.push('/login')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!mounted || dismissed) return null
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="min-h-screen bg-zinc-50">
|
|
136
|
+
<div className="max-w-3xl mx-auto px-4 py-12">
|
|
137
|
+
|
|
138
|
+
{/* Header */}
|
|
139
|
+
<div className="mb-10">
|
|
140
|
+
<div className="inline-flex items-center gap-2 bg-zinc-900 text-white text-xs font-mono px-3 py-1.5 rounded-full mb-4">
|
|
141
|
+
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
|
|
142
|
+
create-saas-ar
|
|
143
|
+
</div>
|
|
144
|
+
<h1 className="text-3xl font-bold text-zinc-900 mb-2">
|
|
145
|
+
Tu proyecto está listo
|
|
146
|
+
</h1>
|
|
147
|
+
<p className="text-zinc-500 text-lg">
|
|
148
|
+
Completá la configuración antes de empezar a desarrollar.
|
|
149
|
+
Son 5 minutos y después tenés todo andando.
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Backend */}
|
|
154
|
+
<section className="mb-8">
|
|
155
|
+
<div className="flex items-center gap-3 mb-4">
|
|
156
|
+
<div className="w-7 h-7 rounded-full bg-zinc-900 text-white text-xs font-bold flex items-center justify-center">1</div>
|
|
157
|
+
<h2 className="text-lg font-semibold text-zinc-900">Backend — configurar <code className="text-sm bg-zinc-100 px-1.5 py-0.5 rounded">.env</code></h2>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="bg-white border border-zinc-200 rounded-xl overflow-hidden mb-4">
|
|
161
|
+
<div className="px-4 py-2.5 bg-zinc-50 border-b border-zinc-100">
|
|
162
|
+
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Variables requeridas</span>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="px-4">
|
|
165
|
+
{BACKEND_REQUIRED.map((v) => (
|
|
166
|
+
<EnvVar key={v.key} varKey={v.key} example={v.example} desc={v.desc} />
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<details className="bg-white border border-zinc-200 rounded-xl overflow-hidden mb-4">
|
|
172
|
+
<summary className="px-4 py-2.5 bg-zinc-50 border-b border-zinc-100 cursor-pointer text-xs font-semibold text-zinc-500 uppercase tracking-wider hover:text-zinc-700">
|
|
173
|
+
Variables opcionales
|
|
174
|
+
</summary>
|
|
175
|
+
<div className="px-4">
|
|
176
|
+
{BACKEND_OPTIONAL.map((v) => (
|
|
177
|
+
<EnvVar key={v.key} varKey={v.key} example={v.example} desc={v.desc} />
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</details>
|
|
181
|
+
|
|
182
|
+
<div className="bg-white border border-zinc-200 rounded-xl overflow-hidden">
|
|
183
|
+
<div className="px-4 py-2.5 bg-zinc-50 border-b border-zinc-100">
|
|
184
|
+
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Iniciar el backend</span>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="p-4 space-y-3">
|
|
187
|
+
<CodeBlock>{`cd backend
|
|
188
|
+
cp .env.example .env
|
|
189
|
+
# Completar las variables en .env
|
|
190
|
+
npm install
|
|
191
|
+
npm run start:dev`}</CodeBlock>
|
|
192
|
+
<p className="text-xs text-zinc-400">
|
|
193
|
+
El servidor levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3000</code>.
|
|
194
|
+
Swagger en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3000/api/docs</code>.
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</section>
|
|
199
|
+
|
|
200
|
+
{/* Frontend */}
|
|
201
|
+
<section className="mb-8">
|
|
202
|
+
<div className="flex items-center gap-3 mb-4">
|
|
203
|
+
<div className="w-7 h-7 rounded-full bg-zinc-900 text-white text-xs font-bold flex items-center justify-center">2</div>
|
|
204
|
+
<h2 className="text-lg font-semibold text-zinc-900">Frontend — configurar <code className="text-sm bg-zinc-100 px-1.5 py-0.5 rounded">.env.local</code></h2>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div className="bg-white border border-zinc-200 rounded-xl overflow-hidden mb-4">
|
|
208
|
+
<div className="px-4 py-2.5 bg-zinc-50 border-b border-zinc-100">
|
|
209
|
+
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Variables requeridas</span>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="px-4">
|
|
212
|
+
{FRONTEND_REQUIRED.map((v) => (
|
|
213
|
+
<EnvVar key={v.key} varKey={v.key} example={v.example} desc={v.desc} />
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div className="bg-white border border-zinc-200 rounded-xl overflow-hidden">
|
|
219
|
+
<div className="px-4 py-2.5 bg-zinc-50 border-b border-zinc-100">
|
|
220
|
+
<span className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Iniciar el frontend</span>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="p-4 space-y-3">
|
|
223
|
+
<CodeBlock>{`cd frontend
|
|
224
|
+
cp .env.local.example .env.local
|
|
225
|
+
# Ajustar NEXT_PUBLIC_API_URL si cambiaste el PORT del backend
|
|
226
|
+
npm install
|
|
227
|
+
npm run dev`}</CodeBlock>
|
|
228
|
+
<p className="text-xs text-zinc-400">
|
|
229
|
+
El frontend levanta en <code className="bg-zinc-100 px-1 py-0.5 rounded">http://localhost:3001</code> (o el puerto que indique Next.js).
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</section>
|
|
234
|
+
|
|
235
|
+
{/* Verificación */}
|
|
236
|
+
<section className="mb-10">
|
|
237
|
+
<div className="flex items-center gap-3 mb-4">
|
|
238
|
+
<div className="w-7 h-7 rounded-full bg-zinc-900 text-white text-xs font-bold flex items-center justify-center">3</div>
|
|
239
|
+
<h2 className="text-lg font-semibold text-zinc-900">Verificar que todo funciona</h2>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div className="bg-white border border-zinc-200 rounded-xl p-4 space-y-2">
|
|
243
|
+
{[
|
|
244
|
+
{ label: 'Backend corre sin errores', hint: 'npm run start:dev no muestra errores rojos' },
|
|
245
|
+
{ label: 'Swagger disponible', hint: 'http://localhost:3000/api/docs carga correctamente' },
|
|
246
|
+
{ label: 'MongoDB conectado', hint: 'El log dice "Connected to MongoDB"' },
|
|
247
|
+
{ label: 'Registro funciona', hint: 'POST /api/auth/register crea un usuario' },
|
|
248
|
+
{ label: 'Email de verificación llega', hint: 'Revisar spam si no aparece en inbox' },
|
|
249
|
+
].map((item) => (
|
|
250
|
+
<div key={item.label} className="flex items-start gap-3 py-1.5">
|
|
251
|
+
<div className="w-4 h-4 rounded border-2 border-zinc-300 mt-0.5 shrink-0" />
|
|
252
|
+
<div>
|
|
253
|
+
<p className="text-sm text-zinc-800 font-medium">{item.label}</p>
|
|
254
|
+
<p className="text-xs text-zinc-400">{item.hint}</p>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
</section>
|
|
260
|
+
|
|
261
|
+
{/* CTA */}
|
|
262
|
+
<div className="flex items-center justify-between">
|
|
263
|
+
<p className="text-xs text-zinc-400">
|
|
264
|
+
Esta pantalla no vuelve a aparecer. Podés volver desde{' '}
|
|
265
|
+
<code className="bg-zinc-100 px-1 py-0.5 rounded">/setup</code>.
|
|
266
|
+
</p>
|
|
267
|
+
<button
|
|
268
|
+
onClick={handleDone}
|
|
269
|
+
className="bg-zinc-900 hover:bg-zinc-700 text-white font-medium text-sm px-6 py-2.5 rounded-lg transition-colors"
|
|
270
|
+
>
|
|
271
|
+
Todo listo, ir al login →
|
|
272
|
+
</button>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*="size-"])]:size-4 shrink-0',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
12
|
+
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
|
13
|
+
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
|
14
|
+
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
|
15
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
16
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: 'h-9 px-4 py-2',
|
|
20
|
+
sm: 'h-8 rounded-md px-3 text-xs',
|
|
21
|
+
lg: 'h-10 rounded-md px-8',
|
|
22
|
+
icon: 'h-9 w-9',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: 'default',
|
|
27
|
+
size: 'default',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export interface ButtonProps
|
|
33
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
34
|
+
VariantProps<typeof buttonVariants> {
|
|
35
|
+
asChild?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
39
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
40
|
+
const Comp = asChild ? Slot : 'button'
|
|
41
|
+
return (
|
|
42
|
+
<Comp
|
|
43
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
44
|
+
ref={ref}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
Button.displayName = 'Button'
|
|
51
|
+
|
|
52
|
+
export { Button, buttonVariants }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div
|
|
7
|
+
ref={ref}
|
|
8
|
+
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
),
|
|
12
|
+
)
|
|
13
|
+
Card.displayName = 'Card'
|
|
14
|
+
|
|
15
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
16
|
+
({ className, ...props }, ref) => (
|
|
17
|
+
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
CardHeader.displayName = 'CardHeader'
|
|
21
|
+
|
|
22
|
+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
23
|
+
({ className, ...props }, ref) => (
|
|
24
|
+
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
CardTitle.displayName = 'CardTitle'
|
|
28
|
+
|
|
29
|
+
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
30
|
+
({ className, ...props }, ref) => (
|
|
31
|
+
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
CardDescription.displayName = 'CardDescription'
|
|
35
|
+
|
|
36
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
37
|
+
({ className, ...props }, ref) => (
|
|
38
|
+
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
CardContent.displayName = 'CardContent'
|
|
42
|
+
|
|
43
|
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
44
|
+
({ className, ...props }, ref) => (
|
|
45
|
+
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
CardFooter.displayName = 'CardFooter'
|
|
49
|
+
|
|
50
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|