@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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +557 -0
  3. package/package.json +35 -0
  4. package/src/auth/ForgotPasswordPage.jsx +176 -0
  5. package/src/auth/LoginPage.jsx +265 -0
  6. package/src/auth/RegisterPage.jsx +302 -0
  7. package/src/auth/ResetPasswordPage.jsx +158 -0
  8. package/src/auth/Verify2FAPage.jsx +175 -0
  9. package/src/dashboard/BillingPage.jsx +345 -0
  10. package/src/dashboard/DashboardHome.jsx +246 -0
  11. package/src/dashboard/NotificationsPage.jsx +230 -0
  12. package/src/dashboard/ProfilePage.jsx +272 -0
  13. package/src/dashboard/SettingsPage.jsx +415 -0
  14. package/src/dashboard/UsersTablePage.jsx +317 -0
  15. package/src/extra/CareersPage.jsx +166 -0
  16. package/src/extra/ChangelogPage.jsx +127 -0
  17. package/src/extra/CheckoutPage.jsx +383 -0
  18. package/src/extra/ComingSoonPage.jsx +147 -0
  19. package/src/extra/ErrorPage.jsx +91 -0
  20. package/src/extra/InvoicePage.jsx +241 -0
  21. package/src/extra/LegalPage.jsx +115 -0
  22. package/src/extra/MaintenancePage.jsx +172 -0
  23. package/src/extra/OnboardingPage.jsx +197 -0
  24. package/src/extra/StatusPage.jsx +181 -0
  25. package/src/index.js +53 -0
  26. package/src/layouts/AuthLayout.jsx +104 -0
  27. package/src/layouts/DashboardLayout.jsx +249 -0
  28. package/src/layouts/MarketingLayout.jsx +184 -0
  29. package/src/marketing/AboutPage.jsx +189 -0
  30. package/src/marketing/BlogPage.jsx +182 -0
  31. package/src/marketing/BlogPostPage.jsx +192 -0
  32. package/src/marketing/ContactPage.jsx +546 -0
  33. package/src/marketing/DocsPage.jsx +256 -0
  34. package/src/marketing/HomePage.jsx +702 -0
  35. package/src/marketing/LandingPage.jsx +755 -0
  36. 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
+ }