Ir al contenido
Evaluar mi proyecto
Componentes · IADisponible

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.

¿Te sirvió esta respuesta?

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

ai-feedback.tsx90 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/ai-feedback

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
onSubmit?(data: { vote: "up" | "down"; reasons: string[]; comment: string }) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)