@imansi/templates 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/LICENSE +21 -0
- package/README.md +557 -0
- package/package.json +35 -0
- package/src/auth/ForgotPasswordPage.jsx +176 -0
- package/src/auth/LoginPage.jsx +265 -0
- package/src/auth/RegisterPage.jsx +302 -0
- package/src/auth/ResetPasswordPage.jsx +158 -0
- package/src/auth/Verify2FAPage.jsx +175 -0
- package/src/dashboard/BillingPage.jsx +345 -0
- package/src/dashboard/DashboardHome.jsx +246 -0
- package/src/dashboard/NotificationsPage.jsx +230 -0
- package/src/dashboard/ProfilePage.jsx +272 -0
- package/src/dashboard/SettingsPage.jsx +415 -0
- package/src/dashboard/UsersTablePage.jsx +317 -0
- package/src/extra/CareersPage.jsx +166 -0
- package/src/extra/ChangelogPage.jsx +127 -0
- package/src/extra/CheckoutPage.jsx +383 -0
- package/src/extra/ComingSoonPage.jsx +147 -0
- package/src/extra/ErrorPage.jsx +91 -0
- package/src/extra/InvoicePage.jsx +241 -0
- package/src/extra/LegalPage.jsx +115 -0
- package/src/extra/MaintenancePage.jsx +172 -0
- package/src/extra/OnboardingPage.jsx +197 -0
- package/src/extra/StatusPage.jsx +181 -0
- package/src/index.js +53 -0
- package/src/layouts/AuthLayout.jsx +104 -0
- package/src/layouts/DashboardLayout.jsx +249 -0
- package/src/layouts/MarketingLayout.jsx +184 -0
- package/src/marketing/AboutPage.jsx +189 -0
- package/src/marketing/BlogPage.jsx +182 -0
- package/src/marketing/BlogPostPage.jsx +192 -0
- package/src/marketing/ContactPage.jsx +546 -0
- package/src/marketing/DocsPage.jsx +256 -0
- package/src/marketing/HomePage.jsx +702 -0
- package/src/marketing/LandingPage.jsx +755 -0
- package/src/marketing/PricingPage.jsx +205 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Avatar,
|
|
4
|
+
AvatarFallback,
|
|
5
|
+
AvatarImage,
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
Card,
|
|
9
|
+
CardContent,
|
|
10
|
+
Checkbox,
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
DropdownMenuContent,
|
|
14
|
+
DropdownMenuItem,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
Input,
|
|
17
|
+
PaginationBar,
|
|
18
|
+
Table,
|
|
19
|
+
TableBody,
|
|
20
|
+
TableCell,
|
|
21
|
+
TableHead,
|
|
22
|
+
TableHeader,
|
|
23
|
+
TableRow,
|
|
24
|
+
Tabs,
|
|
25
|
+
TabsList,
|
|
26
|
+
TabsTrigger,
|
|
27
|
+
cn,
|
|
28
|
+
} from '@imansi/ui';
|
|
29
|
+
import { DashboardLayout } from '../layouts/DashboardLayout.jsx';
|
|
30
|
+
|
|
31
|
+
export function UsersTablePage({
|
|
32
|
+
brandName,
|
|
33
|
+
brandLogo,
|
|
34
|
+
navSections,
|
|
35
|
+
user,
|
|
36
|
+
userMenuItems,
|
|
37
|
+
notificationsCount,
|
|
38
|
+
onLogout,
|
|
39
|
+
|
|
40
|
+
title = 'Usuarios',
|
|
41
|
+
subtitle = 'Gestioná los usuarios de tu organización.',
|
|
42
|
+
addButtonText = 'Agregar usuario',
|
|
43
|
+
onAddClick,
|
|
44
|
+
|
|
45
|
+
// Tabs
|
|
46
|
+
tabs = [
|
|
47
|
+
{ value: 'all', label: 'Todos' },
|
|
48
|
+
{ value: 'active', label: 'Activos' },
|
|
49
|
+
{ value: 'pending', label: 'Pendientes' },
|
|
50
|
+
{ value: 'blocked', label: 'Bloqueados' },
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
// Tabla
|
|
54
|
+
users = [],
|
|
55
|
+
// [{ id, name, email, avatar, role, status: 'active'|'pending'|'blocked', joinedAt }]
|
|
56
|
+
|
|
57
|
+
statusMap = {
|
|
58
|
+
active: { label: 'Activo', variant: 'success' },
|
|
59
|
+
pending: { label: 'Pendiente', variant: 'warning' },
|
|
60
|
+
blocked: { label: 'Bloqueado', variant: 'destructive' },
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
pageSize = 10,
|
|
64
|
+
currentPage: externalPage,
|
|
65
|
+
totalPages: externalTotal,
|
|
66
|
+
onPageChange,
|
|
67
|
+
onRowAction,
|
|
68
|
+
rowActions = [
|
|
69
|
+
{ label: 'Ver perfil' },
|
|
70
|
+
{ label: 'Editar' },
|
|
71
|
+
{ label: 'Eliminar', variant: 'destructive' },
|
|
72
|
+
],
|
|
73
|
+
}) {
|
|
74
|
+
const [activeTab, setActiveTab] = useState('all');
|
|
75
|
+
const [search, setSearch] = useState('');
|
|
76
|
+
const [selected, setSelected] = useState([]);
|
|
77
|
+
|
|
78
|
+
// Filtros
|
|
79
|
+
const filtered = users.filter((u) => {
|
|
80
|
+
const matchTab = activeTab === 'all' || u.status === activeTab;
|
|
81
|
+
const matchSearch =
|
|
82
|
+
!search ||
|
|
83
|
+
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
84
|
+
u.email.toLowerCase().includes(search.toLowerCase());
|
|
85
|
+
return matchTab && matchSearch;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const allSelected =
|
|
89
|
+
filtered.length > 0 && selected.length === filtered.length;
|
|
90
|
+
const someSelected = selected.length > 0 && !allSelected;
|
|
91
|
+
|
|
92
|
+
function toggleAll() {
|
|
93
|
+
if (allSelected) {
|
|
94
|
+
setSelected([]);
|
|
95
|
+
} else {
|
|
96
|
+
setSelected(filtered.map((u) => u.id));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toggleOne(id) {
|
|
101
|
+
setSelected((prev) =>
|
|
102
|
+
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<DashboardLayout
|
|
108
|
+
brandName={brandName}
|
|
109
|
+
brandLogo={brandLogo}
|
|
110
|
+
navSections={navSections}
|
|
111
|
+
user={user}
|
|
112
|
+
userMenuItems={userMenuItems}
|
|
113
|
+
notificationsCount={notificationsCount}
|
|
114
|
+
onLogout={onLogout}
|
|
115
|
+
title={title}
|
|
116
|
+
>
|
|
117
|
+
<div className="space-y-6 max-w-7xl mx-auto">
|
|
118
|
+
{/* Header */}
|
|
119
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
120
|
+
<div className="space-y-1">
|
|
121
|
+
<h1 className="text-h2 font-bold tracking-tight">{title}</h1>
|
|
122
|
+
<p className="text-small text-muted-foreground">{subtitle}</p>
|
|
123
|
+
</div>
|
|
124
|
+
{onAddClick && (
|
|
125
|
+
<Button onClick={onAddClick}>
|
|
126
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
127
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
128
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
129
|
+
</svg>
|
|
130
|
+
{addButtonText}
|
|
131
|
+
</Button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Barra de filtros */}
|
|
136
|
+
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
|
|
137
|
+
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
138
|
+
<TabsList>
|
|
139
|
+
{tabs.map((tab) => {
|
|
140
|
+
const count =
|
|
141
|
+
tab.value === 'all'
|
|
142
|
+
? users.length
|
|
143
|
+
: users.filter((u) => u.status === tab.value).length;
|
|
144
|
+
return (
|
|
145
|
+
<TabsTrigger key={tab.value} value={tab.value}>
|
|
146
|
+
{tab.label}
|
|
147
|
+
<span className="ml-1.5 text-caption text-muted-foreground">
|
|
148
|
+
{count}
|
|
149
|
+
</span>
|
|
150
|
+
</TabsTrigger>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</TabsList>
|
|
154
|
+
</Tabs>
|
|
155
|
+
|
|
156
|
+
<div className="w-full lg:w-72">
|
|
157
|
+
<Input
|
|
158
|
+
type="search"
|
|
159
|
+
placeholder="Buscar usuario..."
|
|
160
|
+
value={search}
|
|
161
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
162
|
+
className="h-9"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Selección múltiple */}
|
|
168
|
+
{selected.length > 0 && (
|
|
169
|
+
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/50 px-4 py-2.5">
|
|
170
|
+
<span className="text-small font-medium">
|
|
171
|
+
{selected.length} seleccionados
|
|
172
|
+
</span>
|
|
173
|
+
<div className="flex gap-2 ml-auto">
|
|
174
|
+
<Button variant="ghost" size="sm">
|
|
175
|
+
Exportar
|
|
176
|
+
</Button>
|
|
177
|
+
<Button variant="ghost" size="sm" className="text-destructive">
|
|
178
|
+
Eliminar
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{/* Tabla */}
|
|
185
|
+
<Card>
|
|
186
|
+
<CardContent className="p-0">
|
|
187
|
+
<Table>
|
|
188
|
+
<TableHeader>
|
|
189
|
+
<TableRow>
|
|
190
|
+
<TableHead className="w-[40px]">
|
|
191
|
+
<Checkbox
|
|
192
|
+
checked={allSelected || (someSelected ? 'indeterminate' : false)}
|
|
193
|
+
onCheckedChange={toggleAll}
|
|
194
|
+
/>
|
|
195
|
+
</TableHead>
|
|
196
|
+
<TableHead>Usuario</TableHead>
|
|
197
|
+
<TableHead>Rol</TableHead>
|
|
198
|
+
<TableHead>Estado</TableHead>
|
|
199
|
+
<TableHead>Registro</TableHead>
|
|
200
|
+
<TableHead className="w-[60px] text-right">
|
|
201
|
+
<span className="sr-only">Acciones</span>
|
|
202
|
+
</TableHead>
|
|
203
|
+
</TableRow>
|
|
204
|
+
</TableHeader>
|
|
205
|
+
<TableBody>
|
|
206
|
+
{filtered.length > 0 ? (
|
|
207
|
+
filtered.map((u) => {
|
|
208
|
+
const status = statusMap[u.status] || statusMap.active;
|
|
209
|
+
return (
|
|
210
|
+
<TableRow
|
|
211
|
+
key={u.id}
|
|
212
|
+
data-state={selected.includes(u.id) ? 'selected' : undefined}
|
|
213
|
+
>
|
|
214
|
+
<TableCell>
|
|
215
|
+
<Checkbox
|
|
216
|
+
checked={selected.includes(u.id)}
|
|
217
|
+
onCheckedChange={() => toggleOne(u.id)}
|
|
218
|
+
/>
|
|
219
|
+
</TableCell>
|
|
220
|
+
<TableCell>
|
|
221
|
+
<div className="flex items-center gap-3">
|
|
222
|
+
<Avatar size="sm">
|
|
223
|
+
{u.avatar ? (
|
|
224
|
+
<AvatarImage src={u.avatar} alt={u.name} />
|
|
225
|
+
) : null}
|
|
226
|
+
<AvatarFallback>
|
|
227
|
+
{u.name?.charAt(0)?.toUpperCase() || 'U'}
|
|
228
|
+
</AvatarFallback>
|
|
229
|
+
</Avatar>
|
|
230
|
+
<div className="min-w-0">
|
|
231
|
+
<p className="text-small font-medium truncate">
|
|
232
|
+
{u.name}
|
|
233
|
+
</p>
|
|
234
|
+
<p className="text-caption text-muted-foreground truncate">
|
|
235
|
+
{u.email}
|
|
236
|
+
</p>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</TableCell>
|
|
240
|
+
<TableCell>
|
|
241
|
+
<Badge variant="secondary">{u.role}</Badge>
|
|
242
|
+
</TableCell>
|
|
243
|
+
<TableCell>
|
|
244
|
+
<Badge variant={status.variant}>
|
|
245
|
+
{status.label}
|
|
246
|
+
</Badge>
|
|
247
|
+
</TableCell>
|
|
248
|
+
<TableCell className="text-small text-muted-foreground">
|
|
249
|
+
{u.joinedAt}
|
|
250
|
+
</TableCell>
|
|
251
|
+
<TableCell className="text-right">
|
|
252
|
+
<DropdownMenu>
|
|
253
|
+
<DropdownMenuTrigger asChild>
|
|
254
|
+
<button className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted transition-colors">
|
|
255
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
256
|
+
<circle cx="12" cy="12" r="1" />
|
|
257
|
+
<circle cx="12" cy="5" r="1" />
|
|
258
|
+
<circle cx="12" cy="19" r="1" />
|
|
259
|
+
</svg>
|
|
260
|
+
</button>
|
|
261
|
+
</DropdownMenuTrigger>
|
|
262
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
263
|
+
{rowActions.map((action, i) => (
|
|
264
|
+
<div key={i}>
|
|
265
|
+
{action.variant === 'destructive' && i > 0 && (
|
|
266
|
+
<DropdownMenuSeparator />
|
|
267
|
+
)}
|
|
268
|
+
<DropdownMenuItem
|
|
269
|
+
variant={action.variant}
|
|
270
|
+
onClick={() => onRowAction?.(u, action)}
|
|
271
|
+
>
|
|
272
|
+
{action.label}
|
|
273
|
+
</DropdownMenuItem>
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</DropdownMenuContent>
|
|
277
|
+
</DropdownMenu>
|
|
278
|
+
</TableCell>
|
|
279
|
+
</TableRow>
|
|
280
|
+
);
|
|
281
|
+
})
|
|
282
|
+
) : (
|
|
283
|
+
<TableRow>
|
|
284
|
+
<TableCell colSpan={6} className="text-center py-16">
|
|
285
|
+
<div className="space-y-2">
|
|
286
|
+
<p className="text-small font-medium text-muted-foreground">
|
|
287
|
+
No se encontraron usuarios
|
|
288
|
+
</p>
|
|
289
|
+
<p className="text-caption text-muted-foreground">
|
|
290
|
+
Probá ajustar los filtros o la búsqueda.
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
</TableCell>
|
|
294
|
+
</TableRow>
|
|
295
|
+
)}
|
|
296
|
+
</TableBody>
|
|
297
|
+
</Table>
|
|
298
|
+
|
|
299
|
+
{/* Paginación */}
|
|
300
|
+
{externalPage != null && externalTotal != null && (
|
|
301
|
+
<div className="border-t border-border p-4 flex items-center justify-between">
|
|
302
|
+
<p className="text-caption text-muted-foreground">
|
|
303
|
+
Mostrando {filtered.length} de {users.length} usuarios
|
|
304
|
+
</p>
|
|
305
|
+
<PaginationBar
|
|
306
|
+
currentPage={externalPage}
|
|
307
|
+
totalPages={externalTotal}
|
|
308
|
+
onPageChange={onPageChange}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</CardContent>
|
|
313
|
+
</Card>
|
|
314
|
+
</div>
|
|
315
|
+
</DashboardLayout>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Badge, Button, Card, CardContent, cn } from '@imansi/ui';
|
|
2
|
+
import { MarketingLayout } from '../layouts/MarketingLayout.jsx';
|
|
3
|
+
|
|
4
|
+
export function CareersPage({
|
|
5
|
+
brandName = 'Imansi',
|
|
6
|
+
brandLogo,
|
|
7
|
+
navLinks = [],
|
|
8
|
+
headerCTA,
|
|
9
|
+
headerSecondaryCTA,
|
|
10
|
+
footerColumns = [],
|
|
11
|
+
|
|
12
|
+
badge = 'Carreras',
|
|
13
|
+
title = 'Trabajá con nosotros',
|
|
14
|
+
subtitle = 'Estamos construyendo el futuro del desarrollo de software. Sumate al equipo.',
|
|
15
|
+
|
|
16
|
+
// Beneficios
|
|
17
|
+
benefitsTitle = '¿Por qué Imansi?',
|
|
18
|
+
benefits = [],
|
|
19
|
+
// [{ title, description, icon }]
|
|
20
|
+
|
|
21
|
+
// Posiciones
|
|
22
|
+
positionsTitle = 'Posiciones abiertas',
|
|
23
|
+
positionsSubtitle = 'Todas las posiciones son remotas.',
|
|
24
|
+
positions = [],
|
|
25
|
+
// [{ id, title, department, location, type, level, description, tags }]
|
|
26
|
+
|
|
27
|
+
// Empty state
|
|
28
|
+
emptyMessage = 'No hay posiciones abiertas en este momento.',
|
|
29
|
+
|
|
30
|
+
// CTA
|
|
31
|
+
ctaTitle = '¿No encontrás tu rol?',
|
|
32
|
+
ctaSubtitle = 'Escribinos igual. Siempre estamos buscando gente talentosa.',
|
|
33
|
+
ctaPrimary = { label: 'Contactanos', href: '/contacto' },
|
|
34
|
+
}) {
|
|
35
|
+
return (
|
|
36
|
+
<MarketingLayout
|
|
37
|
+
brandName={brandName}
|
|
38
|
+
brandLogo={brandLogo}
|
|
39
|
+
navLinks={navLinks}
|
|
40
|
+
headerCTA={headerCTA}
|
|
41
|
+
headerSecondaryCTA={headerSecondaryCTA}
|
|
42
|
+
footerColumns={footerColumns}
|
|
43
|
+
>
|
|
44
|
+
{/* Hero */}
|
|
45
|
+
<section className="imansi-container pt-20 pb-16 text-center max-w-2xl mx-auto space-y-4">
|
|
46
|
+
<span className="inline-flex items-center rounded-full border border-border bg-card px-4 py-1.5 text-caption font-medium uppercase tracking-widest text-muted-foreground">
|
|
47
|
+
{badge}
|
|
48
|
+
</span>
|
|
49
|
+
<h1 className="text-display font-bold tracking-tight leading-[1.1]">
|
|
50
|
+
{title}
|
|
51
|
+
</h1>
|
|
52
|
+
<p className="text-body text-muted-foreground leading-relaxed">
|
|
53
|
+
{subtitle}
|
|
54
|
+
</p>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
{/* Beneficios */}
|
|
58
|
+
{benefits.length > 0 && (
|
|
59
|
+
<section className="imansi-container pb-20">
|
|
60
|
+
<h2 className="text-h2 font-bold tracking-tight text-center mb-10">
|
|
61
|
+
{benefitsTitle}
|
|
62
|
+
</h2>
|
|
63
|
+
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
|
|
64
|
+
{benefits.map((benefit, i) => (
|
|
65
|
+
<div key={i} className="rounded-xl border border-border bg-card p-6 space-y-3">
|
|
66
|
+
{benefit.icon && (
|
|
67
|
+
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
68
|
+
{benefit.icon}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
<h3 className="text-h4 font-semibold">{benefit.title}</h3>
|
|
72
|
+
<p className="text-small text-muted-foreground leading-relaxed">
|
|
73
|
+
{benefit.description}
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{/* Posiciones */}
|
|
82
|
+
<section className="border-t border-border bg-muted/20">
|
|
83
|
+
<div className="imansi-container py-20">
|
|
84
|
+
<div className="text-center max-w-2xl mx-auto space-y-3 mb-12">
|
|
85
|
+
<h2 className="text-h2 font-bold tracking-tight">{positionsTitle}</h2>
|
|
86
|
+
<p className="text-body text-muted-foreground">{positionsSubtitle}</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{positions.length > 0 ? (
|
|
90
|
+
<div className="max-w-4xl mx-auto space-y-4">
|
|
91
|
+
{positions.map((position) => (
|
|
92
|
+
<Card key={position.id} className="transition-shadow hover:shadow-md">
|
|
93
|
+
<CardContent className="p-6">
|
|
94
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
95
|
+
<div className="space-y-3 flex-1 min-w-0">
|
|
96
|
+
<div className="space-y-1">
|
|
97
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
98
|
+
<h3 className="text-h4 font-semibold">{position.title}</h3>
|
|
99
|
+
{position.level && (
|
|
100
|
+
<Badge variant="outline">{position.level}</Badge>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex flex-wrap items-center gap-3 text-caption text-muted-foreground">
|
|
104
|
+
<span>{position.department}</span>
|
|
105
|
+
<span>·</span>
|
|
106
|
+
<span>{position.location}</span>
|
|
107
|
+
<span>·</span>
|
|
108
|
+
<span>{position.type}</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{position.description && (
|
|
113
|
+
<p className="text-small text-muted-foreground leading-relaxed">
|
|
114
|
+
{position.description}
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{position.tags?.length > 0 && (
|
|
119
|
+
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
120
|
+
{position.tags.map((tag, j) => (
|
|
121
|
+
<Badge key={j} variant="secondary" size="sm">
|
|
122
|
+
{tag}
|
|
123
|
+
</Badge>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<Button asChild>
|
|
130
|
+
<a href={position.applyHref || '#'}>Aplicar</a>
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
</CardContent>
|
|
134
|
+
</Card>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
<div className="text-center py-12 max-w-md mx-auto">
|
|
139
|
+
<p className="text-small text-muted-foreground">{emptyMessage}</p>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</section>
|
|
144
|
+
|
|
145
|
+
{/* CTA */}
|
|
146
|
+
<section className="imansi-container py-20">
|
|
147
|
+
<div className="rounded-2xl bg-primary text-primary-foreground p-10 sm:p-14 text-center space-y-5">
|
|
148
|
+
<h2 className="text-h2 font-bold tracking-tight">{ctaTitle}</h2>
|
|
149
|
+
<p className="text-body opacity-80 max-w-xl mx-auto">{ctaSubtitle}</p>
|
|
150
|
+
{ctaPrimary && (
|
|
151
|
+
<div className="pt-2">
|
|
152
|
+
<a href={ctaPrimary.href}>
|
|
153
|
+
<Button
|
|
154
|
+
size="lg"
|
|
155
|
+
className="!bg-primary-foreground !text-primary hover:opacity-90"
|
|
156
|
+
>
|
|
157
|
+
{ctaPrimary.label}
|
|
158
|
+
</Button>
|
|
159
|
+
</a>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
</MarketingLayout>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Badge, Button, cn } from '@imansi/ui';
|
|
2
|
+
import { MarketingLayout } from '../layouts/MarketingLayout.jsx';
|
|
3
|
+
|
|
4
|
+
export function ChangelogPage({
|
|
5
|
+
brandName = 'Imansi',
|
|
6
|
+
brandLogo,
|
|
7
|
+
navLinks = [],
|
|
8
|
+
headerCTA,
|
|
9
|
+
headerSecondaryCTA,
|
|
10
|
+
footerColumns = [],
|
|
11
|
+
|
|
12
|
+
badge = 'Changelog',
|
|
13
|
+
title = 'Novedades',
|
|
14
|
+
subtitle = 'Todas las actualizaciones del producto, versión por versión.',
|
|
15
|
+
|
|
16
|
+
releases = [],
|
|
17
|
+
// [{ version, date, type: 'major'|'minor'|'patch'|'hotfix', title,
|
|
18
|
+
// changes: [{ type: 'added'|'changed'|'fixed'|'removed', text }] }]
|
|
19
|
+
|
|
20
|
+
typeMap = {
|
|
21
|
+
major: { label: 'Major', variant: 'primary' },
|
|
22
|
+
minor: { label: 'Minor', variant: 'success' },
|
|
23
|
+
patch: { label: 'Patch', variant: 'muted' },
|
|
24
|
+
hotfix: { label: 'Hotfix', variant: 'warning' },
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
changeLabels = {
|
|
28
|
+
added: { label: 'Nuevo', color: 'text-success bg-success/10' },
|
|
29
|
+
changed: { label: 'Cambiado', color: 'text-info bg-info/10' },
|
|
30
|
+
fixed: { label: 'Arreglado', color: 'text-warning bg-warning/10' },
|
|
31
|
+
removed: { label: 'Eliminado', color: 'text-destructive bg-destructive/10' },
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
subscribeCTA = { label: 'Suscribirme a novedades', href: '#' },
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<MarketingLayout
|
|
38
|
+
brandName={brandName}
|
|
39
|
+
brandLogo={brandLogo}
|
|
40
|
+
navLinks={navLinks}
|
|
41
|
+
headerCTA={headerCTA}
|
|
42
|
+
headerSecondaryCTA={headerSecondaryCTA}
|
|
43
|
+
footerColumns={footerColumns}
|
|
44
|
+
>
|
|
45
|
+
{/* Hero */}
|
|
46
|
+
<section className="imansi-container pt-20 pb-16 text-center max-w-2xl mx-auto space-y-4">
|
|
47
|
+
<span className="inline-flex items-center rounded-full border border-border bg-card px-4 py-1.5 text-caption font-medium uppercase tracking-widest text-muted-foreground">
|
|
48
|
+
{badge}
|
|
49
|
+
</span>
|
|
50
|
+
<h1 className="text-display font-bold tracking-tight leading-[1.1]">
|
|
51
|
+
{title}
|
|
52
|
+
</h1>
|
|
53
|
+
<p className="text-body text-muted-foreground leading-relaxed">
|
|
54
|
+
{subtitle}
|
|
55
|
+
</p>
|
|
56
|
+
{subscribeCTA && (
|
|
57
|
+
<div className="pt-2">
|
|
58
|
+
<Button>{subscribeCTA.label}</Button>
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
{/* Timeline */}
|
|
64
|
+
<section className="imansi-container pb-24">
|
|
65
|
+
<div className="max-w-3xl mx-auto">
|
|
66
|
+
<div className="relative">
|
|
67
|
+
{/* Línea vertical */}
|
|
68
|
+
<div className="absolute left-[15px] top-3 bottom-3 w-px bg-border" aria-hidden="true" />
|
|
69
|
+
|
|
70
|
+
<div className="space-y-12">
|
|
71
|
+
{releases.map((release, i) => {
|
|
72
|
+
const typeInfo = typeMap[release.type] || typeMap.minor;
|
|
73
|
+
return (
|
|
74
|
+
<article key={i} className="relative pl-12">
|
|
75
|
+
{/* Dot */}
|
|
76
|
+
<div className="absolute left-0 top-1.5 flex h-8 w-8 items-center justify-center rounded-full border-2 border-background bg-primary text-primary-foreground">
|
|
77
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
78
|
+
<circle cx="12" cy="12" r="4" />
|
|
79
|
+
</svg>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{/* Header */}
|
|
83
|
+
<div className="flex flex-wrap items-center gap-3 mb-3">
|
|
84
|
+
<h2 className="text-h3 font-bold tracking-tight font-mono">
|
|
85
|
+
{release.version}
|
|
86
|
+
</h2>
|
|
87
|
+
<Badge variant={typeInfo.variant}>{typeInfo.label}</Badge>
|
|
88
|
+
<span className="text-caption text-muted-foreground">
|
|
89
|
+
{release.date}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{release.title && (
|
|
94
|
+
<h3 className="text-h4 font-semibold mb-4">{release.title}</h3>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{/* Cambios */}
|
|
98
|
+
<ul className="space-y-2.5">
|
|
99
|
+
{release.changes.map((change, j) => {
|
|
100
|
+
const changeInfo = changeLabels[change.type] || changeLabels.changed;
|
|
101
|
+
return (
|
|
102
|
+
<li key={j} className="flex items-start gap-3">
|
|
103
|
+
<span
|
|
104
|
+
className={cn(
|
|
105
|
+
'shrink-0 inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider',
|
|
106
|
+
changeInfo.color
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{changeInfo.label}
|
|
110
|
+
</span>
|
|
111
|
+
<span className="text-small text-muted-foreground leading-relaxed pt-0.5">
|
|
112
|
+
{change.text}
|
|
113
|
+
</span>
|
|
114
|
+
</li>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</ul>
|
|
118
|
+
</article>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</section>
|
|
125
|
+
</MarketingLayout>
|
|
126
|
+
);
|
|
127
|
+
}
|