muitas melhorias pra descrever

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-11 12:54:25 -03:00
1 parent 89b08a19ef
commit c8fba4d240
21 files changed
+971 -17

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { getConsent, setConsent, startHeartbeat, type ConsentValue } from "../lib/analytics.js";
export function CookieConsent() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const current = getConsent();
if (current === null) {
setVisible(true);
} else if (current === "accepted") {
startHeartbeat();
}
}, []);
function choose(value: ConsentValue) {
setConsent(value);
setVisible(false);
if (value === "accepted") startHeartbeat();
}
if (!visible) return null;
return (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-panel/95 backdrop-blur px-4 py-3 sm:px-6">
<div className="mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-300 sm:text-sm">
Este site coleta dados básicos de uso (endereço IP, país aproximado e páginas
acessadas) apenas para estatísticas anônimas de acesso. Nenhum dado é vendido ou
compartilhado com terceiros. Você pode recusar sem perder acesso ao atlas.
</p>
<div className="flex shrink-0 gap-2">
<button
onClick={() => choose("declined")}
className="rounded px-3 py-1.5 text-xs text-slate-300 hover:bg-white/5 sm:text-sm"
>
Recusar
</button>
<button
onClick={() => choose("accepted")}
className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 sm:text-sm"
>
Aceitar
</button>
</div>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from "react";
import { useLocation } from "react-router-dom";
import { api } from "../lib/api.js";
export function FeedbackWidget() {
const location = useLocation();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const [rating, setRating] = useState<number | null>(null);
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
function reset() {
setMessage("");
setEmail("");
setRating(null);
setStatus("idle");
}
async function submit() {
if (!message.trim()) return;
setStatus("sending");
try {
await api.sendFeedback({
message: message.trim(),
email: email.trim() || undefined,
rating: rating ?? undefined,
page: location.pathname,
});
setStatus("sent");
} catch {
setStatus("error");
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-4 right-4 z-40 rounded-full bg-accent px-4 py-2.5 text-sm font-medium text-white shadow-lg hover:opacity-90"
>
💬 Feedback
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-end justify-end bg-black/40 p-4 sm:items-center sm:justify-center">
<div className="w-full max-w-sm rounded-lg border border-white/10 bg-panel p-4 shadow-xl">
{status === "sent" ? (
<div className="py-4 text-center">
<p className="text-sm text-slate-200">Obrigado pelo feedback! 🙏</p>
<button
onClick={() => {
reset();
setOpen(false);
}}
className="mt-3 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white"
>
Fechar
</button>
</div>
) : (
<>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Enviar feedback</h2>
<button
onClick={() => setOpen(false)}
className="text-slate-400 hover:text-white"
aria-label="Fechar"
>
✕
</button>
</div>
<div className="mb-3 flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRating(n)}
className={`text-lg ${rating && n <= rating ? "text-yellow-400" : "text-slate-600"}`}
aria-label={`Nota ${n}`}
>
★
</button>
))}
</div>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="O que você achou? Encontrou algum erro ou tem alguma sugestão?"
rows={4}
className="w-full resize-none rounded border border-white/10 bg-black/20 p-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Seu e-mail (opcional)"
className="mt-2 w-full rounded border border-white/10 bg-black/20 p-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
/>
{status === "error" && (
<p className="mt-2 text-xs text-red-400">
Não foi possível enviar. Tente novamente.
</p>
)}
<button
onClick={submit}
disabled={!message.trim() || status === "sending"}
className="mt-3 w-full rounded bg-accent py-2 text-sm font-medium text-white disabled:opacity-50"
>
{status === "sending" ? "Enviando..." : "Enviar"}
</button>
</>
)}
</div>
</div>
)}
</>
);
}
+16 -1
View File
@@ -1,9 +1,21 @@
import { NavLink, Outlet } from "react-router-dom";
import { useEffect } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { OnlineCounter } from "./OnlineCounter.js";
import { CookieConsent } from "./CookieConsent.js";
import { FeedbackWidget } from "./FeedbackWidget.js";
import { trackVisit } from "../lib/analytics.js";
const linkClass = ({ isActive }: { isActive: boolean }) =>
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
export function Layout() {
const location = useLocation();
useEffect(() => {
trackVisit(location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
return (
<div className="h-[100dvh] flex flex-col">
<header className="flex items-center gap-2 sm:gap-4 px-2 sm:px-4 py-2 border-b border-white/10 bg-panel overflow-x-auto">
@@ -24,6 +36,7 @@ export function Layout() {
Quiz
</NavLink>
</nav>
<OnlineCounter />
<a
href="https://github.com/LluisV/Z-Anatomy-Sample"
target="_blank"
@@ -36,6 +49,8 @@ export function Layout() {
<main className="flex-1 min-h-0">
<Outlet />
</main>
<FeedbackWidget />
<CookieConsent />
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { api } from "../lib/api.js";
export function OnlineCounter() {
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const { count } = await api.onlineCount();
if (!cancelled) setCount(count);
} catch {
// Silencioso — não é crítico pro uso do atlas.
}
}
poll();
const id = setInterval(poll, 20_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
if (count === null) return null;
return (
<span
className="hidden items-center gap-1.5 text-[11px] text-slate-400 sm:inline-flex"
title="Pessoas com a página aberta agora"
>
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
{count} online
</span>
);
}