Valoración de una respuesta
Pulgar arriba o abajo, motivos rápidos distintos según el caso, comentario opcional y confirmación de envío.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, type CSSProperties } from "react";
/** Valoración de una respuesta de IA: pulgar arriba o abajo, motivos rápidos según el caso y comentario opcional. Confirma el envío en el mismo lugar. React + TypeScript, sin dependencias. Personaliza con --gcai-*. */
export type AiFeedbackProps = {
onSubmit?: (data: { vote: "up" | "down"; reasons: string[]; comment: string }) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcaifb-root { box-sizing: border-box; width: 100%; max-width: 460px; color: var(--gcai-ink, #ededee); font: 400 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcaifb-root *, .gcaifb-root *::before, .gcaifb-root *::after { box-sizing: border-box; }
.gcaifb-root button, .gcaifb-root textarea { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcaifb-root :focus-visible { outline: 2px solid var(--gcai-accent, #84b3ff); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcaifb-root * { animation: none !important; transition-duration: .01ms !important; } }
.gcaifb-bar { display: flex; align-items: center; gap: 4px; color: var(--gcai-muted, #9b9ca3); }
.gcaifb-bar > span { margin-right: 8px; font-size: 13px; }
.gcaifb-btn { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
.gcaifb-btn:hover { background: var(--gcai-well, #1a1b1e); color: var(--gcai-ink, #ededee); }
.gcaifb-btn[aria-pressed="true"] { color: var(--gcai-accent, #84b3ff); border-color: var(--gcai-line, #26272c); background: var(--gcai-well, #1a1b1e); }
.gcaifb-panel { display: grid; gap: 12px; margin-top: 10px; padding: 14px; border: 1px solid var(--gcai-line, #26272c); border-radius: 12px; background: var(--gcai-surface, #111113); animation: gcaifb-in .25s cubic-bezier(.16, 1, .3, 1); }
.gcaifb-q { margin: 0; font-weight: 600; }
.gcaifb-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.gcaifb-chip { min-height: 32px; padding: 0 12px; border: 1px solid var(--gcai-line, #26272c); border-radius: 999px; background: transparent; color: var(--gcai-muted, #9b9ca3); cursor: pointer; font-size: 13px; transition: border-color .15s, color .15s, background .15s; }
.gcaifb-chip:hover { border-color: #3a3b42; color: var(--gcai-ink, #ededee); }
.gcaifb-chip[aria-pressed="true"] { border-color: var(--gcai-accent, #84b3ff); color: var(--gcai-accent, #84b3ff); }
.gcaifb-area { width: 100%; min-height: 64px; resize: vertical; padding: 10px 12px; border: 1px solid var(--gcai-line, #26272c); border-radius: 8px; background: var(--gcai-well, #1a1b1e); color: inherit; }
.gcaifb-area::placeholder { color: #6a6b73; }
.gcaifb-send { justify-self: end; min-height: 36px; padding: 0 16px; border: 0; border-radius: 8px; background: var(--gcai-accent, #84b3ff); color: #0a0a0b; font-weight: 600; cursor: pointer; }
.gcaifb-send:hover { filter: brightness(1.08); }
.gcaifb-thanks { margin: 10px 0 0; color: #8fe3b0; font-size: 13px; }
@keyframes gcaifb-in { from { opacity: 0; transform: translateY(-4px); } }`;
const REASONS = { up: ["Precisa", "Clara", "Útil", "Bien escrita"], down: ["Incorrecta", "Incompleta", "Confusa", "No sigue mis instrucciones"] };
export function AiFeedback({ onSubmit, className = "", style }: AiFeedbackProps) {
const [vote, setVote] = useState<"up" | "down" | null>(null);
const [reasons, setReasons] = useState<string[]>([]);
const [comment, setComment] = useState("");
const [sent, setSent] = useState(false);
function choose(next: "up" | "down") {
setSent(false);
setReasons([]);
setVote((value) => (value === next ? null : next));
}
function submit() {
if (!vote) return;
onSubmit?.({ vote, reasons, comment });
setSent(true);
setVote(null);
setComment("");
setReasons([]);
}
return (
<div className={`gcaifb-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcaifb-bar" role="group" aria-label="Valorar la respuesta">
<span>¿Te sirvió esta respuesta?</span>
<button className="gcaifb-btn" type="button" aria-pressed={vote === "up"} aria-label="Buena respuesta" onClick={() => choose("up")}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8c1.5 0 2.5 1 2.5 2.5V9H19a2 2 0 0 1 2 2.3l-1.2 7A2 2 0 0 1 17.8 20H7" /></svg>
</button>
<button className="gcaifb-btn" type="button" aria-pressed={vote === "down"} aria-label="Mala respuesta" onClick={() => choose("down")}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8c-1.5 0-2.5-1-2.5-2.5V15H5a2 2 0 0 1-2-2.3l1.2-7A2 2 0 0 1 6.2 4H17" /></svg>
</button>
</div>
{vote ? (
<div className="gcaifb-panel">
<p className="gcaifb-q">{vote === "up" ? "¿Qué te gustó?" : "¿Qué salió mal?"}</p>
<div className="gcaifb-chips">
{REASONS[vote].map((reason) => (
<button key={reason} className="gcaifb-chip" type="button" aria-pressed={reasons.includes(reason)} onClick={() => setReasons((list) => (list.includes(reason) ? list.filter((value) => value !== reason) : [...list, reason]))}>
{reason}
</button>
))}
</div>
<textarea className="gcaifb-area" aria-label="Comentario opcional" placeholder="Cuéntanos más (opcional)" value={comment} onChange={(event) => setComment(event.target.value)} />
<button className="gcaifb-send" type="button" onClick={submit}>
Enviar valoración
</button>
</div>
) : null}
<p className="gcaifb-thanks" role="status">{sent ? "Gracias, tu valoración se envió." : ""}</p>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-feedbackEjemplo de uso de Valoración de una respuesta
Después de copiar ai-feedback.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { AiFeedback } from "./ai-feedback";
export default function Ejemplo() {
return <AiFeedback />;
}Propiedades de AiFeedback
AiFeedback acepta 3 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
onSubmit? | (data: { vote: "up" | "down"; reasons: string[]; comment: string }) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Valoración de una respuesta con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gcai-ink | #ededee |
--gcai-accent | #84b3ff |
--gcai-muted | #9b9ca3 |
--gcai-well | #1a1b1e |
--gcai-line | #26272c |
--gcai-surface | #111113 |
Accesibilidad y rendimiento
El archivo tiene 90 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Valoración de una respuesta por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: group, status.
- Es un componente de cliente de Next.js: incluye la directiva "use client".