feat: add canvas-confetti integration and AuroraButton component; enhance UI with animations and improve button interactions

This commit is contained in:
2025-08-26 15:58:30 -06:00
parent 56b0dc8a62
commit 196aea5df9
8 changed files with 143 additions and 41 deletions

View File

@@ -22,6 +22,8 @@
"@tanstack/react-router": "^1.130.2", "@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.131.5", "@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -426,6 +428,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
@@ -510,6 +514,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="], "caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="],
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
"chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="], "chai": ["chai@5.3.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A=="],
"check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="],

View File

@@ -28,6 +28,8 @@
"@tanstack/react-router": "^1.130.2", "@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.131.5", "@tanstack/react-router-devtools": "^1.131.5",
"@tanstack/router-plugin": "^1.121.2", "@tanstack/router-plugin": "^1.121.2",
"@types/canvas-confetti": "^1.9.0",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",

View File

@@ -0,0 +1,23 @@
// components/ui/aurora-button.tsx
import { Button } from "@/components/ui/button"
export function AuroraButton({
loading,
children,
...props
}: React.ComponentProps<typeof Button> & { loading?: boolean }) {
return (
<Button
{...props}
className={`${props.className ?? ""} relative overflow-hidden`}
disabled={props.disabled || loading}
>
{loading && (
<span className="absolute inset-0 animate-aurora" />
)}
<span className="relative z-10">
{loading ? "Pensando…" : children}
</span>
</Button>
)
}

View File

@@ -6,6 +6,8 @@ import { supabase } from "@/auth/supabase"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import confetti from "canvas-confetti"
import { import {
Accordion, AccordionContent, AccordionItem, AccordionTrigger, Accordion, AccordionContent, AccordionItem, AccordionTrigger,
} from "@/components/ui/accordion" } from "@/components/ui/accordion"
@@ -514,6 +516,11 @@ function MejorarAIButton({ asignaturaId, onApply }: {
} }
const nuevo = await res.json() const nuevo = await res.json()
onApply(nuevo as Asignatura) onApply(nuevo as Asignatura)
confetti({
particleCount: 120,
spread: 80,
origin: { y: 0.6 },
})
setOpen(false) setOpen(false)
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Error al mejorar la asignatura") alert(e?.message ?? "Error al mejorar la asignatura")
@@ -547,7 +554,24 @@ function MejorarAIButton({ asignaturaId, onApply }: {
</label> </label>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={apply} disabled={!prompt.trim() || loading}>{loading ? "Aplicando…" : "Aplicar ajuste"}</Button> <Button
onClick={apply}
disabled={!prompt.trim() || loading}
className={
loading
? "relative overflow-hidden text-white shadow-md"
: ""
}
>
{loading ? (
<span className="relative z-10">Pensando</span>
) : (
"Aplicar ajuste"
)}
{loading && (
<span className="absolute inset-0 animate-aurora" />
)}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -291,7 +291,7 @@ function RouteComponent() {
// NEW: acciones carrito // NEW: acciones carrito
function addToCart(a: Asignatura) { function addToCart(a: Asignatura) {
setCart(prev => prev.find(x => x.id === a.id) ? prev : [...prev, a]) setCart(prev => prev.find(x => x.id === a.id) ? prev : [...prev, a])
toast.success('Asignatura añadida al carrito') toast.success('Asignatura añadida al carrito de asignaturas')
} }
function removeFromCart(id: string) { function removeFromCart(id: string) {
setCart(prev => prev.filter(x => x.id !== id)) setCart(prev => prev.filter(x => x.id !== id))
@@ -355,7 +355,7 @@ function RouteComponent() {
className="relative" className="relative"
> >
<Icons.ShoppingCart className="w-4 h-4 mr-2" /> <Icons.ShoppingCart className="w-4 h-4 mr-2" />
Carrito Carrito de asignaturas
{cart.length > 0 && ( {cart.length > 0 && (
<span className="ml-2 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-white/90 text-[11px] text-neutral-900 px-1"> <span className="ml-2 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-white/90 text-[11px] text-neutral-900 px-1">
{cart.length} {cart.length}
@@ -369,36 +369,51 @@ function RouteComponent() {
</div> </div>
{/* Filtros */} {/* Filtros */}
<div className="grid gap-2 sm:grid-cols-[1fr,140px,180px,150px]"> <div className="grid gap-4 sm:grid-cols-4">
<Input <div>
value={q} <Label>Búsqueda</Label>
onChange={(e) => setQ(e.target.value)} <Input
placeholder="Buscar por nombre, clave, plan, carrera, facultad…" value={q}
className="w-full" onChange={(e) => setQ(e.target.value)}
/> placeholder="Nombre, clave, plan, carrera, facultad…"
<Select value={sem} onValueChange={setSem}> />
<SelectTrigger><SelectValue placeholder="Semestre" /></SelectTrigger> </div>
<SelectContent>
<SelectItem value="todos">Todos</SelectItem> <div>
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)} <Label>Semestre</Label>
</SelectContent> <Select value={sem} onValueChange={setSem}>
</Select> <SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
<Select value={tipo} onValueChange={setTipo}> <SelectContent>
<SelectTrigger><SelectValue placeholder="Tipo" /></SelectTrigger> <SelectItem value="todos">Todos</SelectItem>
<SelectContent className="max-h-64"> {semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
<SelectItem value="todos">Todos</SelectItem> </SelectContent>
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)} </Select>
</SelectContent> </div>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}> <div>
<SelectTrigger><SelectValue placeholder="Agrupar por" /></SelectTrigger> <Label>Tipo</Label>
<SelectContent> <Select value={tipo} onValueChange={setTipo}>
<SelectItem value="semestre">Agrupar por semestre</SelectItem> <SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
<SelectItem value="ninguno">Sin agrupación</SelectItem> <SelectContent className="max-h-64">
</SelectContent> <SelectItem value="todos">Todos</SelectItem>
</Select> {tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Agrupación</Label>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="semestre">Por semestre</SelectItem>
<SelectItem value="ninguno">Sin agrupación</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
{/* Chips de salud */} {/* Chips de salud */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<HealthChip <HealthChip
@@ -621,7 +636,7 @@ function RouteComponent() {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito</Button> <Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito de Asignaturas</Button>
<div className="space-x-2"> <div className="space-x-2">
<Button variant="outline" onClick={() => setBulkOpen(false)}>Cerrar</Button> <Button variant="outline" onClick={() => setBulkOpen(false)}>Cerrar</Button>
<Button onClick={cloneBulk}><Icons.CopyPlus className="w-4 h-4 mr-1" /> Clonar en lote</Button> <Button onClick={cloneBulk}><Icons.CopyPlus className="w-4 h-4 mr-1" /> Clonar en lote</Button>
@@ -686,7 +701,7 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
<Icons.Copy className="w-4 h-4" /> Clonar <Icons.Copy className="w-4 h-4" /> Clonar
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="gap-2" onClick={onAddToCart}> <DropdownMenuItem className="gap-2" onClick={onAddToCart}>
<Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito <Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito de asignaturas
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -171,10 +171,10 @@ function RouteComponent() {
{/* Métricas principales */} {/* Métricas principales */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric to={`/_authenticated/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} /> <Metric to={`/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
<Metric to={`/_authenticated/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} /> <Metric to={`/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
<Metric to={`/_authenticated/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} /> <Metric to={`/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
<Metric to={`/_authenticated/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} /> <Metric to={`/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
</div> </div>
{/* Calidad + Salud */} {/* Calidad + Salud */}

View File

@@ -13,6 +13,8 @@ import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger' import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { AcademicSections } from '@/components/planes/academic-sections' import { AcademicSections } from '@/components/planes/academic-sections'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
import confetti from 'canvas-confetti'
import { AuroraButton } from '@/components/effect/aurora-button'
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
@@ -407,6 +409,8 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
body: JSON.stringify({ prompt, plan_id: plan.id }), body: JSON.stringify({ prompt, plan_id: plan.id }),
}).catch(() => { }) }).catch(() => { })
setLoading(false) setLoading(false)
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false) setOpen(false)
} }
@@ -423,9 +427,9 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
</DialogHeader> </DialogHeader>
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" /> <Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
<DialogFooter> <DialogFooter>
<Button onClick={apply} disabled={!prompt.trim() || loading}> <AuroraButton onClick={apply} disabled={!prompt.trim() || loading}>
{loading ? 'Aplicando…' : 'Aplicar ajuste'} {loading ? 'Aplicando…' : 'Aplicar ajuste'}
</Button> </AuroraButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -519,6 +523,8 @@ function AddAsignaturaButton({
}), }),
}) })
if (!res.ok) throw new Error(await res.text()) if (!res.ok) throw new Error(await res.text())
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
setOpen(false); onAdded?.() setOpen(false); onAdded?.()
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Error al generar la asignatura") alert(e?.message ?? "Error al generar la asignatura")
@@ -616,11 +622,13 @@ function AddAsignaturaButton({
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button> <Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={submit} disabled={saving || !canSubmit}> <Button >
</Button>
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
{saving {saving
? (mode === "manual" ? "Guardando…" : "Generando…") ? (mode === "manual" ? "Guardando…" : "Generando…")
: (mode === "manual" ? "Crear" : "Generar e insertar")} : (mode === "manual" ? "Crear" : "Generar e insertar")}
</Button> </AuroraButton>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -118,7 +118,31 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@keyframes aurora {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-aurora {
background: radial-gradient(at 20% 30%, rgba(59, 130, 246, .5), transparent 50%),
radial-gradient(at 80% 70%, rgba(236, 72, 153, .5), transparent 50%),
radial-gradient(at 50% 100%, rgba(34, 197, 94, .5), transparent 50%);
background-size: 200% 200%;
animation: aurora 6s ease infinite;
filter: blur(12px) opacity(0.8);
}