Initial commit without README

This commit is contained in:
2026-08-20 11:35:19 -04:00
commit 5ddd08210f
43 changed files with 9353 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
VITE_REPORT_WEBHOOK_URL="https://TU-N8N/webhook/cruces-glm-reportar"
VITE_DEMO_AUTH="false"
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local environment variables
.env
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+28
View File
@@ -0,0 +1,28 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
);
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="GomezLee Marketing" />
<meta
name="description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:title" content="Cruces GLM | GomezLee Marketing" />
<meta
property="og:description"
content="Centraliza los cruces y automatizaciones internas de GomezLee Marketing."
/>
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" href="/cruces-glm/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/cruces-glm/favicon.png" />
<title>Cruces GLM | GomezLee Marketing</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4742
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "cruces-glm",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite preview --host 0.0.0.0 --port 3000",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "1.170.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

+14
View File
@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { LoaderCircle } from "lucide-react";
import {
DEMO_AUTH_ENABLED,
DEMO_USER,
GLM_LOGO_URL,
initializeAuth,
signInWithGoogle,
signOut,
type AuthUser,
} from "@/lib/auth";
import { AuthContext, type AuthContextValue } from "@/lib/auth-context";
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="size-5 shrink-0">
<path
fill="#4285F4"
d="M21.6 12.23c0-.71-.06-1.4-.18-2.07H12v3.92h5.38a4.6 4.6 0 0 1-2 3.02v2.54h3.24c1.9-1.75 2.98-4.33 2.98-7.41Z"
/>
<path
fill="#34A853"
d="M12 22c2.7 0 4.98-.9 6.64-2.36l-3.24-2.54c-.9.6-2.05.96-3.4.96-2.61 0-4.82-1.76-5.61-4.13H3.04v2.62A10 10 0 0 0 12 22Z"
/>
<path
fill="#FBBC05"
d="M6.39 13.93A6.02 6.02 0 0 1 6.08 12c0-.67.12-1.32.31-1.93V7.45H3.04A10 10 0 0 0 2 12c0 1.61.39 3.13 1.04 4.55l3.35-2.62Z"
/>
<path
fill="#EA4335"
d="M12 5.94c1.47 0 2.79.5 3.83 1.5l2.88-2.88A9.64 9.64 0 0 0 12 2a10 10 0 0 0-8.96 5.45l3.35 2.62C7.18 7.7 9.39 5.94 12 5.94Z"
/>
</svg>
);
}
function LoginScreen({
message,
onDemoLogin,
}: {
message?: string | undefined;
onDemoLogin: () => void;
}) {
const [redirecting, setRedirecting] = useState(false);
const [localError, setLocalError] = useState<string | undefined>();
const login = () => {
if (DEMO_AUTH_ENABLED) {
onDemoLogin();
return;
}
try {
setLocalError(undefined);
setRedirecting(true);
signInWithGoogle();
} catch (error) {
setRedirecting(false);
setLocalError(error instanceof Error ? error.message : "No se pudo iniciar sesión.");
}
};
const error = localError ?? message;
return (
<main className="glm-login-background min-h-screen px-5 py-10">
<section className="glm-login-card" aria-label="Inicio de sesión de Cruces GLM">
<div className="glm-login-topline" />
<div className="px-10 pt-11 pb-12 sm:px-12">
<div className="mx-auto flex size-[92px] items-center justify-center rounded-[24px] border border-white/80 bg-white shadow-[0_14px_34px_rgba(79,117,139,0.12)]">
<img
src={GLM_LOGO_URL}
alt="GomezLee Marketing"
className="w-[66px] max-w-full object-contain"
/>
</div>
<div className="mt-8 text-center">
<h1 className="text-[34px] leading-none font-semibold tracking-[-0.045em] text-[#45697D]">
Cruces GLM
</h1>
<p className="mt-3 text-[11px] font-bold tracking-[0.27em] text-[#7F95AE] uppercase">
GomezLee Marketing
</p>
</div>
<button
type="button"
onClick={login}
disabled={redirecting}
className="mt-11 flex h-[52px] w-full items-center justify-center gap-3 rounded-[14px] bg-gradient-to-r from-[#466F87] to-[#628CA4] px-5 text-[14px] font-bold text-white shadow-[0_12px_24px_rgba(69,105,125,0.18)] transition hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(69,105,125,0.24)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-75 disabled:hover:translate-y-0"
>
{redirecting ? (
<LoaderCircle className="size-5 animate-spin" aria-hidden="true" />
) : (
<GoogleIcon />
)}
{redirecting ? "Abriendo Google…" : "Continuar con Google"}
</button>
{error && (
<p
role="alert"
className="mt-5 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-center text-xs leading-relaxed text-red-700"
>
{error}
</p>
)}
</div>
</section>
</main>
);
}
function LoadingScreen() {
return (
<main className="glm-login-background flex min-h-screen items-center justify-center px-5">
<div className="flex items-center gap-3 rounded-2xl border border-white/80 bg-white/85 px-5 py-4 text-sm text-glm-blue shadow-lg backdrop-blur">
<LoaderCircle className="size-5 animate-spin" />
Validando sesión
</div>
</main>
);
}
type GateState =
| { status: "loading" }
| { status: "unauthenticated"; message?: string }
| { status: "authenticated"; user: AuthUser; canManageForms: boolean };
export function AuthGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<GateState>(
DEMO_AUTH_ENABLED ? { status: "unauthenticated" } : { status: "loading" },
);
useEffect(() => {
if (DEMO_AUTH_ENABLED) return;
let alive = true;
initializeAuth().then((result) => {
if (!alive) return;
setState(result);
});
return () => {
alive = false;
};
}, []);
const value = useMemo<AuthContextValue | null>(() => {
if (state.status !== "authenticated") return null;
return {
user: state.user,
canManageForms: state.canManageForms,
logout: async () => {
if (!DEMO_AUTH_ENABLED) {
await signOut();
}
setState({ status: "unauthenticated" });
},
};
}, [state]);
if (state.status === "loading") return <LoadingScreen />;
if (state.status === "unauthenticated") {
return (
<LoginScreen
message={state.message}
onDemoLogin={() =>
setState({ status: "authenticated", user: DEMO_USER, canManageForms: true })
}
/>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
+149
View File
@@ -0,0 +1,149 @@
import { Link } from "@tanstack/react-router";
import { ArrowUpRight, ExternalLink, Pencil, Star, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { GlmForm } from "@/lib/glm-forms";
import { recordFormOpened } from "@/lib/metrics";
type Props = {
form: GlmForm;
favorito: boolean;
canManageForms: boolean;
onFavorito: (id: string) => void;
onEditar: (form: GlmForm) => void;
onEliminar: (form: GlmForm) => void;
onAbrir: (form: GlmForm) => void;
};
export function FormCard({
form,
favorito,
canManageForms,
onFavorito,
onEditar,
onEliminar,
onAbrir,
}: Props) {
const disponible = form.activo;
return (
<article
className={`glm-card group relative flex flex-col overflow-hidden ${
disponible ? "" : "glm-card-disabled"
}`}
aria-disabled={!disponible}
>
<div className={disponible ? "glm-strip" : "h-1 w-full bg-muted-foreground/45"} />
<div className="flex flex-1 flex-col gap-3 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-sm bg-glm-leaf-bg px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-glm-blue uppercase">
{form.categoria}
</span>
{!disponible && (
<span className="rounded-sm bg-muted px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-muted-foreground uppercase">
No disponible
</span>
)}
</div>
<button
type="button"
aria-label={favorito ? "Quitar de favoritos" : "Marcar como favorito"}
onClick={() => onFavorito(form.id)}
className="text-muted-foreground transition-colors hover:text-glm-green"
>
<Star
className="size-4"
strokeWidth={1.8}
fill={favorito ? "currentColor" : "none"}
color={favorito ? "var(--glm-green)" : undefined}
/>
</button>
</div>
{disponible ? (
<Link
to="/formularios/$formId"
params={{ formId: form.id }}
onClick={() => onAbrir(form)}
className="text-base leading-snug font-bold text-glm-blue transition-colors hover:text-glm-teal"
>
{form.nombre}
</Link>
) : (
<h3 className="cursor-not-allowed text-base leading-snug font-bold text-muted-foreground">
{form.nombre}
</h3>
)}
<p className="line-clamp-3 text-sm leading-relaxed text-glm-body">{form.descripcion}</p>
<div className="mt-auto pt-3">
<div className="glm-rule" />
<div className="mt-3 flex items-center justify-between">
{disponible ? (
<Link
to="/formularios/$formId"
params={{ formId: form.id }}
onClick={() => onAbrir(form)}
className="inline-flex items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-glm-blue uppercase transition-colors hover:text-glm-green"
>
Abrir <ArrowUpRight className="size-3.5" />
</Link>
) : (
<span className="inline-flex cursor-not-allowed items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-muted-foreground uppercase">
No disponible
</span>
)}
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{disponible && (
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Abrir en n8n"
asChild
>
<a
href={form.url}
target="_blank"
rel="noreferrer"
onClick={() => {
onAbrir(form);
void recordFormOpened(form);
}}
>
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
{canManageForms && (
<>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Editar"
onClick={() => onEditar(form)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label="Eliminar"
onClick={() => onEliminar(form)}
>
<Trash2 className="size-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
</div>
</article>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { CATEGORIAS, slugify, type GlmForm } from "@/lib/glm-forms";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
inicial?: GlmForm | null;
onGuardar: (form: GlmForm) => void | Promise<void>;
};
const vacio: GlmForm = {
id: "",
nombre: "",
descripcion: "",
categoria: "Seguridad Social",
url: "",
activo: true,
};
function RequiredMark() {
return (
<>
<span className="ml-0.5 text-glm-orange" aria-hidden="true">
*
</span>
<span className="sr-only"> obligatorio</span>
</>
);
}
export function FormDialog({ open, onOpenChange, inicial, onGuardar }: Props) {
const [form, setForm] = useState<GlmForm>(inicial ?? vacio);
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => {
if (open) {
setForm(inicial ?? vacio);
setError(null);
setGuardando(false);
}
}, [open, inicial]);
const set = <K extends keyof GlmForm>(key: K, value: GlmForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const todosCompletos = useMemo(
() =>
Boolean(
form.nombre.trim() &&
form.url.trim() &&
form.descripcion.trim() &&
form.categoria.trim(),
),
[form],
);
const submit = async () => {
if (!todosCompletos) {
setError("Completa todos los campos marcados con * antes de guardar.");
return;
}
if (!/^https?:\/\//i.test(form.url.trim())) {
setError("La URL del formulario n8n debe empezar con http:// o https://");
return;
}
setGuardando(true);
setError(null);
try {
await onGuardar({
...form,
nombre: form.nombre.trim(),
descripcion: form.descripcion.trim(),
categoria: form.categoria.trim(),
url: form.url.trim(),
id: form.id || slugify(form.nombre),
});
onOpenChange(false);
} catch (saveError) {
setError(
saveError instanceof Error
? saveError.message
: "No se pudo guardar el formulario. Intenta nuevamente.",
);
} finally {
setGuardando(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-glm-blue">
{inicial ? "Editar formulario" : "Nuevo formulario"}
</DialogTitle>
<DialogDescription>
Registra el enlace del formulario n8n para que el equipo lo encuentre en el hub.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<Label htmlFor="nombre">
Nombre
<RequiredMark />
</Label>
<Input
id="nombre"
value={form.nombre}
onChange={(e) => {
set("nombre", e.target.value);
if (error) setError(null);
}}
placeholder="Cruce de Seguridad Social - Honduras"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="url">
URL del formulario n8n
<RequiredMark />
</Label>
<Input
id="url"
value={form.url}
onChange={(e) => {
set("url", e.target.value);
if (error) setError(null);
}}
placeholder="https://n8n.tuempresa.com/form/..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="descripcion">
Descripción
<RequiredMark />
</Label>
<Textarea
id="descripcion"
rows={4}
value={form.descripcion}
onChange={(e) => {
set("descripcion", e.target.value);
if (error) setError(null);
}}
placeholder="Describe brevemente qué hace este cruce."
required
/>
</div>
<div className="grid gap-5 sm:grid-cols-2 sm:items-end">
<div className="space-y-2">
<Label>
Categoría
<RequiredMark />
</Label>
<Select
value={form.categoria}
onValueChange={(value) => {
set("categoria", value);
if (error) setError(null);
}}
>
<SelectTrigger aria-required="true">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIAS.map((categoria) => (
<SelectItem key={categoria} value={categoria}>
{categoria}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex h-10 items-center gap-3 sm:justify-start">
<Switch
id="activo"
checked={form.activo}
onCheckedChange={(value) => set("activo", value)}
/>
<Label htmlFor="activo" className="cursor-pointer">
Disponible
<RequiredMark />
</Label>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
Los campos marcados con <span className="font-bold text-glm-orange">*</span> son
obligatorios.
</p>
{error ? (
<p
role="alert"
className="rounded-md border border-border bg-glm-section-bg px-3 py-2 text-xs font-bold text-glm-blue"
>
{error}
</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={guardando}>
Cancelar
</Button>
<Button onClick={() => void submit()} disabled={!todosCompletos || guardando}>
{guardando ? "Guardando…" : "Guardar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from "react";
import { CheckCircle2, LoaderCircle, MessageSquareWarning, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth-context";
const REPORT_WEBHOOK_URL = (import.meta.env["VITE_REPORT_WEBHOOK_URL"] ?? "").trim();
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getUserName(email: string, metadata?: Record<string, unknown>) {
const directName =
metadataString(metadata, "full_name") ?? metadataString(metadata, "name");
if (directName) return directName;
const composedName = [
metadataString(metadata, "given_name"),
metadataString(metadata, "family_name"),
]
.filter(Boolean)
.join(" ");
return composedName || email;
}
export function ReportIssue() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const reset = () => {
setMessage("");
setError(null);
setSent(false);
setSending(false);
};
const changeOpen = (next: boolean) => {
setOpen(next);
if (!next) reset();
};
const submit = async () => {
const cleanMessage = message.trim();
if (!cleanMessage) {
setError("Debes escribir una descripción antes de enviar el reporte.");
return;
}
if (!REPORT_WEBHOOK_URL) {
setError(
"El webhook de reportes todavía no está configurado. Agrega VITE_REPORT_WEBHOOK_URL y vuelve a compilar la aplicación.",
);
return;
}
setSending(true);
setError(null);
try {
const response = await fetch(REPORT_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app: "Cruces GLM",
userEmail: user.email,
userName: getUserName(user.email, user.user_metadata),
message: cleanMessage,
submittedAt: new Date().toISOString(),
}),
});
if (!response.ok) {
throw new Error(`El servidor respondió con estado ${response.status}.`);
}
setSent(true);
setMessage("");
} catch (submitError) {
setError(
submitError instanceof Error
? `No se pudo enviar el reporte. ${submitError.message}`
: "No se pudo enviar el reporte. Intenta nuevamente.",
);
} finally {
setSending(false);
}
};
return (
<section className="border-t border-border py-8 text-center">
<p className="mb-3 text-xs text-muted-foreground">
¿Encontraste un problema o necesitas reportar algo?
</p>
<Button variant="outline" onClick={() => setOpen(true)} className="gap-2">
<MessageSquareWarning className="size-4" />
Reportar
</Button>
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-glm-blue">Reportar</DialogTitle>
<DialogDescription className="sr-only">
Formulario para enviar un reporte de Cruces GLM.
</DialogDescription>
</DialogHeader>
{sent ? (
<div className="flex items-start gap-3 rounded-md border border-glm-green/40 bg-glm-leaf-bg p-4 text-left">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-glm-green" />
<div>
<p className="text-sm font-bold text-glm-blue">Reporte enviado</p>
<p className="mt-1 text-sm text-glm-body">
Tu mensaje fue enviado correctamente.
</p>
</div>
</div>
) : (
<div className="space-y-2 py-1 text-left">
<Label htmlFor="reporte-mensaje">Descripción del reporte</Label>
<Textarea
id="reporte-mensaje"
value={message}
onChange={(event) => {
setMessage(event.target.value);
if (error) setError(null);
}}
rows={6}
required
placeholder="Explica qué ocurrió, qué esperabas que pasara y cualquier detalle que pueda ayudar a revisarlo."
className="resize-y"
/>
<p className="text-[11px] text-muted-foreground">Se enviará como: {user.email}</p>
</div>
)}
{error ? (
<p role="alert" className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-left text-xs text-red-700 dark:border-red-900/60 dark:bg-red-950/35 dark:text-red-300">
{error}
</p>
) : null}
<DialogFooter>
{sent ? (
<Button onClick={() => changeOpen(false)}>Cerrar</Button>
) : (
<>
<Button variant="outline" onClick={() => changeOpen(false)} disabled={sending}>
Cancelar
</Button>
<Button onClick={() => void submit()} disabled={!message.trim() || sending}>
{sending ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
{sending ? "Enviando…" : "Enviar reporte"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { Link } from "@tanstack/react-router";
import { LogOut, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { getStoredTheme, saveTheme, type AppTheme } from "@/lib/theme";
import { useAuth } from "@/lib/auth-context";
const HEADER_LOGO_URL = `${import.meta.env.BASE_URL}glm-logo-header.png`;
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getProfilePhoto(metadata?: Record<string, unknown>) {
return (
metadataString(metadata, "avatar_url") ??
metadataString(metadata, "picture") ??
metadataString(metadata, "photo_url")
);
}
function getInitials(email: string, metadata?: Record<string, unknown>) {
const givenName = metadataString(metadata, "given_name");
const familyName = metadataString(metadata, "family_name");
if (givenName && familyName) {
return `${givenName.charAt(0)}${familyName.charAt(0)}`.toUpperCase();
}
const fullName = metadataString(metadata, "full_name") ?? metadataString(metadata, "name");
if (fullName) {
const parts = fullName.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]?.charAt(0) ?? ""}${parts.at(-1)?.charAt(0) ?? ""}`.toUpperCase();
}
if (parts[0]) return parts[0].slice(0, 2).toUpperCase();
}
const localPart = email.split("@")[0] ?? "";
const emailParts = localPart.split(/[._-]+/).filter(Boolean);
if (emailParts.length >= 2) {
return `${emailParts[0]?.charAt(0) ?? ""}${emailParts.at(-1)?.charAt(0) ?? ""}`.toUpperCase();
}
return localPart.slice(0, 2).toUpperCase() || "GL";
}
function UserAvatar({
email,
metadata,
}: {
email: string;
metadata?: Record<string, unknown>;
}) {
const photoUrl = getProfilePhoto(metadata);
const [photoFailed, setPhotoFailed] = useState(false);
const initials = getInitials(email, metadata);
return (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border bg-glm-leaf-bg text-sm font-bold text-glm-blue shadow-sm">
{photoUrl && !photoFailed ? (
<img
src={photoUrl}
alt="Foto de perfil"
className="size-full object-cover"
referrerPolicy="no-referrer"
onError={() => setPhotoFailed(true)}
/>
) : (
<span aria-label={`Iniciales ${initials}`}>{initials}</span>
)}
</div>
);
}
export function SiteHeader() {
const { user, logout } = useAuth();
const [theme, setTheme] = useState<AppTheme>(() => getStoredTheme());
const toggleTheme = () => {
const nextTheme: AppTheme = theme === "dark" ? "light" : "dark";
setTheme(nextTheme);
saveTheme(nextTheme);
};
return (
<header className="sticky top-0 z-30 border-b border-border/80 bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 py-4">
<Link to="/" className="flex shrink-0 items-center" aria-label="Ir a Cruces GLM">
<img src={HEADER_LOGO_URL} alt="GomezLee Marketing" className="h-11 w-auto sm:h-12" />
</Link>
<div className="flex min-w-0 items-center gap-3 sm:gap-4">
<div className="flex min-w-0 items-center gap-2.5">
<UserAvatar email={user.email} metadata={user.user_metadata} />
<span className="hidden max-w-64 truncate text-sm text-muted-foreground sm:block">
{user.email}
</span>
</div>
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-10 shrink-0 items-center justify-center rounded-md border border-glm-blue/25 bg-card text-glm-blue shadow-sm transition-all hover:border-glm-green hover:bg-glm-leaf-bg hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2"
title={theme === "dark" ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
aria-label={theme === "dark" ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
>
{theme === "dark" ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<button
type="button"
onClick={() => void logout()}
className="inline-flex h-10 shrink-0 items-center gap-2 rounded-md border border-glm-blue/25 bg-card px-4 text-sm font-semibold text-glm-blue shadow-sm transition-all hover:border-glm-green hover:bg-glm-leaf-bg hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2"
title="Cerrar sesión"
>
<LogOut className="size-4" />
<span className="hidden sm:inline">Cerrar sesión</span>
</button>
</div>
</div>
</header>
);
}
+115
View File
@@ -0,0 +1,115 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+21
View File
@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+152
View File
@@ -0,0 +1,152 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-select-content-transform-origin)",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
+16
View File
@@ -0,0 +1,16 @@
import { createContext, useContext } from "react";
import type { AuthUser } from "@/lib/auth";
export type AuthContextValue = {
user: AuthUser;
canManageForms: boolean;
logout: () => Promise<void>;
};
export const AuthContext = createContext<AuthContextValue | null>(null);
export function useAuth() {
const value = useContext(AuthContext);
if (!value) throw new Error("useAuth debe usarse dentro de AuthGate.");
return value;
}
+354
View File
@@ -0,0 +1,354 @@
export const GLM_LOGO_URL =
"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png";
// El login real Google + Supabase es el modo de producción.
// El modo demo solo se activa de forma explícita con VITE_DEMO_AUTH="true".
export const DEMO_AUTH_ENABLED =
(import.meta.env["VITE_DEMO_AUTH"] ?? "false").trim().toLowerCase() === "true";
const SESSION_STORAGE_KEY = "cruces-glm-supabase-session-v1";
const SUPABASE_URL = (import.meta.env["VITE_SUPABASE_URL"] ?? "").replace(/\/+$/, "");
const SUPABASE_ANON_KEY = import.meta.env["VITE_SUPABASE_ANON_KEY"] ?? "";
export type AuthUser = {
id: string;
email: string;
user_metadata?: Record<string, unknown> | undefined;
};
export type AuthAccess = {
allowed: boolean;
canManageForms: boolean;
displayName?: string | undefined;
};
export const DEMO_USER: AuthUser = {
id: "demo-iaracena",
email: "iaracena@gomezleemarketing.com",
user_metadata: {
full_name: "Isaac Aracena",
given_name: "Isaac",
family_name: "Aracena",
},
};
type StoredSession = {
accessToken: string;
refreshToken: string;
expiresAt: number;
};
export type AuthInitResult =
| { status: "authenticated"; user: AuthUser; canManageForms: boolean }
| { status: "unauthenticated"; message?: string };
function assertConfig() {
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error(
"Faltan VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY en las variables de entorno.",
);
}
}
function normalizeEmail(email?: string | null) {
return (email ?? "").trim().toLowerCase();
}
function getRedirectUrl() {
if (typeof window === "undefined") return "";
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
}
function saveSession(session: StoredSession) {
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
}
function loadSession(): StoredSession | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredSession>;
if (
typeof parsed.accessToken !== "string" ||
typeof parsed.refreshToken !== "string" ||
typeof parsed.expiresAt !== "number"
) {
return null;
}
return parsed as StoredSession;
} catch {
return null;
}
}
export function getStoredAccessToken() {
return loadSession()?.accessToken ?? null;
}
function clearSession() {
if (typeof window !== "undefined") {
window.localStorage.removeItem(SESSION_STORAGE_KEY);
}
}
function consumeOAuthFragment(): { message?: string; freshLogin: boolean } {
if (typeof window === "undefined" || !window.location.hash) return { freshLogin: false };
const params = new URLSearchParams(window.location.hash.slice(1));
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
const errorDescription = params.get("error_description") ?? params.get("error");
if (accessToken && refreshToken) {
const expiresAtFromHash = Number(params.get("expires_at"));
const expiresIn = Number(params.get("expires_in"));
const expiresAt =
Number.isFinite(expiresAtFromHash) && expiresAtFromHash > 0
? expiresAtFromHash
: Math.floor(Date.now() / 1000) +
(Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600);
saveSession({ accessToken, refreshToken, expiresAt });
}
if (accessToken || errorDescription) {
window.history.replaceState(
{},
document.title,
`${window.location.pathname}${window.location.search}`,
);
}
return errorDescription
? { message: decodeURIComponent(errorDescription), freshLogin: false }
: { freshLogin: Boolean(accessToken && refreshToken) };
}
async function refreshSession(session: StoredSession): Promise<StoredSession | null> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
"content-type": "application/json",
},
body: JSON.stringify({ refresh_token: session.refreshToken }),
});
if (!response.ok) {
clearSession();
return null;
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
expires_at?: number;
};
if (!data.access_token || !data.refresh_token) {
clearSession();
return null;
}
const next: StoredSession = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt:
typeof data.expires_at === "number"
? data.expires_at
: Math.floor(Date.now() / 1000) + (data.expires_in ?? 3600),
};
saveSession(next);
return next;
}
async function recordLoginMetric(accessToken: string) {
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) return;
try {
await fetch(`${SUPABASE_URL}/rest/v1/rpc/cruces_glm_record_login`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: "{}",
keepalive: true,
});
} catch {
// Las métricas no deben bloquear el inicio de sesión.
}
}
async function getUser(accessToken: string): Promise<AuthUser | null> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) return null;
const data = (await response.json()) as {
id?: string;
email?: string;
user_metadata?: Record<string, unknown> | undefined;
};
const email = normalizeEmail(data.email);
if (!data.id || !email) return null;
return {
id: data.id,
email,
user_metadata: data.user_metadata,
};
}
async function getMyAccess(accessToken: string): Promise<AuthAccess> {
assertConfig();
const response = await fetch(`${SUPABASE_URL}/rest/v1/rpc/cruces_glm_get_my_access`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: "{}",
});
if (!response.ok) {
return { allowed: false, canManageForms: false };
}
const rows = (await response.json()) as Array<{
allowed?: boolean;
can_manage_forms?: boolean;
display_name?: string | null;
}>;
const row = rows[0];
const displayName =
typeof row?.display_name === "string" && row.display_name.trim()
? row.display_name.trim()
: null;
return {
allowed: row?.allowed === true,
canManageForms: row?.allowed === true && row?.can_manage_forms === true,
...(displayName ? { displayName } : {}),
};
}
export async function initializeAuth(): Promise<AuthInitResult> {
try {
assertConfig();
const oauthResult = consumeOAuthFragment();
if (oauthResult.message) {
return { status: "unauthenticated", message: oauthResult.message };
}
let session = loadSession();
if (!session) return { status: "unauthenticated" };
const now = Math.floor(Date.now() / 1000);
if (session.expiresAt <= now + 30) {
session = await refreshSession(session);
if (!session) return { status: "unauthenticated" };
}
let user = await getUser(session.accessToken);
if (!user) {
const refreshed = await refreshSession(session);
if (!refreshed) return { status: "unauthenticated" };
session = refreshed;
user = await getUser(refreshed.accessToken);
}
if (!user) {
clearSession();
return { status: "unauthenticated" };
}
// La fuente de verdad del acceso ya no está en el frontend. Supabase decide
// si el correo puede entrar y si puede administrar formularios.
const access = await getMyAccess(session.accessToken);
if (!access.allowed) {
await signOut();
return {
status: "unauthenticated",
message: `La cuenta ${user.email} no tiene acceso a Cruces GLM.`,
};
}
if (access.displayName && !user.user_metadata?.["full_name"]) {
user = {
...user,
user_metadata: {
...(user.user_metadata ?? {}),
full_name: access.displayName,
},
};
}
if (oauthResult.freshLogin) {
await recordLoginMetric(session.accessToken);
}
return {
status: "authenticated",
user,
canManageForms: access.canManageForms,
};
} catch (error) {
clearSession();
return {
status: "unauthenticated",
message: error instanceof Error ? error.message : "No se pudo validar la sesión.",
};
}
}
export function signInWithGoogle() {
assertConfig();
if (typeof window === "undefined") return;
const redirectTo = getRedirectUrl();
const params = new URLSearchParams({
provider: "google",
redirect_to: redirectTo,
});
window.location.assign(`${SUPABASE_URL}/auth/v1/authorize?${params.toString()}`);
}
export async function signOut() {
const session = loadSession();
clearSession();
if (!session || !SUPABASE_URL || !SUPABASE_ANON_KEY) return;
try {
await fetch(`${SUPABASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${session.accessToken}`,
},
});
} catch {
// La sesión local ya quedó eliminada; el cierre remoto es best-effort.
}
}
+399
View File
@@ -0,0 +1,399 @@
import { useCallback, useEffect, useState } from "react";
import { DEMO_AUTH_ENABLED } from "@/lib/auth";
import { callAuthenticatedRpc } from "@/lib/supabase-api";
export type GlmForm = {
id: string;
nombre: string;
descripcion: string;
categoria: string;
url: string;
activo: boolean;
};
export const CATEGORIAS = ["Seguridad Social", "IR", "Reportes"] as const;
// Estos datos solo son el fallback del modo demostrativo. En modo real la fuente
// de verdad es public.cruces_glm_forms en Supabase.
export const FORMULARIOS_SEED: GlmForm[] = [
{
id: "cruce-facturacion-clientes",
nombre: "Cruce de facturación vs. órdenes de cliente",
descripcion:
"Compara las facturas emitidas contra las órdenes de compra registradas y devuelve las diferencias por cuenta.",
categoria: "Reportes",
url: "https://n8n.example.com/form/cruce-facturacion-clientes",
activo: true,
},
{
id: "cruce-proveedores-pagos",
nombre: "Cruce de proveedores vs. pagos ejecutados",
descripcion:
"Valida que cada factura de proveedor cargada tenga su pago conciliado en el período seleccionado.",
categoria: "Reportes",
url: "https://n8n.example.com/form/cruce-proveedores-pagos",
activo: true,
},
{
id: "conciliacion-bancaria",
nombre: "Conciliación bancaria automática",
descripcion:
"Sube el extracto bancario y el libro mayor para obtener partidas conciliadas y pendientes.",
categoria: "Reportes",
url: "https://n8n.example.com/form/conciliacion-bancaria",
activo: true,
},
{
id: "solicitud-orden-compra",
nombre: "Solicitud de orden de compra",
descripcion:
"Registra una nueva solicitud de compra y dispara el flujo de aprobación con el área responsable.",
categoria: "Reportes",
url: "https://n8n.example.com/form/solicitud-orden-compra",
activo: true,
},
{
id: "carga-facturas-proveedor",
nombre: "Carga de facturas de proveedor",
descripcion:
"Envía la factura en PDF y sus datos fiscales para que el flujo la extraiga y la registre.",
categoria: "Reportes",
url: "https://n8n.example.com/form/carga-facturas-proveedor",
activo: true,
},
{
id: "reporte-cuentas-por-cobrar",
nombre: "Reporte de cuentas por cobrar",
descripcion:
"Genera el aging de cartera por cliente y lo envía por correo al cierre del período elegido.",
categoria: "Reportes",
url: "https://n8n.example.com/form/reporte-cuentas-por-cobrar",
activo: true,
},
];
const LEGACY_FORMS_KEY = "glm-hub-formularios-v1";
const MIGRATION_KEY = "cruces-glm-supabase-forms-migrated-v1";
const FAVS_KEY = "glm-hub-favoritos-v1";
const RECENT_KEY = "glm-hub-recientes-v1";
const CATEGORIA_SET = new Set<string>(CATEGORIAS);
type RpcFormRow = {
id?: string;
nombre?: string;
descripcion?: string;
categoria?: string;
url?: string;
activo?: boolean;
};
function normalizarCategoria(categoria: string) {
return CATEGORIA_SET.has(categoria) ? categoria : "Reportes";
}
function normalizarFormulario(form: GlmForm): GlmForm {
return {
...form,
categoria: normalizarCategoria(form.categoria),
};
}
function normalizarFormularios(formularios: GlmForm[]) {
return formularios.map(normalizarFormulario);
}
function fromRpcRow(row: RpcFormRow): GlmForm | null {
if (
typeof row.id !== "string" ||
typeof row.nombre !== "string" ||
typeof row.descripcion !== "string" ||
typeof row.categoria !== "string" ||
typeof row.url !== "string" ||
typeof row.activo !== "boolean"
) {
return null;
}
return normalizarFormulario({
id: row.id,
nombre: row.nombre,
descripcion: row.descripcion,
categoria: row.categoria,
url: row.url,
activo: row.activo,
});
}
function read<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function write(key: string, value: unknown) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* almacenamiento no disponible */
}
}
function hasMigrationMarker() {
if (typeof window === "undefined") return true;
return window.localStorage.getItem(MIGRATION_KEY) === "1";
}
function markMigrationComplete() {
if (typeof window === "undefined") return;
window.localStorage.setItem(MIGRATION_KEY, "1");
}
function readLegacyFormsIfPresent(): GlmForm[] | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(LEGACY_FORMS_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return null;
const forms = parsed
.filter((item): item is GlmForm => {
if (!item || typeof item !== "object") return false;
const row = item as Partial<GlmForm>;
return (
typeof row.id === "string" &&
typeof row.nombre === "string" &&
typeof row.descripcion === "string" &&
typeof row.categoria === "string" &&
typeof row.url === "string" &&
typeof row.activo === "boolean"
);
})
.map(normalizarFormulario);
return forms;
} catch {
return null;
}
}
async function rpcListForms(): Promise<GlmForm[]> {
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_get_forms");
return rows.map(fromRpcRow).filter((form): form is GlmForm => form !== null);
}
async function rpcGetForm(formId: string): Promise<GlmForm | null> {
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_get_form", {
p_form_id: formId,
});
return fromRpcRow(rows[0] ?? {});
}
async function rpcSaveForm(form: GlmForm): Promise<GlmForm> {
const normalizado = normalizarFormulario(form);
const rows = await callAuthenticatedRpc<RpcFormRow[]>("cruces_glm_save_form", {
p_form_id: normalizado.id,
p_nombre: normalizado.nombre,
p_descripcion: normalizado.descripcion,
p_categoria: normalizado.categoria,
p_url: normalizado.url,
p_activo: normalizado.activo,
});
const saved = fromRpcRow(rows[0] ?? {});
if (!saved) throw new Error("Supabase no devolvió el formulario guardado.");
return saved;
}
async function rpcDeleteForm(formId: string): Promise<void> {
await callAuthenticatedRpc<boolean>("cruces_glm_delete_form", {
p_form_id: formId,
});
}
async function migrateLegacyFormsIfNeeded(canManageForms: boolean, remoteForms: GlmForm[]) {
if (DEMO_AUTH_ENABLED || hasMigrationMarker()) return remoteForms;
if (remoteForms.length > 0) {
markMigrationComplete();
return remoteForms;
}
// Solo un administrador puede migrar la información histórica del navegador.
if (!canManageForms) return remoteForms;
const legacyForms = readLegacyFormsIfPresent();
if (!legacyForms || legacyForms.length === 0) {
markMigrationComplete();
return remoteForms;
}
for (const form of legacyForms) {
await rpcSaveForm(form);
}
markMigrationComplete();
return rpcListForms();
}
export function slugify(value: string) {
return (
value
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 48) || `formulario-${Date.now()}`
);
}
export function useFormularios(canManageForms = false) {
const [formularios, setFormularios] = useState<GlmForm[]>(
DEMO_AUTH_ENABLED ? FORMULARIOS_SEED : [],
);
const [favoritos, setFavoritos] = useState<string[]>([]);
const [recientes, setRecientes] = useState<string[]>([]);
const [listo, setListo] = useState(false);
const [errorCarga, setErrorCarga] = useState<string | null>(null);
const cargar = useCallback(async () => {
setErrorCarga(null);
try {
if (DEMO_AUTH_ENABLED) {
const cargados = normalizarFormularios(
read<GlmForm[]>(LEGACY_FORMS_KEY, FORMULARIOS_SEED),
);
setFormularios(cargados);
write(LEGACY_FORMS_KEY, cargados);
} else {
let cargados = await rpcListForms();
cargados = await migrateLegacyFormsIfNeeded(canManageForms, cargados);
setFormularios(cargados);
}
} catch (error) {
setFormularios([]);
setErrorCarga(
error instanceof Error
? error.message
: "No se pudieron cargar los formularios desde Supabase.",
);
} finally {
setListo(true);
}
}, [canManageForms]);
useEffect(() => {
setFavoritos(read<string[]>(FAVS_KEY, []));
setRecientes(read<string[]>(RECENT_KEY, []));
void cargar();
}, [cargar]);
useEffect(() => {
if (DEMO_AUTH_ENABLED || typeof window === "undefined") return;
const refreshOnFocus = () => {
if (document.visibilityState === "visible") void cargar();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [cargar]);
const guardar = useCallback(async (form: GlmForm) => {
const normalizado = normalizarFormulario(form);
if (DEMO_AUTH_ENABLED) {
setFormularios((prev) => {
const existe = prev.some((f) => f.id === normalizado.id);
const next = existe
? prev.map((f) => (f.id === normalizado.id ? normalizado : f))
: [normalizado, ...prev];
write(LEGACY_FORMS_KEY, next);
return next;
});
return normalizado;
}
const saved = await rpcSaveForm(normalizado);
setFormularios((prev) => {
const existe = prev.some((f) => f.id === saved.id);
return existe ? prev.map((f) => (f.id === saved.id ? saved : f)) : [saved, ...prev];
});
return saved;
}, []);
const eliminar = useCallback(async (id: string) => {
if (DEMO_AUTH_ENABLED) {
setFormularios((prev) => {
const next = prev.filter((f) => f.id !== id);
write(LEGACY_FORMS_KEY, next);
return next;
});
return;
}
await rpcDeleteForm(id);
setFormularios((prev) => prev.filter((f) => f.id !== id));
setFavoritos((prev) => {
const next = prev.filter((formId) => formId !== id);
write(FAVS_KEY, next);
return next;
});
setRecientes((prev) => {
const next = prev.filter((formId) => formId !== id);
write(RECENT_KEY, next);
return next;
});
}, []);
const alternarFavorito = useCallback((id: string) => {
setFavoritos((prev) => {
const next = prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id];
write(FAVS_KEY, next);
return next;
});
}, []);
const registrarUso = useCallback((id: string) => {
setRecientes((prev) => {
const next = [id, ...prev.filter((r) => r !== id)].slice(0, 6);
write(RECENT_KEY, next);
return next;
});
}, []);
return {
formularios,
favoritos,
recientes,
listo,
errorCarga,
guardar,
eliminar,
alternarFavorito,
registrarUso,
recargar: cargar,
};
}
export async function obtenerFormularioPorId(formId: string): Promise<GlmForm | null> {
if (DEMO_AUTH_ENABLED) {
const forms = normalizarFormularios(read<GlmForm[]>(LEGACY_FORMS_KEY, FORMULARIOS_SEED));
return forms.find((form) => form.id === formId) ?? null;
}
return rpcGetForm(formId);
}
+19
View File
@@ -0,0 +1,19 @@
import { DEMO_AUTH_ENABLED } from "@/lib/auth";
import type { GlmForm } from "@/lib/glm-forms";
import { callAuthenticatedRpc } from "@/lib/supabase-api";
/**
* Registra una apertura real. El RPC toma nombre/categoría directamente desde
* cruces_glm_forms para no confiar en datos enviados por el navegador.
*/
export async function recordFormOpened(form: GlmForm) {
if (DEMO_AUTH_ENABLED || !form.activo) return;
try {
await callAuthenticatedRpc<void>("cruces_glm_record_form_open", {
p_form_id: form.id,
});
} catch {
// Una falla de telemetría no debe bloquear la navegación del usuario.
}
}
+69
View File
@@ -0,0 +1,69 @@
import { DEMO_AUTH_ENABLED, getStoredAccessToken } from "@/lib/auth";
const SUPABASE_URL = (import.meta.env["VITE_SUPABASE_URL"] ?? "").replace(/\/+$/, "");
const SUPABASE_ANON_KEY = import.meta.env["VITE_SUPABASE_ANON_KEY"] ?? "";
function parseErrorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
const message = record["message"];
const error = record["error"];
const details = record["details"];
if (typeof message === "string" && message.trim()) return message;
if (typeof error === "string" && error.trim()) return error;
if (typeof details === "string" && details.trim()) return details;
return fallback;
}
export function hasRealSupabaseSession() {
return !DEMO_AUTH_ENABLED && Boolean(getStoredAccessToken());
}
export async function callAuthenticatedRpc<T>(
functionName: string,
payload: Record<string, unknown> = {},
): Promise<T> {
if (DEMO_AUTH_ENABLED) {
throw new Error("Esta operación de Supabase no está disponible en modo demostrativo.");
}
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error("Faltan las variables VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY.");
}
const accessToken = getStoredAccessToken();
if (!accessToken) {
throw new Error("No hay una sesión autenticada activa.");
}
const response = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${functionName}`, {
method: "POST",
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
const text = await response.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text) as unknown;
} catch {
parsed = text;
}
}
if (!response.ok) {
throw new Error(
parseErrorMessage(parsed, `Supabase rechazó la operación ${functionName} (${response.status}).`),
);
}
return parsed as T;
}
+25
View File
@@ -0,0 +1,25 @@
export type AppTheme = "light" | "dark";
const THEME_STORAGE_KEY = "cruces-glm-theme";
export function getStoredTheme(): AppTheme {
if (typeof window === "undefined") return "light";
return window.localStorage.getItem(THEME_STORAGE_KEY) === "dark" ? "dark" : "light";
}
export function applyTheme(theme: AppTheme) {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
export function saveTheme(theme: AppTheme) {
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}
applyTheme(theme);
}
export function initializeTheme() {
applyTheme(getStoredTheme());
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "./router";
import "./styles.css";
import { initializeTheme } from "./lib/theme";
initializeTheme();
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("No se encontró el elemento raíz de Cruces GLM.");
}
createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+78
View File
@@ -0,0 +1,78 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as FormulariosFormIdRouteImport } from './routes/formularios.$formId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const FormulariosFormIdRoute = FormulariosFormIdRouteImport.update({
id: '/formularios/$formId',
path: '/formularios/$formId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/formularios/$formId': typeof FormulariosFormIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/formularios/$formId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/formularios/$formId'
id: '__root__' | '/' | '/formularios/$formId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
FormulariosFormIdRoute: typeof FormulariosFormIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/formularios/$formId': {
id: '/formularios/$formId'
path: '/formularios/$formId'
fullPath: '/formularios/$formId'
preLoaderRoute: typeof FormulariosFormIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
FormulariosFormIdRoute: FormulariosFormIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+20
View File
@@ -0,0 +1,20 @@
import { QueryClient } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const queryClient = new QueryClient();
const basepath = import.meta.env.BASE_URL.replace(/\/$/, "") || "/";
export const router = createRouter({
routeTree,
context: { queryClient },
basepath,
scrollRestoration: true,
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Link, Outlet, createRootRouteWithContext, useRouter } from "@tanstack/react-router";
import { AuthGate } from "@/components/glm/auth-gate";
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Página no encontrada</h2>
<p className="mt-2 text-sm text-muted-foreground">
La página que buscas no existe o fue movida.
</p>
<div className="mt-6">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Ir al inicio
</Link>
</div>
</div>
</div>
);
}
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
Esta página no pudo cargar
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Ocurrió un error. Puedes intentar nuevamente o volver al inicio.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button
onClick={() => {
router.invalidate();
reset();
}}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Intentar nuevamente
</button>
<Link
to="/"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
Ir al inicio
</Link>
</div>
</div>
</div>
);
}
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootComponent,
notFoundComponent: NotFoundComponent,
errorComponent: ErrorComponent,
});
function RootComponent() {
const { queryClient } = Route.useRouteContext();
return (
<QueryClientProvider client={queryClient}>
<AuthGate>
<Outlet />
</AuthGate>
</QueryClientProvider>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ArrowLeft, ExternalLink, Link2, LoaderCircle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SiteHeader } from "@/components/glm/site-header";
import { ReportIssue } from "@/components/glm/report-issue";
import { obtenerFormularioPorId, type GlmForm } from "@/lib/glm-forms";
import { recordFormOpened } from "@/lib/metrics";
export const Route = createFileRoute("/formularios/$formId")({
component: FormularioPage,
});
function FormularioPage() {
const { formId } = Route.useParams();
const [form, setForm] = useState<GlmForm | null>(null);
const [listo, setListo] = useState(false);
const [errorCarga, setErrorCarga] = useState<string | null>(null);
const [recarga, setRecarga] = useState(0);
const [copiado, setCopiado] = useState(false);
useEffect(() => {
let alive = true;
setListo(false);
setErrorCarga(null);
setForm(null);
obtenerFormularioPorId(formId)
.then((encontrado) => {
if (!alive) return;
setForm(encontrado);
setListo(true);
if (encontrado?.activo) {
void recordFormOpened(encontrado);
}
})
.catch((error) => {
if (!alive) return;
setErrorCarga(
error instanceof Error
? error.message
: "No se pudo consultar este formulario en Supabase.",
);
setListo(true);
});
return () => {
alive = false;
};
}, [formId]);
const copiar = async () => {
if (!form) return;
try {
await navigator.clipboard.writeText(form.url);
setCopiado(true);
setTimeout(() => setCopiado(false), 1800);
} catch {
/* portapapeles no disponible */
}
};
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="mx-auto w-full max-w-6xl flex-1 px-6 py-8">
<Link
to="/"
className="inline-flex items-center gap-1.5 text-xs font-bold tracking-[0.08em] text-muted-foreground uppercase transition-colors hover:text-glm-green"
>
<ArrowLeft className="size-3.5" /> Volver al hub
</Link>
{!listo ? (
<div className="mt-10 flex items-center gap-3 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" /> Cargando formulario
</div>
) : errorCarga ? (
<div className="mt-10 rounded-md border border-border bg-glm-section-bg p-8">
<h1 className="text-xl font-bold text-glm-blue">No se pudo cargar el formulario</h1>
<p className="mt-2 text-sm text-glm-body">{errorCarga}</p>
</div>
) : !form ? (
<div className="mt-10 glm-card p-8">
<h1 className="text-xl font-bold text-glm-blue">Formulario no encontrado</h1>
<p className="mt-2 text-sm text-glm-body">
Este formulario ya no existe en el hub o fue eliminado.
</p>
</div>
) : !form.activo ? (
<div className="mt-10 rounded-md border border-border bg-muted/45 p-8">
<span className="text-[11px] font-bold tracking-[0.16em] text-muted-foreground uppercase">
{form.categoria}
</span>
<h1 className="mt-2 text-2xl font-bold text-muted-foreground">{form.nombre}</h1>
<p className="mt-3 max-w-2xl text-sm text-muted-foreground">
Este formulario no está disponible actualmente. Cuando vuelva a habilitarse podrás
abrirlo desde Cruces GLM.
</p>
</div>
) : (
<>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4">
<div>
<span className="text-[11px] font-bold tracking-[0.16em] text-glm-green uppercase">
{form.categoria}
</span>
<h1 className="mt-1 max-w-3xl text-3xl leading-tight font-bold text-glm-blue">
{form.nombre}
</h1>
<p className="mt-2 max-w-2xl text-sm text-glm-body">{form.descripcion}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={copiar}>
<Link2 className="size-4" /> {copiado ? "Copiado" : "Copiar enlace"}
</Button>
<Button variant="outline" size="sm" onClick={() => setRecarga((r) => r + 1)}>
<RotateCw className="size-4" /> Reiniciar
</Button>
<Button size="sm" asChild>
<a href={form.url} target="_blank" rel="noreferrer">
<ExternalLink className="size-4" /> Abrir en pestaña nueva
</a>
</Button>
</div>
</div>
<div className="mt-6 overflow-hidden rounded-md border border-border">
<div className="glm-strip" />
<iframe
key={recarga}
src={form.url}
title={form.nombre}
className="h-[72vh] min-h-[520px] w-full bg-background"
/>
</div>
<p className="mt-3 text-[11px] italic text-muted-foreground">
Si el formulario no carga aquí, presiona el botón Abrir en pestaña nueva.
</p>
</>
)}
<ReportIssue />
</main>
</div>
);
}
+387
View File
@@ -0,0 +1,387 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SiteHeader } from "@/components/glm/site-header";
import { FormCard } from "@/components/glm/form-card";
import { FormDialog } from "@/components/glm/form-dialog";
import { ReportIssue } from "@/components/glm/report-issue";
import { CATEGORIAS, useFormularios, type GlmForm } from "@/lib/glm-forms";
import { useAuth } from "@/lib/auth-context";
export const Route = createFileRoute("/")({
component: Index,
});
const PAGE_SIZE = 15;
const CATEGORIAS_HUB = ["Todos", ...CATEGORIAS] as const;
type PaginationItem = number | "start-ellipsis" | "end-ellipsis";
function buildPagination(currentPage: number, totalPages: number): PaginationItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "end-ellipsis", totalPages];
}
if (currentPage >= totalPages - 3) {
return [
1,
"start-ellipsis",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
return [
1,
"start-ellipsis",
currentPage - 1,
currentPage,
currentPage + 1,
"end-ellipsis",
totalPages,
];
}
function Index() {
const { canManageForms } = useAuth();
const {
formularios,
favoritos,
recientes,
listo,
errorCarga,
guardar,
eliminar,
alternarFavorito,
registrarUso,
} = useFormularios(canManageForms);
const [busqueda, setBusqueda] = useState("");
const [categoria, setCategoria] = useState<(typeof CATEGORIAS_HUB)[number]>("Todos");
const [paginaPorCategoria, setPaginaPorCategoria] = useState<Record<string, number>>({
Todos: 1,
});
const [dialogo, setDialogo] = useState(false);
const [editando, setEditando] = useState<GlmForm | null>(null);
const [porEliminar, setPorEliminar] = useState<GlmForm | null>(null);
const filtrados = useMemo(() => {
const q = busqueda.trim().toLowerCase();
return formularios
.filter((f) => categoria === "Todos" || f.categoria === categoria)
.filter(
(f) =>
!q ||
`${f.nombre} ${f.descripcion} ${f.categoria}`.toLowerCase().includes(q),
)
.sort((a, b) => {
const fa = favoritos.includes(a.id) ? 0 : 1;
const fb = favoritos.includes(b.id) ? 0 : 1;
return fa - fb || a.nombre.localeCompare(b.nombre);
});
}, [formularios, categoria, busqueda, favoritos]);
const totalPaginas = Math.max(1, Math.ceil(filtrados.length / PAGE_SIZE));
const paginaGuardada = paginaPorCategoria[categoria] ?? 1;
const paginaActual = Math.min(paginaGuardada, totalPaginas);
const inicio = (paginaActual - 1) * PAGE_SIZE;
const formulariosPagina = filtrados.slice(inicio, inicio + PAGE_SIZE);
const itemsPaginacion = buildPagination(paginaActual, totalPaginas);
const cambiarPagina = (pagina: number) => {
const siguiente = Math.min(Math.max(pagina, 1), totalPaginas);
setPaginaPorCategoria((prev) => ({ ...prev, [categoria]: siguiente }));
};
const cambiarCategoria = (nuevaCategoria: (typeof CATEGORIAS_HUB)[number]) => {
setCategoria(nuevaCategoria);
setPaginaPorCategoria((prev) => ({
...prev,
[nuevaCategoria]: prev[nuevaCategoria] ?? 1,
}));
};
const recientesForms = recientes
.map((id) => formularios.find((f) => f.id === id))
.filter((f): f is GlmForm => f != null && f.activo)
.slice(0, 4);
const abrirFormulario = (form: GlmForm) => {
if (!form.activo) return;
registrarUso(form.id);
};
const guardarFormulario = async (form: GlmForm) => {
await guardar(form);
};
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="mx-auto w-full max-w-6xl flex-1 px-6">
{/* Hero */}
<section className="pt-14 pb-10">
<h1 className="max-w-3xl text-4xl leading-[1.1] font-bold text-glm-blue sm:text-5xl">
Cruces GLM
</h1>
<p className="mt-5 max-w-2xl text-[15px] leading-relaxed text-glm-body">
Un solo lugar para acceder a los cruces desarrollados para el área de administración.
Busca el formulario, ábrelo y ejecuta el cruce sin tener que buscar enlaces sueltos.
</p>
<dl className="mt-8 flex flex-wrap gap-x-12 gap-y-4">
{[
{ k: "Formularios", v: formularios.length },
{ k: "Disponibles", v: formularios.filter((f) => f.activo).length },
{ k: "Categorías", v: CATEGORIAS.length },
{ k: "Favoritos", v: favoritos.length },
].map((s) => (
<div key={s.k}>
<dd className="text-3xl font-bold text-glm-blue">{s.v}</dd>
<dt className="mt-1 text-[11px] tracking-[0.14em] text-muted-foreground uppercase">
{s.k}
</dt>
</div>
))}
</dl>
</section>
<div className="glm-rule" />
{/* Recientes */}
{listo && recientesForms.length > 0 && (
<section className="py-8">
<h2 className="glm-section-title inline-block text-xs">Usados recientemente</h2>
<div className="mt-4 flex flex-wrap gap-2">
{recientesForms.map((f) => (
<Link
key={f.id}
to="/formularios/$formId"
params={{ formId: f.id }}
onClick={() => abrirFormulario(f)}
className="rounded-full border border-border px-3.5 py-1.5 text-xs text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg"
>
{f.nombre}
</Link>
))}
</div>
</section>
)}
{/* Controles */}
<section className="py-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-sm">
<Search className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={busqueda}
onChange={(e) => {
setBusqueda(e.target.value);
setPaginaPorCategoria((prev) => ({ ...prev, [categoria]: 1 }));
}}
placeholder="Buscar formulario o proceso…"
className="pl-9"
/>
</div>
{canManageForms && (
<Button
size="sm"
onClick={() => {
setEditando(null);
setDialogo(true);
}}
>
<Plus className="size-4" /> Agregar formulario
</Button>
)}
</div>
<div className="mt-6 overflow-x-auto pb-1">
<div
role="tablist"
aria-label="Secciones de Cruces GLM"
className="grid w-full min-w-[640px] max-w-3xl grid-cols-4 gap-1 rounded-xl border border-border bg-muted/55 p-1 shadow-sm"
>
{CATEGORIAS_HUB.map((c) => {
const activa = c === categoria;
return (
<button
key={c}
type="button"
role="tab"
aria-selected={activa}
onClick={() => cambiarCategoria(c)}
className={
activa
? "relative w-full rounded-lg border border-glm-green/35 bg-card px-3 py-2.5 text-center text-xs font-bold tracking-[0.08em] text-glm-blue uppercase shadow-sm outline-none transition-all after:absolute after:right-5 after:bottom-0 after:left-5 after:h-0.5 after:rounded-full after:bg-glm-green focus:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 focus-visible:ring-offset-background"
: "w-full rounded-lg border border-transparent px-3 py-2.5 text-center text-xs font-semibold tracking-[0.08em] text-muted-foreground uppercase outline-none transition-all hover:bg-card/75 hover:text-glm-blue focus:outline-none focus-visible:ring-2 focus-visible:ring-glm-green focus-visible:ring-offset-2 focus-visible:ring-offset-background"
}
>
{c}
</button>
);
})}
</div>
</div>
</section>
{/* Listado */}
<section className="pb-24 sm:pb-28">
{errorCarga && (
<div className="mb-5 rounded-md border border-border bg-glm-section-bg px-4 py-3 text-sm text-glm-blue">
No se pudieron cargar los formularios: {errorCarga}
</div>
)}
{filtrados.length === 0 ? (
<div className="glm-card flex flex-col items-center p-12 text-center">
<p className="text-sm text-glm-body">
No hay formularios que coincidan con esta sección o búsqueda.
</p>
</div>
) : (
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{formulariosPagina.map((f) => (
<FormCard
key={f.id}
form={f}
favorito={favoritos.includes(f.id)}
onFavorito={alternarFavorito}
canManageForms={canManageForms}
onEditar={(form) => {
if (!canManageForms) return;
setEditando(form);
setDialogo(true);
}}
onEliminar={(form) => {
if (!canManageForms) return;
setPorEliminar(form);
}}
onAbrir={abrirFormulario}
/>
))}
</div>
)}
{totalPaginas > 1 && (
<>
<nav
className="mt-10 flex flex-wrap items-center justify-center gap-2"
aria-label={`Paginación de ${categoria}`}
>
<button
type="button"
onClick={() => cambiarPagina(paginaActual - 1)}
disabled={paginaActual === 1}
className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Página anterior"
>
<ChevronLeft className="size-4" />
</button>
{itemsPaginacion.map((item) =>
typeof item === "number" ? (
<button
key={item}
type="button"
onClick={() => cambiarPagina(item)}
aria-current={item === paginaActual ? "page" : undefined}
className={
item === paginaActual
? "inline-flex size-9 items-center justify-center rounded-md border border-glm-blue bg-glm-blue text-sm font-bold text-white shadow-sm"
: "inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-sm font-semibold text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg"
}
>
{item}
</button>
) : (
<span
key={item}
className="inline-flex size-9 items-center justify-center text-sm text-muted-foreground"
aria-hidden="true"
>
</span>
),
)}
<button
type="button"
onClick={() => cambiarPagina(paginaActual + 1)}
disabled={paginaActual === totalPaginas}
className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-glm-blue transition-colors hover:border-glm-green hover:bg-glm-leaf-bg disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Página siguiente"
>
<ChevronRight className="size-4" />
</button>
</nav>
<p className="mt-3 text-center text-[11px] text-muted-foreground">
Página {paginaActual} de {totalPaginas} · {filtrados.length} formulario
{filtrados.length === 1 ? "" : "s"}
</p>
</>
)}
</section>
<ReportIssue />
</main>
{canManageForms && (
<>
<FormDialog
open={dialogo}
onOpenChange={setDialogo}
inicial={editando}
onGuardar={guardarFormulario}
/>
<AlertDialog open={Boolean(porEliminar)} onOpenChange={(o) => !o && setPorEliminar(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-glm-blue">
¿Eliminar {porEliminar?.nombre}?
</AlertDialogTitle>
<AlertDialogDescription>
Se quitará este formulario del hub. Si lo necesitas nuevamente, podrás volver a
agregarlo desde Agregar formulario.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const objetivo = porEliminar;
setPorEliminar(null);
if (objetivo) void eliminar(objetivo.id);
}}
>
Eliminar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
@import "tailwindcss" source(none);
@source "../src";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
* GLM — GomezLee Marketing design system.
* Arial only. White surfaces, blue hierarchy (#4F758B), green accents (#6CC24A).
* Color appears as thin strips, lines, icons and text — never as large blocks.
*/
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--font-sans: Arial, Helvetica, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-ring-offset-background: var(--background);
/* GLM brand tokens */
--color-glm-green: var(--glm-green);
--color-glm-green-light: var(--glm-green-light);
--color-glm-lime: var(--glm-lime);
--color-glm-mint: var(--glm-mint);
--color-glm-blue: var(--glm-blue);
--color-glm-blue-mid: var(--glm-blue-mid);
--color-glm-steel: var(--glm-steel);
--color-glm-teal: var(--glm-teal);
--color-glm-orange: var(--glm-orange);
--color-glm-gold: var(--glm-gold);
--color-glm-leaf-bg: var(--glm-leaf-bg);
--color-glm-section-bg: var(--glm-section-bg);
--color-glm-body: var(--glm-body);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.5rem;
/* Brand palette (converted from the GLM Pantone/HEX system) */
--glm-green: oklch(0.75 0.171 141.2); /* #6CC24A */
--glm-green-light: oklch(0.82 0.148 130.5); /* #A4D65E */
--glm-lime: oklch(0.83 0.183 116.5); /* #C4D600 */
--glm-mint: oklch(0.79 0.114 165.4); /* #68D2A0 */
--glm-blue: oklch(0.55 0.043 233.4); /* #4F758B */
--glm-blue-mid: oklch(0.59 0.04 232.6); /* #5B7F95 */
--glm-steel: oklch(0.64 0.037 231.9); /* #6B8FA3 */
--glm-teal: oklch(0.47 0.055 190.3); /* #2C6E6F */
--glm-orange: oklch(0.67 0.216 41.1); /* #FF6A13 */
--glm-gold: oklch(0.84 0.121 87.5); /* #F0C75E */
--glm-leaf-bg: oklch(0.966 0.026 133.8); /* #EEF6E8 */
--glm-section-bg: oklch(0.917 0.028 234.9); /* #D6E8F4 */
--glm-body: oklch(0.4 0 0); /* #4A4A4A */
--background: oklch(1 0 0);
--foreground: oklch(0.4 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.4 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.4 0 0);
--primary: var(--glm-blue);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.968 0 0);
--secondary-foreground: var(--glm-blue);
--muted: oklch(0.968 0 0);
--muted-foreground: var(--glm-steel);
--accent: var(--glm-leaf-bg);
--accent-foreground: var(--glm-blue);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9 0 0);
--input: oklch(0.88 0 0);
--ring: var(--glm-green);
--chart-1: var(--glm-green);
--chart-2: var(--glm-blue);
--chart-3: var(--glm-lime);
--chart-4: var(--glm-orange);
--chart-5: var(--glm-steel);
--sidebar: oklch(1 0 0);
--sidebar-foreground: var(--glm-body);
--sidebar-primary: var(--glm-blue);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: var(--glm-leaf-bg);
--sidebar-accent-foreground: var(--glm-blue);
--sidebar-border: oklch(0.9 0 0);
--sidebar-ring: var(--glm-green);
--shadow-card:
0 1px 2px oklch(0.55 0.043 233.4 / 0.06), 0 8px 24px -12px oklch(0.55 0.043 233.4 / 0.18);
--shadow-card-hover:
0 2px 4px oklch(0.55 0.043 233.4 / 0.08), 0 18px 40px -18px oklch(0.55 0.043 233.4 / 0.3);
}
.dark {
--glm-green: oklch(0.75 0.171 141.2);
--glm-green-light: oklch(0.82 0.148 130.5);
--glm-lime: oklch(0.83 0.183 116.5);
--glm-mint: oklch(0.79 0.114 165.4);
--glm-blue: oklch(0.72 0.045 233.4);
--glm-blue-mid: oklch(0.68 0.042 232.6);
--glm-steel: oklch(0.74 0.036 231.9);
--glm-teal: oklch(0.72 0.055 190.3);
--glm-orange: oklch(0.72 0.19 41.1);
--glm-gold: oklch(0.84 0.121 87.5);
--glm-leaf-bg: oklch(0.27 0.045 141.2);
--glm-section-bg: oklch(0.29 0.035 233.4);
--glm-body: oklch(0.9 0.008 230);
--background: oklch(0.18 0.015 230);
--foreground: oklch(0.94 0.004 230);
--card: oklch(0.22 0.016 230);
--card-foreground: oklch(0.94 0.004 230);
--popover: oklch(0.22 0.016 230);
--popover-foreground: oklch(0.94 0.004 230);
--primary: oklch(0.56 0.043 233.4);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.27 0.015 230);
--secondary-foreground: oklch(0.92 0.006 230);
--muted: oklch(0.27 0.015 230);
--muted-foreground: oklch(0.7 0.025 232);
--accent: oklch(0.28 0.04 141.2);
--accent-foreground: oklch(0.94 0.004 230);
--destructive: oklch(0.58 0.2 27.3);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.33 0.018 230);
--input: oklch(0.36 0.018 230);
--ring: var(--glm-green);
--sidebar: var(--card);
--sidebar-foreground: var(--foreground);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: var(--accent);
--sidebar-accent-foreground: var(--accent-foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
--shadow-card:
0 1px 2px oklch(0 0 0 / 0.28), 0 10px 28px -16px oklch(0 0 0 / 0.7);
--shadow-card-hover:
0 2px 4px oklch(0 0 0 / 0.32), 0 18px 42px -18px oklch(0 0 0 / 0.78);
}
@layer base {
* {
border-color: var(--color-border);
}
html {
font-family: Arial, Helvetica, sans-serif;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
transition: background-color 180ms ease, color 180ms ease;
}
}
/* GLM section title: blue caps with a thin green underline */
@utility glm-section-title {
color: var(--glm-blue);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding-bottom: 0.35rem;
border-bottom: 2px solid var(--glm-green);
}
@utility glm-card {
background-color: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
transition:
box-shadow 220ms ease,
transform 220ms ease,
border-color 220ms ease;
&:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--glm-green) 45%, transparent);
}
}
@utility glm-strip {
height: 4px;
width: 100%;
background: linear-gradient(90deg, var(--glm-green), var(--glm-lime));
}
@utility glm-rule {
height: 1px;
width: 100%;
background: var(--color-border);
}
/* Login de Cruces GLM — inspirado en la pantalla de acceso de CDC Brief. */
.glm-login-background {
display: grid;
place-items: center;
background-color: #f8faf8;
background-image:
radial-gradient(circle at 10% 14%, rgba(164, 214, 94, 0.13), transparent 28rem),
radial-gradient(circle at 91% 19%, rgba(91, 127, 149, 0.18), transparent 31rem),
linear-gradient(rgba(79, 117, 139, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(79, 117, 139, 0.035) 1px, transparent 1px);
background-size:
auto,
auto,
48px 48px,
48px 48px;
}
.glm-login-card {
position: relative;
width: min(100%, 390px);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 31px;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 26px 65px rgba(79, 117, 139, 0.14),
0 2px 8px rgba(79, 117, 139, 0.05);
backdrop-filter: blur(12px);
}
.glm-login-topline {
position: absolute;
inset: 0 0 auto;
height: 4px;
background: linear-gradient(90deg, #6cc24a 0%, #68d2a0 28%, #4f758b 100%);
}
/* Formularios no disponibles: visibles para referencia/edición, pero claramente desactivados. */
.glm-card-disabled {
filter: grayscale(1);
opacity: 0.62;
}
.glm-card-disabled:hover {
transform: none;
border-color: var(--color-border);
box-shadow: var(--shadow-card);
}
+977
View File
@@ -0,0 +1,977 @@
-- ============================================================================
-- CRUCES GLM — SUPABASE COMPLETO
-- GomezLee Marketing
-- ============================================================================
-- Ejecutar una vez en SQL Editor.
--
-- Supabase queda como fuente de verdad para:
-- 1) Usuarios autorizados para iniciar sesión en Cruces GLM.
-- 2) Permiso para Agregar / Editar / Eliminar formularios.
-- 3) Datos de todos los formularios/cruces del Hub.
-- 4) Sumatorias globales: formularios creados, aperturas y logueos.
-- 5) Métricas por formulario y por usuario.
-- 6) Historial de eventos.
-- 7) Consultas y mutaciones mediante RPC; el frontend no necesita acceso
-- directo a las tablas.
--
-- IMPORTANTE:
-- Google/Supabase puede completar el OAuth, pero Cruces GLM consulta
-- cruces_glm_get_my_access() inmediatamente. Si is_allowed = false o el correo
-- no existe en cruces_glm_permissions, la app cierra esa sesión y no permite
-- entrar. Todas las RPC de datos vuelven a validar el acceso en servidor.
-- ============================================================================
begin;
-- --------------------------------------------------------------------------
-- 1. ACCESOS / PERMISOS
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_permissions (
email text primary key,
display_name text,
is_allowed boolean not null default true,
can_manage_forms boolean not null default false,
updated_at timestamptz not null default now(),
constraint cruces_glm_permissions_email_lowercase check (email = lower(email))
);
-- Compatibilidad con versiones anteriores del script.
alter table public.cruces_glm_permissions
add column if not exists display_name text;
alter table public.cruces_glm_permissions
add column if not exists is_allowed boolean not null default true;
alter table public.cruces_glm_permissions
add column if not exists can_manage_forms boolean not null default false;
alter table public.cruces_glm_permissions
add column if not exists updated_at timestamptz not null default now();
alter table public.cruces_glm_permissions enable row level security;
create or replace function public.cruces_glm_set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists cruces_glm_permissions_set_updated_at
on public.cruces_glm_permissions;
create trigger cruces_glm_permissions_set_updated_at
before update on public.cruces_glm_permissions
for each row execute function public.cruces_glm_set_updated_at();
-- Lista inicial autorizada. Solo Isaac administra formularios inicialmente.
-- ON CONFLICT preserva is_allowed/can_manage_forms para no deshacer cambios
-- manuales posteriores si actualizas solamente nombres.
insert into public.cruces_glm_permissions (
email,
display_name,
is_allowed,
can_manage_forms
)
values
('mgomez@gomezleemarketing.com', 'Máximo Gómez', true, false),
('iaracena@gomezleemarketing.com', 'Isaac Aracena', true, true),
('jgomez@gomezleemarketing.com', 'José Leopoldo Gómez', true, false),
('lmatos@gomezleemarketing.com', 'Luis Matos', true, false),
('ymadera@gomezleemarketing.com', 'Yanelly Madera', true, false),
('iherrera@gomezleemarketing.com', 'Iveth Herrera', true, false),
('msoto@gomezleemarketing.com', 'Mati Soto', true, false),
('vparamo@gomezleemarketing.com', 'Viviana Páramo', true, false),
('asrodriguez@gomezleemarketing.com', 'Ada Rodríguez', true, false),
('administrativonicaragua@gomezleemarketing.com', 'Estefani Dayana Orozco Dávila', true, false),
('administrativo@gomezleemarketing.com', 'Seyling Zambrana', true, false),
('adminregional@gomezleemarketing.com', 'Liliana Benítez', true, false)
on conflict (email) do update
set display_name = excluded.display_name;
-- --------------------------------------------------------------------------
-- 2. FORMULARIOS / CRUCES — FUENTE DE VERDAD
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_forms (
id text primary key,
nombre text not null,
descripcion text not null,
categoria text not null,
url text not null,
activo boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
created_by text,
updated_by text,
constraint cruces_glm_forms_categoria_check
check (categoria in ('Seguridad Social', 'IR', 'Reportes')),
constraint cruces_glm_forms_nombre_not_blank check (length(trim(nombre)) > 0),
constraint cruces_glm_forms_descripcion_not_blank check (length(trim(descripcion)) > 0),
constraint cruces_glm_forms_url_not_blank check (length(trim(url)) > 0)
);
create index if not exists cruces_glm_forms_categoria_idx
on public.cruces_glm_forms (categoria, nombre);
create index if not exists cruces_glm_forms_activo_idx
on public.cruces_glm_forms (activo, categoria);
alter table public.cruces_glm_forms enable row level security;
drop trigger if exists cruces_glm_forms_set_updated_at
on public.cruces_glm_forms;
create trigger cruces_glm_forms_set_updated_at
before update on public.cruces_glm_forms
for each row execute function public.cruces_glm_set_updated_at();
-- --------------------------------------------------------------------------
-- 3. MÉTRICAS GLOBALES
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_metrics_summary (
id smallint primary key default 1,
total_formularios_colocados bigint not null default 0 check (total_formularios_colocados >= 0),
total_formularios_abiertos bigint not null default 0 check (total_formularios_abiertos >= 0),
total_logueos bigint not null default 0 check (total_logueos >= 0),
updated_at timestamptz not null default now(),
constraint cruces_glm_metrics_summary_single_row check (id = 1)
);
insert into public.cruces_glm_metrics_summary (id)
values (1)
on conflict (id) do nothing;
alter table public.cruces_glm_metrics_summary enable row level security;
-- --------------------------------------------------------------------------
-- 4. MÉTRICAS POR FORMULARIO
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_form_metrics (
form_id text primary key,
form_name text not null,
category text,
total_publicaciones bigint not null default 1 check (total_publicaciones >= 0),
total_aperturas bigint not null default 0 check (total_aperturas >= 0),
first_published_at timestamptz not null default now(),
last_published_at timestamptz not null default now(),
last_opened_at timestamptz
);
alter table public.cruces_glm_form_metrics enable row level security;
-- --------------------------------------------------------------------------
-- 5. MÉTRICAS POR USUARIO
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_user_metrics (
email text primary key,
total_logueos bigint not null default 0 check (total_logueos >= 0),
total_aperturas bigint not null default 0 check (total_aperturas >= 0),
last_login_at timestamptz,
last_opened_at timestamptz,
constraint cruces_glm_user_metrics_email_lowercase check (email = lower(email))
);
alter table public.cruces_glm_user_metrics enable row level security;
-- --------------------------------------------------------------------------
-- 6. HISTORIAL DE EVENTOS
-- --------------------------------------------------------------------------
create table if not exists public.cruces_glm_metric_events (
id bigint generated by default as identity primary key,
event_type text not null check (event_type in ('login', 'form_created', 'form_opened')),
user_email text not null,
form_id text,
form_name text,
category text,
created_at timestamptz not null default now()
);
create index if not exists cruces_glm_metric_events_type_idx
on public.cruces_glm_metric_events (event_type, created_at desc);
create index if not exists cruces_glm_metric_events_user_idx
on public.cruces_glm_metric_events (user_email, created_at desc);
create index if not exists cruces_glm_metric_events_form_idx
on public.cruces_glm_metric_events (form_id, created_at desc);
alter table public.cruces_glm_metric_events enable row level security;
-- --------------------------------------------------------------------------
-- 7. SEGURIDAD BASE — SIN ACCESO DIRECTO DESDE EL FRONTEND
-- --------------------------------------------------------------------------
-- El navegador usa exclusivamente RPCs SECURITY DEFINER. Esto evita depender
-- de filtros construidos por el cliente para permisos o CRUD.
revoke all on table public.cruces_glm_permissions from anon, authenticated;
revoke all on table public.cruces_glm_forms from anon, authenticated;
revoke all on table public.cruces_glm_metrics_summary from anon, authenticated;
revoke all on table public.cruces_glm_form_metrics from anon, authenticated;
revoke all on table public.cruces_glm_user_metrics from anon, authenticated;
revoke all on table public.cruces_glm_metric_events from anon, authenticated;
-- Quitar policies antiguas conocidas; el acceso normal será solo vía RPC.
drop policy if exists "Cruces GLM - leer permiso propio"
on public.cruces_glm_permissions;
drop policy if exists "Cruces GLM - administradores leen resumen"
on public.cruces_glm_metrics_summary;
drop policy if exists "Cruces GLM - administradores leen metricas formularios"
on public.cruces_glm_form_metrics;
drop policy if exists "Cruces GLM - administradores leen metricas usuarios"
on public.cruces_glm_user_metrics;
drop policy if exists "Cruces GLM - administradores leen eventos"
on public.cruces_glm_metric_events;
-- --------------------------------------------------------------------------
-- 8. HELPERS DE AUTORIZACIÓN
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_current_email()
returns text
language sql
stable
as $$
select lower(coalesce(auth.jwt() ->> 'email', ''));
$$;
create or replace function public.cruces_glm_is_allowed()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.cruces_glm_permissions p
where p.email = public.cruces_glm_current_email()
and p.is_allowed = true
);
$$;
create or replace function public.cruces_glm_can_manage_forms()
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.cruces_glm_permissions p
where p.email = public.cruces_glm_current_email()
and p.is_allowed = true
and p.can_manage_forms = true
);
$$;
revoke all on function public.cruces_glm_current_email() from public;
revoke all on function public.cruces_glm_is_allowed() from public;
revoke all on function public.cruces_glm_can_manage_forms() from public;
-- --------------------------------------------------------------------------
-- 9. RPC — ACCESO DEL USUARIO ACTUAL
-- --------------------------------------------------------------------------
-- Esta es la fuente de verdad del login de Cruces GLM. No existe allowlist
-- hardcodeada en el frontend.
create or replace function public.cruces_glm_get_my_access()
returns table (
allowed boolean,
can_manage_forms boolean,
display_name text
)
language plpgsql
stable
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
begin
return query
select
coalesce(p.is_allowed, false) as allowed,
(coalesce(p.is_allowed, false) and coalesce(p.can_manage_forms, false)) as can_manage_forms,
p.display_name
from (select v_email as email) current_user_email
left join public.cruces_glm_permissions p
on p.email = current_user_email.email;
end;
$$;
revoke all on function public.cruces_glm_get_my_access() from public;
grant execute on function public.cruces_glm_get_my_access() to authenticated;
-- --------------------------------------------------------------------------
-- 10. RPC — LISTAR FORMULARIOS
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_forms()
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
order by f.nombre asc;
end;
$$;
revoke all on function public.cruces_glm_get_forms() from public;
grant execute on function public.cruces_glm_get_forms() to authenticated;
-- --------------------------------------------------------------------------
-- 11. RPC — CONSULTAR UN FORMULARIO
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_form(p_form_id text)
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
where f.id = trim(coalesce(p_form_id, ''))
limit 1;
end;
$$;
revoke all on function public.cruces_glm_get_form(text) from public;
grant execute on function public.cruces_glm_get_form(text) to authenticated;
-- --------------------------------------------------------------------------
-- 12. RPC — CREAR / EDITAR FORMULARIO
-- --------------------------------------------------------------------------
-- Registra total_formularios_colocados únicamente cuando el ID no existía.
-- Editar un formulario no incrementa esa sumatoria.
drop function if exists public.cruces_glm_record_form_created(text, text, text);
create or replace function public.cruces_glm_save_form(
p_form_id text,
p_nombre text,
p_descripcion text,
p_categoria text,
p_url text,
p_activo boolean
)
returns table (
id text,
nombre text,
descripcion text,
categoria text,
url text,
activo boolean,
created_at timestamptz,
updated_at timestamptz
)
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
v_id text := trim(coalesce(p_form_id, ''));
v_nombre text := trim(coalesce(p_nombre, ''));
v_descripcion text := trim(coalesce(p_descripcion, ''));
v_categoria text := trim(coalesce(p_categoria, ''));
v_url text := trim(coalesce(p_url, ''));
v_activo boolean := coalesce(p_activo, false);
v_existed boolean;
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar formularios';
end if;
if v_id = '' or v_nombre = '' or v_descripcion = '' or v_categoria = '' or v_url = '' then
raise exception 'Todos los campos del formulario son obligatorios';
end if;
if v_categoria not in ('Seguridad Social', 'IR', 'Reportes') then
raise exception 'Categoría no válida';
end if;
if v_url !~* '^https?://' then
raise exception 'La URL debe comenzar con http:// o https://';
end if;
select exists (
select 1 from public.cruces_glm_forms f where f.id = v_id
) into v_existed;
insert into public.cruces_glm_forms (
id,
nombre,
descripcion,
categoria,
url,
activo,
created_by,
updated_by
)
values (
v_id,
v_nombre,
v_descripcion,
v_categoria,
v_url,
v_activo,
v_email,
v_email
)
on conflict (id) do update
set nombre = excluded.nombre,
descripcion = excluded.descripcion,
categoria = excluded.categoria,
url = excluded.url,
activo = excluded.activo,
updated_by = excluded.updated_by;
if not v_existed then
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 1, 0, 0, now())
on conflict (id) do update
set total_formularios_colocados = public.cruces_glm_metrics_summary.total_formularios_colocados + 1,
updated_at = now();
insert into public.cruces_glm_form_metrics (
form_id,
form_name,
category,
total_publicaciones,
total_aperturas,
first_published_at,
last_published_at
)
values (v_id, v_nombre, v_categoria, 1, 0, now(), now())
on conflict (form_id) do update
set form_name = excluded.form_name,
category = excluded.category,
total_publicaciones = public.cruces_glm_form_metrics.total_publicaciones + 1,
last_published_at = now();
insert into public.cruces_glm_metric_events (
event_type,
user_email,
form_id,
form_name,
category
)
values ('form_created', v_email, v_id, v_nombre, v_categoria);
else
update public.cruces_glm_form_metrics
set form_name = v_nombre,
category = v_categoria
where form_id = v_id;
end if;
return query
select
f.id,
f.nombre,
f.descripcion,
f.categoria,
f.url,
f.activo,
f.created_at,
f.updated_at
from public.cruces_glm_forms f
where f.id = v_id;
end;
$$;
revoke all on function public.cruces_glm_save_form(text, text, text, text, text, boolean) from public;
grant execute on function public.cruces_glm_save_form(text, text, text, text, text, boolean) to authenticated;
-- --------------------------------------------------------------------------
-- 13. RPC — ELIMINAR FORMULARIO
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_delete_form(p_form_id text)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_id text := trim(coalesce(p_form_id, ''));
v_deleted_count integer;
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar formularios';
end if;
delete from public.cruces_glm_forms f
where f.id = v_id;
get diagnostics v_deleted_count = row_count;
return v_deleted_count > 0;
end;
$$;
revoke all on function public.cruces_glm_delete_form(text) from public;
grant execute on function public.cruces_glm_delete_form(text) to authenticated;
-- --------------------------------------------------------------------------
-- 14. RPC — REGISTRAR LOGIN
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_record_login()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 0, 0, 1, now())
on conflict (id) do update
set total_logueos = public.cruces_glm_metrics_summary.total_logueos + 1,
updated_at = now();
insert into public.cruces_glm_user_metrics (
email,
total_logueos,
total_aperturas,
last_login_at
)
values (v_email, 1, 0, now())
on conflict (email) do update
set total_logueos = public.cruces_glm_user_metrics.total_logueos + 1,
last_login_at = now();
insert into public.cruces_glm_metric_events (event_type, user_email)
values ('login', v_email);
end;
$$;
revoke all on function public.cruces_glm_record_login() from public;
grant execute on function public.cruces_glm_record_login() to authenticated;
-- --------------------------------------------------------------------------
-- 15. RPC — REGISTRAR APERTURA
-- --------------------------------------------------------------------------
-- Solo recibe el ID. Nombre/categoría/estado salen de la tabla real para evitar
-- manipulación desde el navegador. Un formulario inactivo no se contabiliza.
drop function if exists public.cruces_glm_record_form_open(text, text, text);
create or replace function public.cruces_glm_record_form_open(p_form_id text)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := public.cruces_glm_current_email();
v_form_id text := trim(coalesce(p_form_id, ''));
v_form_name text;
v_category text;
v_active boolean;
begin
if not public.cruces_glm_is_allowed() then
raise exception 'Usuario no autorizado para Cruces GLM';
end if;
select f.nombre, f.categoria, f.activo
into v_form_name, v_category, v_active
from public.cruces_glm_forms f
where f.id = v_form_id;
if not found then
raise exception 'Formulario no encontrado';
end if;
if not v_active then
raise exception 'Formulario no disponible';
end if;
insert into public.cruces_glm_metrics_summary (
id,
total_formularios_colocados,
total_formularios_abiertos,
total_logueos,
updated_at
)
values (1, 0, 1, 0, now())
on conflict (id) do update
set total_formularios_abiertos = public.cruces_glm_metrics_summary.total_formularios_abiertos + 1,
updated_at = now();
insert into public.cruces_glm_user_metrics (
email,
total_logueos,
total_aperturas,
last_opened_at
)
values (v_email, 0, 1, now())
on conflict (email) do update
set total_aperturas = public.cruces_glm_user_metrics.total_aperturas + 1,
last_opened_at = now();
insert into public.cruces_glm_form_metrics (
form_id,
form_name,
category,
total_publicaciones,
total_aperturas,
first_published_at,
last_published_at,
last_opened_at
)
values (v_form_id, v_form_name, v_category, 0, 1, now(), now(), now())
on conflict (form_id) do update
set form_name = excluded.form_name,
category = excluded.category,
total_aperturas = public.cruces_glm_form_metrics.total_aperturas + 1,
last_opened_at = now();
insert into public.cruces_glm_metric_events (
event_type,
user_email,
form_id,
form_name,
category
)
values ('form_opened', v_email, v_form_id, v_form_name, v_category);
end;
$$;
revoke all on function public.cruces_glm_record_form_open(text) from public;
grant execute on function public.cruces_glm_record_form_open(text) to authenticated;
-- --------------------------------------------------------------------------
-- 16. RPC — CONSULTAS DE MÉTRICAS PARA FUTURO DASHBOARD
-- --------------------------------------------------------------------------
create or replace function public.cruces_glm_get_metrics_summary()
returns table (
total_formularios_colocados bigint,
total_formularios_abiertos bigint,
total_logueos bigint,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.total_formularios_colocados,
m.total_formularios_abiertos,
m.total_logueos,
m.updated_at
from public.cruces_glm_metrics_summary m
where m.id = 1;
end;
$$;
create or replace function public.cruces_glm_get_form_metrics()
returns table (
form_id text,
form_name text,
category text,
total_publicaciones bigint,
total_aperturas bigint,
first_published_at timestamptz,
last_published_at timestamptz,
last_opened_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.form_id,
m.form_name,
m.category,
m.total_publicaciones,
m.total_aperturas,
m.first_published_at,
m.last_published_at,
m.last_opened_at
from public.cruces_glm_form_metrics m
order by m.total_aperturas desc, m.form_name asc;
end;
$$;
create or replace function public.cruces_glm_get_user_metrics()
returns table (
email text,
total_logueos bigint,
total_aperturas bigint,
last_login_at timestamptz,
last_opened_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
m.email,
m.total_logueos,
m.total_aperturas,
m.last_login_at,
m.last_opened_at
from public.cruces_glm_user_metrics m
order by m.total_logueos desc, m.email asc;
end;
$$;
create or replace function public.cruces_glm_get_recent_events(p_limit integer default 100)
returns table (
id bigint,
event_type text,
user_email text,
form_id text,
form_name text,
category text,
created_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para consultar métricas';
end if;
return query
select
e.id,
e.event_type,
e.user_email,
e.form_id,
e.form_name,
e.category,
e.created_at
from public.cruces_glm_metric_events e
order by e.created_at desc
limit least(greatest(coalesce(p_limit, 100), 1), 500);
end;
$$;
revoke all on function public.cruces_glm_get_metrics_summary() from public;
revoke all on function public.cruces_glm_get_form_metrics() from public;
revoke all on function public.cruces_glm_get_user_metrics() from public;
revoke all on function public.cruces_glm_get_recent_events(integer) from public;
grant execute on function public.cruces_glm_get_metrics_summary() to authenticated;
grant execute on function public.cruces_glm_get_form_metrics() to authenticated;
grant execute on function public.cruces_glm_get_user_metrics() to authenticated;
grant execute on function public.cruces_glm_get_recent_events(integer) to authenticated;
-- --------------------------------------------------------------------------
-- 17. RPC — ADMINISTRACIÓN DE ACCESOS
-- --------------------------------------------------------------------------
-- No hay UI para esto todavía, pero queda listo para una futura pantalla.
-- Hoy puedes administrar cruces_glm_permissions directamente en Table Editor.
create or replace function public.cruces_glm_admin_get_access_list()
returns table (
email text,
display_name text,
is_allowed boolean,
can_manage_forms boolean,
updated_at timestamptz
)
language plpgsql
stable
security definer
set search_path = public
as $$
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar accesos';
end if;
return query
select
p.email,
p.display_name,
p.is_allowed,
p.can_manage_forms,
p.updated_at
from public.cruces_glm_permissions p
order by p.display_name nulls last, p.email;
end;
$$;
create or replace function public.cruces_glm_admin_save_access(
p_email text,
p_display_name text,
p_is_allowed boolean,
p_can_manage_forms boolean
)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_email text := lower(trim(coalesce(p_email, '')));
v_name text := nullif(trim(coalesce(p_display_name, '')), '');
begin
if not public.cruces_glm_can_manage_forms() then
raise exception 'Usuario sin permiso para administrar accesos';
end if;
if v_email = '' or position('@' in v_email) <= 1 then
raise exception 'Correo no válido';
end if;
insert into public.cruces_glm_permissions (
email,
display_name,
is_allowed,
can_manage_forms
)
values (
v_email,
v_name,
coalesce(p_is_allowed, false),
coalesce(p_can_manage_forms, false)
)
on conflict (email) do update
set display_name = excluded.display_name,
is_allowed = excluded.is_allowed,
can_manage_forms = excluded.can_manage_forms;
end;
$$;
revoke all on function public.cruces_glm_admin_get_access_list() from public;
revoke all on function public.cruces_glm_admin_save_access(text, text, boolean, boolean) from public;
grant execute on function public.cruces_glm_admin_get_access_list() to authenticated;
grant execute on function public.cruces_glm_admin_save_access(text, text, boolean, boolean) to authenticated;
commit;
-- ============================================================================
-- ADMINISTRACIÓN RÁPIDA DESDE SQL EDITOR / TABLE EDITOR
-- ============================================================================
-- Dar acceso a Cruces GLM:
-- update public.cruces_glm_permissions
-- set is_allowed = true
-- where email = 'correo@gomezleemarketing.com';
--
-- Quitar acceso a Cruces GLM sin borrar historial:
-- update public.cruces_glm_permissions
-- set is_allowed = false
-- where email = 'correo@gomezleemarketing.com';
--
-- Dar permiso de Agregar / Editar / Eliminar:
-- update public.cruces_glm_permissions
-- set can_manage_forms = true
-- where email = 'correo@gomezleemarketing.com';
--
-- Quitar permiso de administración:
-- update public.cruces_glm_permissions
-- set can_manage_forms = false
-- where email = 'correo@gomezleemarketing.com';
--
-- Formularios actualmente guardados:
-- select * from public.cruces_glm_forms order by nombre;
--
-- Métricas globales:
-- select * from public.cruces_glm_metrics_summary;
--
-- Métricas por formulario:
-- select * from public.cruces_glm_form_metrics order by total_aperturas desc;
--
-- Métricas por usuario:
-- select * from public.cruces_glm_user_metrics order by total_logueos desc;
--
-- Eventos recientes:
-- select * from public.cruces_glm_metric_events order by created_at desc limit 100;
-- ============================================================================
+30
View File
@@ -0,0 +1,30 @@
{
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "eslint.config.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import tailwindcss from "@tailwindcss/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
export default defineConfig({
base: "/cruces-glm/",
plugins: [tsConfigPaths(), tailwindcss(), viteReact()],
build: {
outDir: "dist",
emptyOutDir: true,
},
});