diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f47f1c3..34ff70b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,9 +39,9 @@ services. The current routes, APIs, and modules are mapped in | --- | --- | | Auth | `/login`, `/register`, `/forgot-password`, `/reset-password`; callback and session refresh when affected | | Account | `/conta`, `/conta/editar`; account deletion, including removing app-owned content before the Auth user | -| Essay | `/redacao`, `/resultados/[id]`; OCR upload/compression, theme ownership, stable `submissionId`, off-topic retries | -| Quiz | `/questoes`; POST attempt creation without answer leakage, PATCH canonical correction, selected answers preserved on retries | -| News | Public feed/detail/search; `/noticias/admin` for moderation, import, sanitization, and highlights when affected | +| Essay | `/redacao`, `/resultados/[id]`; Draft restore/discard/user isolation/explicit logout vs session expiry; OCR compression/retry/confirmed replacement; theme ownership, input limits, stable `submissionId`, off-topic justification | +| Quiz | `/questoes`; Same-tab restore without automatic POST; native radio keyboard/previous/review navigation; POST without answer leakage, frozen PATCH retries, 404/410 recovery | +| News | Feed/detail/search errors vs empty; stale query cancellation, URL history, pagination retry/deduplication, optional related content and denied clipboard; `/noticias/admin` when moderation/import/highlights are affected | | Donations | `/doacao`, `/doacao/sucesso`, checkout, and `/api/doacao/webhook` when Stripe is configured | | Subscriptions | `/planos`, `/conta`, `/api/assinatura/status`, `/api/assinatura/checkout`, `/api/assinatura/portal`, shared `/api/doacao/webhook`; first-time 7-day trial eligibility | diff --git a/FRONTEND_INVENTORY.md b/FRONTEND_INVENTORY.md index bef87ff..8990dfe 100644 --- a/FRONTEND_INVENTORY.md +++ b/FRONTEND_INVENTORY.md @@ -35,12 +35,12 @@ The repository contains no active Supabase Edge Functions. Runtime APIs are Next | Route | Files | Purpose | | --- | --- | --- | | `/` | `app/page.tsx`, `app/_components/home/*.tsx` | Landing page composed from four Server Components | -| `/redacao` | `app/redacao/page.tsx`, `app/redacao/RedacaoPageClient.tsx`, `app/redacao/useEssayWorkflow.ts`, `app/redacao/PhotoUpload.tsx`, `app/redacao/prepareOcrImage.ts` | Essay workflow, locally optimized OCR uploads, idempotent correction UI | -| `/questoes` | `app/questoes/page.tsx`, `app/questoes/QuestoesPageClient.tsx` | Quiz generation, answering and result flow | +| `/redacao` | `app/redacao/page.tsx`, `app/redacao/RedacaoPageClient.tsx`, `app/redacao/useEssayWorkflow.ts`, `app/redacao/PhotoUpload.tsx`, `app/redacao/prepareOcrImage.ts` | Per-user local drafts, confirmed OCR replacement, bounded input validation and idempotent correction UI | +| `/questoes` | `app/questoes/page.tsx`, `app/questoes/QuestoesPageClient.tsx` | Same-tab recovery, native radios, previous/review navigation and frozen manual submission retries | | `/planos` | `app/planos/page.tsx`, `app/planos/PlanosPageClient.tsx` | Free/Max comparison, subscription status, checkout and portal entry points | -| `/noticias` | `app/noticias/page.tsx`, `app/noticias/NoticiasPageClient.tsx` | Public news feed | -| `/noticias/[slug]` | `app/noticias/[slug]/page.tsx` | Approved news detail | -| `/noticias/pesquisa` | `app/noticias/pesquisa/page.tsx` | News search page | +| `/noticias` | `app/noticias/page.tsx`, `app/noticias/NoticiasPageClient.tsx`, `app/noticias/error.tsx` | Public feed with background highlights refresh, recoverable pagination and segment errors | +| `/noticias/[slug]` | `app/noticias/[slug]/page.tsx`, `app/noticias/[slug]/article.module.css` | Sanitized article with local typography and optional related content; missing articles preserve 404 | +| `/noticias/pesquisa` | `app/noticias/pesquisa/page.tsx` | URL-driven archive/AI search (`q`, optional `modo=ia`) with history synchronization | | `/noticias/admin` | `app/noticias/admin/page.tsx` | News admin panel | | `/conta` | `app/conta/page.tsx`, `app/conta/ContaPageClient.tsx` | Account dashboard and Max subscription management | | `/conta/editar` | `app/conta/editar/page.tsx`, `app/conta/editar/ContaEditarPageClient.tsx` | Profile editing | @@ -132,6 +132,7 @@ These Server Components are private to the homepage; `app/page.tsx` handles thei | `app/components/layout/Header.tsx` | Header, auth menu and compact navigation below the desktop breakpoint | | `app/components/layout/Footer.tsx` | Footer links and branding | | `app/components/shared/AprovIALogo.tsx` | Reusable responsive AprovIA symbol and wordmark | +| `app/components/shared/FlowStatus.tsx` | Shared accessible status/error presentation | | `app/components/shared/RebrandingBanner.tsx` | Dismissible transition notice backed by local storage | ### Quiz feature components @@ -145,8 +146,11 @@ These Server Components are private to the homepage; `app/page.tsx` handles thei | File | Purpose | | --- | --- | -| `app/noticias/hooks.ts` | Client hooks for public feed, highlights, article lookup and text search | -| `app/noticias/[slug]/ShareButton.tsx` | Native-share/copy-link control for an approved article | +| `app/noticias/hooks.ts` | Cancelable public feed, background highlights, article lookup and archive/AI queries; stale completions are ignored | +| `app/noticias/useNewsSearch.ts` | URL/history synchronization and shared search orchestration | +| `app/noticias/SearchForm.tsx` | Labeled archive/AI search form | +| `app/noticias/error.tsx` | Retry/recovery for unavailable news segments | +| `app/noticias/[slug]/ShareButton.tsx` | Copy-link control with selected manual-copy fallback | --- @@ -156,7 +160,8 @@ These Server Components are private to the homepage; `app/page.tsx` handles thei | --- | --- | | `app/styles/index.css` | Imports design tokens and base styles | | `app/styles/tokens.css` | Dark-only AprovIA design tokens | -| `app/styles/base.css` | Base element styles and utilities used by the app shell | +| `app/styles/base.css` | Base/app-shell styles and scoped student touch targets, focus and reduced motion | +| `app/noticias/[slug]/article.module.css` | Local article paragraphs, headings, lists, quotes and links | There are currently no separate `components.css`, `forms.css` or `utilities.css` files in the repository. @@ -178,17 +183,30 @@ There are currently no separate `components.css`, `forms.css` or `utilities.css` | File | Purpose | | --- | --- | | `lib/contracts/essay.ts` | Strict Zod contracts for themes, submissions, ENEM competences and API responses | +| `lib/contracts/essay-input.ts` | Shared client word/character and manual-theme validation | +| `lib/contracts/student-drafts.ts` | Validated essay/quiz draft shapes and frozen quiz submission snapshot | | `lib/contracts/ocr.ts` | Shared OCR MIME, payload, error-code and success-response contracts | | `lib/contracts/quiz.ts` | Strict question, attempt and review contracts plus public answer-safe serialization | | `lib/contracts/quiz-result.ts` | Neutral validation/mapping of persisted quiz snapshots | -| `lib/contracts/operating-hours.ts` | Neutral operating-hours interface shared by server and clients | +| `lib/contracts/operating-hours.ts` | Shared timezone-aware operating-hours interface and calculation | + +### `lib/client/` + +| File | Purpose | +| --- | --- | +| `lib/client/drafts.ts` | Versioned, user-isolated stores; explicit logout invalidation across tabs; unavailable/corrupt storage handling | +| `lib/client/use-user-draft.ts` | Restore before saving and persist changes/IDs before network work | +| `lib/client/api-errors.ts` | Student-facing API errors and `resetAt`/`Retry-After` interpretation | +| `lib/client/latest-request.ts` | Cancel/invalidate stale completions and deduplicate articles by ID | +| `lib/client/use-operating-hours.ts` | Refresh availability each minute and on tab return | +| `lib/client/use-retry-delay.ts` | Visible manual-retry cooldown | ### `lib/auth/` | File | Purpose | | --- | --- | | `lib/auth/constants.ts` | Auth constants and route references | -| `lib/auth/context.tsx` | `AuthProvider` and auth state management, including optional server-validated initial user bootstrap | +| `lib/auth/context.tsx` | `AuthProvider`, verified initial bootstrap and explicit-only draft cleanup; automatic expiry keeps work | | `lib/auth/profile-service.ts` | Client wrapper around `/api/perfil` | | `lib/auth/security.ts` | Auth-side security helpers | | `lib/auth/service.ts` | Sign-in, sign-up, reset and session refresh flows | @@ -225,9 +243,9 @@ There are currently no separate `components.css`, `forms.css` or `utilities.css` | `lib/server/news-content.ts` | Server-only sanitization of approved news HTML and external URLs | | `lib/server/news-highlights.ts` | On-demand highlight refresh/status logic backed by `configuracoes` | | `lib/server/news-import.ts` | Server-only NewsAPI fetch/normalize/dedupe/import pipeline | -| `lib/server/noticias.ts` | Server-side approved news access for public routes | +| `lib/server/noticias.ts` | Server-side approved news access ordered by publication date and ID | | `lib/server/ocr-image.ts` | Server-only OCR upload size, MIME and magic-byte validation | -| `lib/server/operating-hours.ts` | Business-hours evaluation | +| `lib/server/operating-hours.ts` | Server clock wrapper around the shared availability calculation | | `lib/server/page-auth.ts` | Cached server-side page guards for authenticated routes | | `lib/server/rate-limit.ts` | Atomic, fail-closed server-side rate limiting | | `lib/server/request-origin.ts` | Trusted-origin enforcement for stateful and authenticated APIs | @@ -324,6 +342,9 @@ Latest system migrations: `20260717180319_reform_essay_quiz_systems.sql` and the | `public/.well-known/discord` | External verification/integration artifact | | `public/favicon.svg` | Primary vector app icon | | `public/manifest.json` | Web app manifest using the vector icon | +| `tests/systems/student-workflows.test.ts` | Draft isolation/logout, frozen IDs/answers, API errors, input limits, availability and stale requests | +| `tests/systems/news-detail.test.tsx` | Article preservation when related content fails, sanitization, genuine 404 and unavailable service | +| `docs/student-workflows-qa.md` | Workflow verification scenarios, results and limits of controlled browser QA | --- @@ -338,6 +359,7 @@ Latest system migrations: `20260717180319_reform_essay_quiz_systems.sql` and the | `postcss.config.mjs` | PostCSS config | | `tailwind.config.js` | Tailwind configuration | | `tsconfig.json` | TypeScript config; incremental cache is stored under `.next/cache/typescript/` | +| `vitest.config.ts` | Node system tests with project aliases and isolated CSS-module handling | | `package.json` | Scripts and dependencies | | `.gitignore`, `.vercelignore` | Exclude private credentials, agent/editor state and diagnostics from Git/deploys | | `.github/workflows/public-security.yml` | Offline security regression tests and tree/history checks with complete checkout history | @@ -355,7 +377,7 @@ Latest system migrations: `20260717180319_reform_essay_quiz_systems.sql` and the - `npm run lint` is the active static validation command in the repo. - `npm run test:security` verifies public-tree/history guards and release export without live secrets or services. CI executes these tests and both publication checks. - Agent instructions and editor/MCP configuration remain local and are excluded from Git, deploys and public release export; runtime credentials remain in private environment stores. -- `npm run test:systems` runs the focused Vitest suite for essay/quiz contracts, persistence mapping and OCR routing. +- `npm run test:systems` runs the focused Vitest suite for contracts, persistence mapping, OCR routing, drafts, student errors and news recovery, using in-memory data and mocked providers. - Shared components and library helpers use direct file imports; unused barrels and starter assets are omitted. - The current runtime path is Next.js route handlers under `app/api`. - Development screenshots live in `.local/screenshots/`, excluded from Git and deploys; generated dependencies/build artifacts and `supabase/.branches/` remain local. diff --git a/README.md b/README.md index 7c5534f..b3adeba 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,17 @@ O feedback produzido por IA é uma orientação de estudo. Ele não substitui pr - Vercel Analytics e Speed Insights só são montados depois do consentimento para métricas opcionais. - O runtime é inteiramente atendido pelos Route Handlers do Next.js; as Edge Functions remotas legadas foram removidas. +## Recuperação do trabalho e navegação + +- Redações salvam texto, tema e `submissionId` no `localStorage`, por usuário. O rascunho é restaurado antes da edição e removido após correção salva, descarte confirmado ou logout explícito. Expiração de sessão e falhas de conexão preservam o rascunho. Fotos do OCR continuam apenas em memória; substituir texto diferente exige confirmação. +- Simulados usam `sessionStorage`, por usuário e por aba, preservando `requestId`, `attemptId`, `expiresAt`, posição e respostas. Retomar não cria outra tentativa automaticamente. O primeiro envio congela as respostas; retries manuais enviam o mesmo snapshot. Respostas 404/410 permitem começar um novo simulado. +- A redação aceita tema manual de 5–300 caracteres, 100–500 palavras e até 5.000 caracteres. Texto colado ou extraído acima dos limites permanece no editor para revisão. +- A disponibilidade exibida usa a mesma regra do servidor, das 7h às 23h30 em `America/Sao_Paulo`, e atualiza a cada minuto e ao voltar à aba. O servidor continua autorizando cada operação. +- Notícias sincronizam termo e modo com a URL: `q` pesquisa o acervo e `modo=ia` pede um resumo. Voltar/avançar restaura a pesquisa. Consultas antigas são invalidadas, paginação com erro mantém os artigos e o mesmo offset para retry, e destaques são revalidados em segundo plano pelo GET existente. +- Falhas de armazenamento são informadas na página. O salvamento depende do navegador; logout explícito também invalida gravações e rascunhos antigos de outras abas. + +A verificação deste lote, incluindo os limites do QA com respostas controladas, está em [docs/student-workflows-qa.md](docs/student-workflows-qa.md). + ## Stack principal - Next.js 16.2 @@ -90,7 +101,7 @@ Use `.env.example` como referência e nunca versione `.env.local` ou chaves reai | --- | --- | | `npm run dev` | iniciar o desenvolvimento com Turbopack | | `npm run lint` | executar ESLint no repositório | -| `npm run test:systems` | executar os testes focados de redação, quiz e roteamento OCR | +| `npm run test:systems` | executar testes de contratos, rascunhos, idempotência, notícias e roteamento OCR | | `npm run setup:security` | instalar verificações locais antes de commit e push, preservando hooks existentes | | `npm run test:security` | testar verificadores de privacidade, índice, histórico e exportação com fixtures em memória/disco temporário | | `npm run build` | gerar o build de produção e atualizar `public/sitemap.xml` | @@ -113,6 +124,7 @@ lib/auth/ autenticação, contexto, perfil, segurança e validaç lib/ai/ integrações padrão com Groq e Gemini lib/contracts/ contratos Zod e tipos neutros compartilhados lib/db/ cliente server-side, repositórios e utilitários de consulta +lib/client/ recuperação de rascunhos, erros, requisições e disponibilidade no navegador lib/server/ regras server-only, autorização admin, segurança de APIs e importação de notícias lib/server/ai/ runtime textual e validação de saídas estruturadas lib/server/essay/ geração e correção canônicas de redação diff --git a/app/components/features/quiz/QuestionCard.tsx b/app/components/features/quiz/QuestionCard.tsx index dc3bead..0607db0 100644 --- a/app/components/features/quiz/QuestionCard.tsx +++ b/app/components/features/quiz/QuestionCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { motion } from 'motion/react'; +import { useEffect, useRef } from 'react'; import type { PublicQuestion } from '@/lib/contracts/quiz'; type QuestionCardProps = { @@ -8,112 +8,50 @@ type QuestionCardProps = { questionIndex: number; totalQuestions: number; selectedAnswer: string | null; + locked: boolean; submitting: boolean; - submitError: string | null; + finishDisabled: boolean; + retrying: boolean; onSelectAnswer: (alternativeId: string) => void; + onPrevious: () => void; onNext: () => void; }; -const DISCIPLINE_COLORS: Record = { - Matemática: 'var(--brand)', - Português: 'var(--brand-hover)', - Química: 'var(--warning)', - Física: 'var(--danger)', - Geografia: 'var(--ai)', -}; - -export default function QuestionCard({ - question, - questionIndex, - totalQuestions, - selectedAnswer, - submitting, - submitError, - onSelectAnswer, - onNext, -}: QuestionCardProps) { +export default function QuestionCard({ question, questionIndex, totalQuestions, selectedAnswer, locked, submitting, finishDisabled, retrying, onSelectAnswer, onPrevious, onNext }: QuestionCardProps) { + const titleRef = useRef(null); + useEffect(() => { titleRef.current?.focus(); }, []); const progress = ((questionIndex + 1) / totalQuestions) * 100; const isLast = questionIndex === totalQuestions - 1; - return ( - +
-
- Questão {questionIndex + 1} de {totalQuestions} - {Math.round(progress)}% -
-
- +

Questão {questionIndex + 1} de {totalQuestions}

+
+
- - - {question.discipline} - - + {question.discipline}
-

- {question.text} -

+

{question.text}

- -
+
+ Escolha uma alternativa {question.alternatives.map((alternative) => { const selected = alternative.id === selectedAnswer; - return ( - - ); + return ; })} +
+
+ +
- - {submitError && ( -

- {submitError} -

- )} - - - {!selectedAnswer && !isLast && ( - A questão será marcada como não respondida. - )} - + {!selectedAnswer && !isLast &&

Você pode voltar depois para responder esta questão.

} +
); } diff --git a/app/components/features/quiz/QuizResults.tsx b/app/components/features/quiz/QuizResults.tsx index 9ea6268..0d81c90 100644 --- a/app/components/features/quiz/QuizResults.tsx +++ b/app/components/features/quiz/QuizResults.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; -import { motion } from 'motion/react'; import type { QuizResult } from '@/lib/contracts/quiz'; type QuizResultsProps = { result: QuizResult; onNewQuiz: () => void }; @@ -16,6 +15,8 @@ function scoreLabel(score: number) { export default function QuizResults({ result, onNewQuiz }: QuizResultsProps) { const [expanded, setExpanded] = useState>({}); + const headingRef = useRef(null); + useEffect(() => { headingRef.current?.focus(); }, []); const performance = useMemo(() => { const grouped = new Map(); for (const question of result.questions) { @@ -29,16 +30,16 @@ export default function QuizResults({ result, onNewQuiz }: QuizResultsProps) { return (
- -

Resultado canônico salvo

-

{result.score}%

+
+

Seu resultado foi salvo

+

{result.score}%

{result.correctAnswers} de {result.totalQuestions} questões corretas

{scoreLabel(result.score)} · {result.unansweredQuestions} não respondida(s)

- +
{performance.length > 1 && (
@@ -71,7 +72,7 @@ export default function QuizResults({ result, onNewQuiz }: QuizResultsProps) { className="flex w-full items-center gap-3 p-4 text-left" aria-expanded={isExpanded} > - + {question.isCorrect ? '✓' : '✗'} Questão {index + 1} · {question.discipline} @@ -92,7 +93,7 @@ export default function QuizResults({ result, onNewQuiz }: QuizResultsProps) {

)}

- Explicação + Explicação {question.explanation}

diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index da3126b..acfa7c2 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter } from 'next/navigation'; import type { User } from '@supabase/supabase-js'; import { createClient } from '@/lib/supabase/client'; import AprovIALogo from '@/app/components/shared/AprovIALogo'; +import { clearUserDrafts } from '@/lib/client/drafts'; const NAV_LINKS = [ { href: '/', label: 'Início' }, @@ -65,6 +66,7 @@ function AuthActions({ onAction?.(); setSubmitting(true); try { + if (user) clearUserDrafts(user.id); await supabase.auth.signOut(); startTransition(() => { router.refresh(); diff --git a/app/components/shared/FlowStatus.tsx b/app/components/shared/FlowStatus.tsx new file mode 100644 index 0000000..2b8d46b --- /dev/null +++ b/app/components/shared/FlowStatus.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; + +export default function FlowStatus({ children, error = false }: { children: ReactNode; error?: boolean }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/noticias/NoticiasPageClient.tsx b/app/noticias/NoticiasPageClient.tsx index 6e5b889..60d0e7f 100644 --- a/app/noticias/NoticiasPageClient.tsx +++ b/app/noticias/NoticiasPageClient.tsx @@ -1,9 +1,12 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { Suspense, useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { useNoticias, useDestaques, useBuscaNoticias, useBuscaIA, type NoticiaAPI } from './hooks'; +import { useNoticias, useDestaques, type NoticiaAPI } from './hooks'; +import { useNewsSearch } from './useNewsSearch'; +import SearchForm from './SearchForm'; +import FlowStatus from '@/app/components/shared/FlowStatus'; // --------------------------------------------------------------------------- // Helpers @@ -96,7 +99,7 @@ function ArticleCardSmall({ noticia }: { noticia: NoticiaAPI }) {
{noticia.tags.length > 0 && ( - + {noticia.tags[0]} )} @@ -104,7 +107,7 @@ function ArticleCardSmall({ noticia }: { noticia: NoticiaAPI }) { {noticia.titulo}

{stripHtml(noticia.resumo)}

-
+
{formatDate(noticia.data_publicacao)} · {readTime(noticia.conteudo)} @@ -126,7 +129,7 @@ function ArticleCardMedium({ noticia }: { noticia: NoticiaAPI }) {
{noticia.tags.length > 0 && ( - + {noticia.tags[0]} )} @@ -134,7 +137,7 @@ function ArticleCardMedium({ noticia }: { noticia: NoticiaAPI }) { {noticia.titulo}

{stripHtml(noticia.resumo)}

-
+
{formatDate(noticia.data_publicacao)} · {readTime(noticia.conteudo)} @@ -145,124 +148,26 @@ function ArticleCardMedium({ noticia }: { noticia: NoticiaAPI }) { ); } -// --------------------------------------------------------------------------- -// Search bar -// --------------------------------------------------------------------------- -function SearchBar({ - onSearch, - onAISearch, -}: { - onSearch: (q: string) => void; - onAISearch: (q: string) => void; -}) { - const [query, setQuery] = useState(''); - const [isAI, setIsAI] = useState(false); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!query.trim()) return; - if (isAI) onAISearch(query.trim()); - else onSearch(query.trim()); - }; - - return ( -
-
- - - - setQuery(e.target.value)} - placeholder={isAI ? 'Peça um resumo das notícias recentes sobre um tema...' : 'Buscar notícias...'} - className="w-full pl-10 pr-28 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-sm" - /> - -
-
- - {isAI && ( - - A IA resume notícias aprovadas já publicadas na AprovIA - - )} -
-
- ); -} - // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- -export default function NoticiasPageClient({ +function NoticiasContent({ initialNoticias, initialDestaques, }: { initialNoticias: NoticiaAPI[]; initialDestaques: NoticiaAPI[]; }) { - const { noticias, loading, loadingMore, error, hasMore, loadMore } = useNoticias(9, initialNoticias); - const { destaques, loading: destaquesLoading } = useDestaques(3, initialDestaques); - const { results: searchResults, loading: searchLoading, searched, search, clear: clearSearch } = useBuscaNoticias(); - const { content: aiContent, loading: aiLoading, error: aiError, search: aiSearch, clear: clearAI } = useBuscaIA(); - - const [showingSearch, setShowingSearch] = useState(false); - const [showingAI, setShowingAI] = useState(false); - - const handleSearch = useCallback( - (q: string) => { - clearAI(); - setShowingAI(false); - search(q); - setShowingSearch(true); - }, - [search, clearAI] - ); - - const handleAISearch = useCallback( - (q: string) => { - clearSearch(); - setShowingSearch(false); - aiSearch(q); - setShowingAI(true); - }, - [aiSearch, clearSearch] - ); - - const handleClearResults = useCallback(() => { - clearSearch(); - clearAI(); - setShowingSearch(false); - setShowingAI(false); - }, [clearSearch, clearAI]); + const { noticias, loading, loadingMore, error, moreError, hasMore, loadMore, refetch } = useNoticias(9, initialNoticias); + const { destaques, loading: destaquesLoading, error: highlightsError, refresh: refreshHighlights } = useDestaques(3, initialDestaques); + const searchState = useNewsSearch(); + const { results: searchResults, loading: searchLoading, searched, error: searchError, aiContent, showingSearch, showingAI, submit, clearResults: handleClearResults, secondsToRetry } = searchState; const heroArticle = destaques[0]; const secondaryArticles = destaques.slice(1, 3); return ( -
+
{/* page header */}
@@ -280,7 +185,7 @@ export default function NoticiasPageClient({ {/* search */}
- +
{/* search results */} @@ -289,7 +194,7 @@ export default function NoticiasPageClient({

{showingAI && ( - + @@ -306,21 +211,21 @@ export default function NoticiasPageClient({

- {(searchLoading || aiLoading) && ( -
+ {searchLoading && ( +
{[1, 2, 3].map((i) => (
))}
)} - {showingAI && aiContent && ( + {showingAI && !searchLoading && !searchError && aiContent && (
- Resumo da IA + Resumo da IA
{aiContent} @@ -328,29 +233,29 @@ export default function NoticiasPageClient({
)} - {showingAI && aiError && ( -
- {aiError} -
- )} + {searchError &&
+ {searchError}{secondsToRetry > 0 && ` Aguarde ${secondsToRetry}s.`} + +
} + {showingAI && searched && !searchLoading && !searchError && !aiContent && Nenhuma notícia encontrada para resumir. Tente outro termo.} - {showingSearch && searched && !searchLoading && searchResults.length === 0 && ( + {showingSearch && searched && !searchLoading && !searchError && searchResults.length === 0 && (

Nenhum resultado encontrado.

- Tente a{' '} + Peça um{' '} {' '} - para uma resposta personalizada. + com base nas notícias publicadas.

)} - {showingSearch && searchResults.length > 0 && ( + {showingSearch && !searchLoading && !searchError && searchResults.length > 0 && (
{searchResults.map((n) => ( @@ -363,8 +268,10 @@ export default function NoticiasPageClient({ {/* featured hero */} {!showingSearch && !showingAI && ( <> + {highlightsError &&
Não foi possível atualizar os destaques.
} {destaquesLoading ? ( -
+
+ Carregando destaques…
@@ -390,7 +297,7 @@ export default function NoticiasPageClient({

{stripHtml(heroArticle.resumo)}

-
+
{formatDate(heroArticle.data_publicacao)} · {readTime(heroArticle.conteudo)} @@ -421,7 +328,8 @@ export default function NoticiasPageClient({

Últimas notícias

{loading ? ( -
+
+ Carregando notícias… {[1, 2, 3, 4, 5, 6].map((i) => (
@@ -433,12 +341,12 @@ export default function NoticiasPageClient({
))}
- ) : error ? ( + ) : error && noticias.length === 0 ? (
-

{error}

+ {error} @@ -460,6 +368,8 @@ export default function NoticiasPageClient({ ))}
+ {moreError &&
{moreError} As notícias carregadas foram mantidas.
} + {loadingMore &&

Carregando mais notícias…

} {hasMore && (
@@ -490,3 +400,7 @@ export default function NoticiasPageClient({
); } + +export default function NoticiasPageClient(props: { initialNoticias: NoticiaAPI[]; initialDestaques: NoticiaAPI[] }) { + return Carregando notícias…
}>; +} diff --git a/app/noticias/SearchForm.tsx b/app/noticias/SearchForm.tsx new file mode 100644 index 0000000..e3518c4 --- /dev/null +++ b/app/noticias/SearchForm.tsx @@ -0,0 +1,28 @@ +'use client'; + +type SearchFormProps = { + query: string; + isAI: boolean; + setQuery: (value: string) => void; + setIsAI: (value: boolean) => void; + submit: () => void; + secondsToRetry: number; +}; + +export default function SearchForm({ query, isAI, setQuery, setIsAI, submit, secondsToRetry }: SearchFormProps) { + return ( +
{ event.preventDefault(); submit(); }} className="space-y-3" aria-label="Pesquisa de notícias"> + +
+ setQuery(event.target.value)} required minLength={2} maxLength={200} aria-describedby="news-search-mode" placeholder={isAI ? 'Tema para um resumo com IA…' : 'Buscar notícias…'} className="w-full rounded-xl border border-[var(--border)] bg-[var(--surface)] py-4 pl-4 pr-28 text-base text-[var(--text)] placeholder:text-[var(--text-3)]" /> + +
+
+ +

{isAI ? 'A IA resume notícias aprovadas já publicadas na AprovIA.' : 'Busque no acervo de notícias aprovadas.'}

+
+
+ ); +} diff --git a/app/noticias/[slug]/ShareButton.tsx b/app/noticias/[slug]/ShareButton.tsx index 411461f..723f88b 100644 --- a/app/noticias/[slug]/ShareButton.tsx +++ b/app/noticias/[slug]/ShareButton.tsx @@ -1,37 +1,42 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; export default function ShareButton() { const [copied, setCopied] = useState(false); + const [manualUrl, setManualUrl] = useState(''); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + const timerRef = useRef | null>(null); + useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); + useEffect(() => { if (manualUrl) { inputRef.current?.focus(); inputRef.current?.select(); } }, [manualUrl]); const handleShare = async () => { - await navigator.clipboard.writeText(window.location.href); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + setBusy(true); + setCopied(false); + setManualUrl(''); + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 2_000); + } catch { + setManualUrl(window.location.href); + } finally { + setBusy(false); + } }; - return ( - +
+ + {copied && Link copiado.} + {manualUrl &&
+

Não foi possível copiar automaticamente. Selecione e copie o link abaixo.

+ + event.target.select()} className="w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-3 text-base text-[var(--text)]" /> +
} +
); } diff --git a/app/noticias/[slug]/article.module.css b/app/noticias/[slug]/article.module.css new file mode 100644 index 0000000..3f4eead --- /dev/null +++ b/app/noticias/[slug]/article.module.css @@ -0,0 +1,21 @@ +.content { + color: var(--text-2); + font-size: 1rem; + line-height: 1.8; + overflow-wrap: anywhere; +} + +.content p { margin-block: 1.25em; color: var(--text-2); } +.content :is(h2, h3, h4, h5, h6) { margin-block: 1.75em 0.75em; color: var(--text); line-height: 1.35; } +.content h2 { font-size: 1.5em; } +.content h3 { font-size: 1.25em; } +.content :is(ul, ol) { padding-inline-start: 1.5em; margin-block: 1.25em; } +.content ul { list-style: disc; } +.content ol { list-style: decimal; } +.content li { margin-block: 0.5em; } +.content blockquote { margin-block: 1.5em; border-inline-start: 3px solid var(--brand-hover); padding-inline-start: 1em; color: var(--text-2); } +.content a { color: var(--brand-hover); text-decoration: underline; text-underline-offset: 0.2em; } +.content a:hover { color: var(--text); } +.content :is(strong, b) { color: var(--text); font-weight: 700; } +.content hr { margin-block: 2em; border-color: var(--border); } +.content img { max-width: 100%; height: auto; } diff --git a/app/noticias/[slug]/page.tsx b/app/noticias/[slug]/page.tsx index b49722a..ef203d9 100644 --- a/app/noticias/[slug]/page.tsx +++ b/app/noticias/[slug]/page.tsx @@ -5,6 +5,7 @@ import { notFound } from 'next/navigation'; import ShareButton from './ShareButton'; import { fetchNoticiaBySlug, fetchNoticiasPorTag, isNewsServerClientConfigured } from '@/lib/server/noticias'; import { sanitizeExternalUrl, sanitizeNewsHtml } from '@/lib/server/news-content'; +import styles from './article.module.css'; type NoticiaPageProps = { params: Promise<{ slug: string }>; @@ -60,7 +61,7 @@ export async function generateMetadata({ params }: NoticiaPageProps): Promise> = []; if (noticia.tags.length > 0) { - related = (await fetchNoticiasPorTag(noticia.tags[0], 4)) + related = (await fetchNoticiasPorTag(noticia.tags[0], 4).catch(() => [])) .filter((item) => item.slug !== slug) .slice(0, 3); } @@ -88,7 +89,7 @@ export default async function NoticiaPage({ params }: NoticiaPageProps) { })); return ( -
+
{safeImageUrl ? (
@@ -126,7 +127,7 @@ export default async function NoticiaPage({ params }: NoticiaPageProps) { {noticia.tags.map((tag) => ( {tag} @@ -162,7 +163,7 @@ export default async function NoticiaPage({ params }: NoticiaPageProps) {
diff --git a/app/noticias/error.tsx b/app/noticias/error.tsx new file mode 100644 index 0000000..1df1bd7 --- /dev/null +++ b/app/noticias/error.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { startTransition } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import FlowStatus from '@/app/components/shared/FlowStatus'; + +export default function NoticiasError({ reset, unstable_retry }: { reset: () => void; unstable_retry?: () => void }) { + const router = useRouter(); + const retry = () => { + if (unstable_retry) unstable_retry(); + else startTransition(() => { router.refresh(); reset(); }); + }; + return ( +
+

Não foi possível abrir as notícias

+ O conteúdo está temporariamente indisponível. Tente novamente em instantes. +
+ + Voltar para notícias +
+
+ ); +} diff --git a/app/noticias/hooks.ts b/app/noticias/hooks.ts index be2ae5c..95f638d 100644 --- a/app/noticias/hooks.ts +++ b/app/noticias/hooks.ts @@ -1,10 +1,9 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; +import { ApiError, apiError, failureMessage } from '@/lib/client/api-errors'; +import { createLatestRequest, deduplicateById } from '@/lib/client/latest-request'; -// --------------------------------------------------------------------------- -// Types — API returns snake_case directly from database -// --------------------------------------------------------------------------- export interface NoticiaAPI { id: string; titulo: string; @@ -19,247 +18,184 @@ export interface NoticiaAPI { created_at: string; fonte_url: string | null; } +const EMPTY_NEWS: NoticiaAPI[] = []; -// --------------------------------------------------------------------------- -// useNoticias — paginated news list with optional destaque filter -// --------------------------------------------------------------------------- -export function useNoticias(pageSize = 9, initialNoticias: NoticiaAPI[] = []) { - const [noticias, setNoticias] = useState(initialNoticias); +async function readNews(response: Response): Promise { + const data = await response.json().catch(() => null); + if (!response.ok) throw apiError(response, data, 'Não foi possível carregar as notícias. Tente novamente.'); + if (!data || !Array.isArray(data.noticias)) throw new ApiError('Não conseguimos abrir as notícias. Tente novamente.', 'unavailable'); + return data.noticias; +} + +export function useNoticias(pageSize = 9, initialNoticias: NoticiaAPI[] = EMPTY_NEWS) { + const [requests] = useState(createLatestRequest); + const [noticias, setNoticias] = useState(() => deduplicateById(initialNoticias)); const [loading, setLoading] = useState(initialNoticias.length === 0); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); + const [moreError, setMoreError] = useState(null); const [offset, setOffset] = useState(initialNoticias.length); const [hasMore, setHasMore] = useState(initialNoticias.length >= pageSize); - const fetchPage = useCallback( - async (currentOffset: number, append: boolean) => { - if (append) setLoadingMore(true); - else setLoading(true); - setError(null); - - try { - const params = new URLSearchParams({ - limit: String(pageSize), - offset: String(currentOffset), - }); - const res = await fetch(`/api/noticias?${params}`); - if (!res.ok) throw new Error('Erro ao carregar notícias'); - const data = await res.json(); - const items: NoticiaAPI[] = data.noticias ?? []; - - if (append) { - setNoticias((prev) => [...prev, ...items]); - } else { - setNoticias(items); - } - setHasMore(items.length >= pageSize); - setOffset(currentOffset + items.length); - } catch (err) { - setError(err instanceof Error ? err.message : 'Erro desconhecido'); - } finally { - setLoading(false); - setLoadingMore(false); - } - }, - [pageSize] - ); - - useEffect(() => { - if (initialNoticias.length > 0) { - return; + const fetchPage = useCallback(async (currentOffset: number, append: boolean) => { + const request = requests.begin(); + setLoadingMore(append); + setLoading(!append); + setError(null); + setMoreError(null); + try { + const params = new URLSearchParams({ limit: String(pageSize), offset: String(currentOffset) }); + const items = await readNews(await fetch(`/api/noticias?${params}`, { signal: request.signal })); + if (!request.isCurrent()) return; + setNoticias((previous) => deduplicateById(append ? [...previous, ...items] : items)); + setHasMore(items.length >= pageSize); + // Advance by the raw page length, not the deduplicated length. Failure keeps this offset. + setOffset(currentOffset + items.length); + } catch (failure) { + if (!request.isCurrent()) return; + const message = failureMessage(failure); + if (append) setMoreError(message); + else setError(message); + } finally { + if (request.isCurrent()) { setLoading(false); setLoadingMore(false); request.finish(); } } - fetchPage(0, false); - }, [fetchPage, initialNoticias.length]); - + }, [pageSize, requests]); + useEffect(() => { + if (initialNoticias.length === 0) void fetchPage(0, false); + return () => requests.cancel(); + }, [fetchPage, initialNoticias.length, requests]); const loadMore = useCallback(() => { - if (!loadingMore && hasMore) { - fetchPage(offset, true); - } - }, [offset, loadingMore, hasMore, fetchPage]); - - const refetch = useCallback(() => { - setOffset(0); - setHasMore(true); - fetchPage(0, false); - }, [fetchPage]); - - return { noticias, loading, loadingMore, error, hasMore, loadMore, refetch }; + if (!requests.isPending() && hasMore) void fetchPage(offset, true); + }, [offset, hasMore, fetchPage, requests]); + const refetch = useCallback(() => { void fetchPage(0, false); }, [fetchPage]); + return { noticias, loading, loadingMore, error, moreError, hasMore, loadMore, refetch }; } -// --------------------------------------------------------------------------- -// useDestaques — featured articles -// --------------------------------------------------------------------------- -export function useDestaques(limit = 3, initialDestaques: NoticiaAPI[] = []) { - const [destaques, setDestaques] = useState(initialDestaques); +export function useDestaques(limit = 3, initialDestaques: NoticiaAPI[] = EMPTY_NEWS) { + const [requests] = useState(createLatestRequest); + const [destaques, setDestaques] = useState(initialDestaques); const [loading, setLoading] = useState(initialDestaques.length === 0); - - useEffect(() => { - if (initialDestaques.length > 0) { - return; + const [error, setError] = useState(null); + const refresh = useCallback(async () => { + const request = requests.begin(); + setError(null); + try { + const items = await readNews(await fetch(`/api/noticias?destaque=true&limit=${limit}&offset=0`, { signal: request.signal })); + if (request.isCurrent()) setDestaques(deduplicateById(items)); + } catch (failure) { + if (request.isCurrent()) setError(failureMessage(failure)); + } finally { + if (request.isCurrent()) { setLoading(false); request.finish(); } } - - (async () => { - try { - const res = await fetch(`/api/noticias?destaque=true&limit=${limit}&offset=0`); - if (!res.ok) return; - const data = await res.json(); - setDestaques(data.noticias ?? []); - } catch { /* ignore */ } - setLoading(false); - })(); - }, [initialDestaques.length, limit]); - - return { destaques, loading }; + }, [limit, requests]); + useEffect(() => { void refresh(); return () => requests.cancel(); }, [refresh, requests]); + return { destaques, loading, error, refresh }; } -// --------------------------------------------------------------------------- -// useNoticia — single article by slug -// --------------------------------------------------------------------------- export function useNoticia(slug: string) { + const [requests] = useState(createLatestRequest); const [noticia, setNoticia] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - if (!slug) return; - (async () => { - setLoading(true); - setError(null); + const request = requests.begin(); + setLoading(!!slug); + setError(null); + setNoticia(null); + if (slug) void (async () => { try { - const res = await fetch(`/api/noticias/${encodeURIComponent(slug)}`); - if (res.status === 404) { - setError('Notícia não encontrada'); - setNoticia(null); - return; - } - if (!res.ok) throw new Error('Erro ao carregar notícia'); - const data = await res.json(); - setNoticia(data.noticia ?? null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Erro desconhecido'); + const response = await fetch(`/api/noticias/${encodeURIComponent(slug)}`, { signal: request.signal }); + const data = await response.json().catch(() => null); + if (response.status === 404) throw new ApiError('Notícia não encontrada.', 'invalid'); + if (!response.ok) throw apiError(response, data, 'Não foi possível carregar a notícia. Tente novamente.'); + if (request.isCurrent()) setNoticia(data.noticia ?? null); + } catch (failure) { + if (request.isCurrent()) setError(failureMessage(failure)); } finally { - setLoading(false); + if (request.isCurrent()) { setLoading(false); request.finish(); } } })(); - }, [slug]); - + return () => requests.cancel(); + }, [slug, requests]); return { noticia, loading, error }; } -// --------------------------------------------------------------------------- -// useBuscaNoticias — keyword search -// --------------------------------------------------------------------------- -export function useBuscaNoticias() { - const [results, setResults] = useState([]); +function useNewsQuery(initial: T, run: (query: string, signal: AbortSignal) => Promise) { + const [requests] = useState(createLatestRequest); + const [result, setResult] = useState(initial); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [searched, setSearched] = useState(false); - - const search = useCallback(async (query: string) => { - const q = query.trim(); - if (!q) return; - setLoading(true); - setError(null); - setSearched(true); - try { - const res = await fetch(`/api/noticias/busca?q=${encodeURIComponent(q)}&limit=20`); - if (!res.ok) throw new Error('Erro na busca'); - const data = await res.json(); - setResults(data.noticias ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Erro desconhecido'); - } finally { - setLoading(false); - } - }, []); - + const [retryAt, setRetryAt] = useState(null); + useEffect(() => () => requests.cancel(), [requests]); const clear = useCallback(() => { - setResults([]); + requests.cancel(); + setResult(initial); + setLoading(false); setSearched(false); setError(null); - }, []); - - return { results, loading, error, searched, search, clear }; -} - -// --------------------------------------------------------------------------- -// useBuscaIA — AI-powered search -// --------------------------------------------------------------------------- -export function useBuscaIA() { - const [content, setContent] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - + }, [requests, initial]); const search = useCallback(async (query: string) => { const q = query.trim(); - if (!q) return; + if (!q) { clear(); return; } + const request = requests.begin(); setLoading(true); setError(null); - setContent(null); + setSearched(true); + setResult(initial); try { - const res = await fetch('/api/noticias/gpt-busca', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ termo: q }), - }); - const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || 'Erro ao gerar resumo com IA'); - } - setContent(data.noticias ?? null); - } catch (err) { - setError(err instanceof Error ? err.message : 'Erro desconhecido'); + const value = await run(q, request.signal); + if (request.isCurrent()) setResult(value); + } catch (failure) { + if (!request.isCurrent()) return; + setError(failureMessage(failure)); + setRetryAt(failure instanceof ApiError ? failure.retryAt : null); } finally { - setLoading(false); + if (request.isCurrent()) { setLoading(false); request.finish(); } } - }, []); - - const clear = useCallback(() => { - setContent(null); - setError(null); - }, []); - - return { content, loading, error, search, clear }; + }, [requests, run, initial, clear]); + return { result, loading, error, searched, retryAt, search, clear }; +} +async function searchArchive(query: string, signal: AbortSignal) { + return deduplicateById(await readNews(await fetch(`/api/noticias/busca?q=${encodeURIComponent(query)}&limit=20`, { signal }))); +} +async function searchAI(query: string, signal: AbortSignal): Promise { + const response = await fetch('/api/noticias/gpt-busca', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ termo: query }), signal, + }); + const data = await response.json().catch(() => null); + // A 404 here means a valid search with no articles in the archive. + if (response.status === 404) return null; + if (!response.ok) throw apiError(response, data, 'Não foi possível gerar o resumo com IA. Tente novamente.'); + if (typeof data?.noticias !== 'string') throw new ApiError('Não conseguimos abrir o resumo. Tente novamente.', 'unavailable'); + return data.noticias.trim() || null; +} +export function useBuscaNoticias() { + const { result: results, ...rest } = useNewsQuery(EMPTY_NEWS, searchArchive); + return { results, ...rest }; +} +export function useBuscaIA() { + const { result: content, ...rest } = useNewsQuery(null, searchAI); + return { content, ...rest }; } -// --------------------------------------------------------------------------- -// useRelatedNoticias — articles by tag -// --------------------------------------------------------------------------- export function useRelatedNoticias(tags: string[], excludeSlug: string, limit = 3) { - const [related, setRelated] = useState([]); - const [loading, setLoading] = useState(() => tags.length > 0); - + const [requests] = useState(createLatestRequest); + const [related, setRelated] = useState(EMPTY_NEWS); + const [loading, setLoading] = useState(tags.length > 0); + const tag = tags[0] ?? ''; useEffect(() => { - if (!tags.length) { - return; - } - let cancelled = false; - (async () => { - setLoading(true); + const request = requests.begin(); + setLoading(!!tag); + setRelated(EMPTY_NEWS); + if (tag) void (async () => { try { - const res = await fetch( - `/api/noticias?tag=${encodeURIComponent(tags[0])}&limit=${limit + 1}&offset=0` - ); - if (!res.ok) return; - const data = await res.json(); - const items: NoticiaAPI[] = (data.noticias ?? []).filter( - (n: NoticiaAPI) => n.slug !== excludeSlug - ); - if (!cancelled) { - setRelated(items.slice(0, limit)); - } - } catch { /* ignore */ } - if (!cancelled) { - setLoading(false); - } + const items = await readNews(await fetch(`/api/noticias?tag=${encodeURIComponent(tag)}&limit=${limit + 1}&offset=0`, { signal: request.signal })); + if (request.isCurrent()) setRelated(deduplicateById(items).filter((item) => item.slug !== excludeSlug).slice(0, limit)); + } catch { /* Related content is optional. */ } + finally { if (request.isCurrent()) { setLoading(false); request.finish(); } } })(); - return () => { - cancelled = true; - }; - }, [tags, excludeSlug, limit]); - - return { - related: tags.length ? related : [], - loading: tags.length ? loading : false, - }; + return () => requests.cancel(); + }, [tag, excludeSlug, limit, requests]); + return { related: tag ? related : EMPTY_NEWS, loading: !!tag && loading }; } diff --git a/app/noticias/page.tsx b/app/noticias/page.tsx index f9318b9..c3fbbd5 100644 --- a/app/noticias/page.tsx +++ b/app/noticias/page.tsx @@ -8,7 +8,7 @@ export default async function NoticiasPage() { if (isNewsServerClientConfigured()) { [initialNoticias, initialDestaques] = await Promise.all([ listNoticias({ limit: 9, offset: 0 }), - listNoticias({ limit: 3, offset: 0, destaque: true }), + listNoticias({ limit: 3, offset: 0, destaque: true }).catch(() => []), ]); } diff --git a/app/noticias/pesquisa/page.tsx b/app/noticias/pesquisa/page.tsx index ec692dd..5edd871 100644 --- a/app/noticias/pesquisa/page.tsx +++ b/app/noticias/pesquisa/page.tsx @@ -1,11 +1,12 @@ 'use client'; -import { Suspense, useState, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; +import { Suspense, useState } from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { motion } from 'motion/react'; -import { useBuscaNoticias, useBuscaIA, type NoticiaAPI } from '../hooks'; +import type { NoticiaAPI } from '../hooks'; +import { useNewsSearch } from '../useNewsSearch'; +import SearchForm from '../SearchForm'; +import FlowStatus from '@/app/components/shared/FlowStatus'; // --------------------------------------------------------------------------- // Helpers @@ -57,7 +58,7 @@ function ResultCard({ noticia }: { noticia: NoticiaAPI }) {
{noticia.tags.length > 0 && ( - + {noticia.tags[0]} )} @@ -65,7 +66,7 @@ function ResultCard({ noticia }: { noticia: NoticiaAPI }) { {noticia.titulo}

{stripHtml(noticia.resumo)}

-
+
{formatDate(noticia.data_publicacao)} · {readTime(noticia.conteudo)} @@ -80,41 +81,15 @@ function ResultCard({ noticia }: { noticia: NoticiaAPI }) { // Main // --------------------------------------------------------------------------- function PesquisaPageInner() { - const searchParams = useSearchParams(); - const initialQuery = searchParams.get('q') || ''; - const [query, setQuery] = useState(initialQuery); - const [isAI, setIsAI] = useState(false); - const { results, loading: searchLoading, searched, search, clear: clearSearch } = useBuscaNoticias(); - const { content: aiContent, loading: aiLoading, error: aiError, search: aiSearch, clear: clearAI } = useBuscaIA(); - - // Auto-search on mount if query param present - useEffect(() => { - if (initialQuery) search(initialQuery); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!query.trim()) return; - if (isAI) { - clearSearch(); - aiSearch(query.trim()); - } else { - clearAI(); - search(query.trim()); - } - }; - - const loading = searchLoading || aiLoading; + const searchState = useNewsSearch(); + const { results, aiContent, loading, error, searched, showingAI, showingSearch, submit, clearResults, secondsToRetry } = searchState; return ( -
+
{/* header */} -

Pesquisar notícias

-
+
- {/* search form */} - -
- - - - setQuery(e.target.value)} - placeholder={isAI ? 'Peça um resumo das notícias recentes sobre um tema...' : 'Buscar por título ou conteúdo...'} - className="w-full pl-10 pr-28 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] text-sm" - /> - -
- -
+
+ {(showingSearch || showingAI) && } {/* loading */} {loading && ( -
+
{[1, 2, 3].map((i) => (
))} @@ -184,36 +116,29 @@ function PesquisaPageInner() { )} {/* AI results */} - {!loading && aiContent && ( -
- Resumo da IA + Resumo da IA
{aiContent}
-
- )} - - {aiError && ( -
- {aiError}
)} + {error &&
{error}{secondsToRetry > 0 && ` Aguarde ${secondsToRetry}s.`}
} + {!loading && showingAI && searched && !error && !aiContent && Nenhuma notícia encontrada para resumir. Tente outro termo.} + {/* search results */} - {!loading && searched && results.length > 0 && ( - 0 && ( +

{results.length} resultado{results.length !== 1 ? 's' : ''} encontrado{results.length !== 1 ? 's' : ''} @@ -221,11 +146,11 @@ function PesquisaPageInner() { {results.map((n) => ( ))} - +

)} {/* empty state */} - {!loading && searched && results.length === 0 && !aiContent && ( + {!loading && showingSearch && searched && !error && results.length === 0 && (
@@ -238,15 +163,9 @@ function PesquisaPageInner() {

Tente termos diferentes ou peça um resumo com IA baseado nas notícias já publicadas.

- {!isAI && ( + {showingSearch && ( - ); - })} +
+
+

{answered} de {questions.length} questões respondidas

+ {storageWarning && Não conseguimos salvar nesta aba. Mantenha a página aberta para preservar suas respostas.} + {draft.submission && !draft.expired && !submitting && As respostas do envio foram mantidas. Tente finalizar novamente para recuperar seu resultado.} + {(error || draft.expired) && {error || 'Este simulado expirou ou não está mais disponível. Comece um novo simulado.'}{secondsToRetry > 0 && ` Aguarde ${secondsToRetry}s.`}} + {draft.expired && } +
+ 0} retrying={!!draft.submission} + onSelectAnswer={(alternativeId) => updateDraft((current) => ({ ...current, selectedAnswers: { ...current.selectedAnswers, [question.id]: alternativeId } }))} + onPrevious={() => moveTo(draft.currentIndex - 1)} onNext={next} + /> + {confirmIncomplete && ( +
+

Há {missing} {missing !== 1 ? 'questões' : 'questão'} sem resposta

+

Você pode revisar ou finalizar com essas questões em branco.

+
+ + +
- - - {error &&

{error}

} - -
- ); - } - - if (phase === 'loading') { - return ( -
- - - {LOADING_MESSAGES[loadingMessage]} - - -
- ); - } - - if (phase === 'quiz' && questions[currentIndex]) { - const currentQuestion = questions[currentIndex]; - return ( -
- - - setSelectedAnswers((current) => ({ - ...current, - [currentQuestion.id]: alternativeId, - })) - } - onNext={handleNext} - /> - + )} + {submitting &&
Finalizando e salvando seu resultado…
}
); } - if (phase === 'results' && result) { - return
; - } + return ( +
+
+ ✦ Simulado com IA +

Simulado inteligente

+

Escolha as disciplinas e pratique no seu ritmo. Ao finalizar, confira seu resultado e a explicação de cada questão.

+

{operatingHours.isOpen ? 'Sistema disponível' : operatingHours.message}

+
+
+

Escolha as disciplinas

+
+ {DISCIPLINES.map((discipline) => { + const selected = draft.disciplines.includes(discipline); + const detail = DISCIPLINE_DETAILS[discipline]; + return ; + })} +
+
+ {storageWarning && O salvamento nesta aba está indisponível. Mantenha a página aberta durante o simulado.} + {error && {error}{secondsToRetry > 0 && ` Aguarde ${secondsToRetry}s.`}} + +
+ ); +} - return null; +export default function QuestoesPageClient({ operatingHours }: { operatingHours: OperatingHoursInfo }) { + const { user } = useAuth(); + if (!user) return
Entre novamente para recuperar seu simulado. Entrar
; + return ; } diff --git a/app/redacao/PhotoUpload.tsx b/app/redacao/PhotoUpload.tsx index 337d962..4c66854 100644 --- a/app/redacao/PhotoUpload.tsx +++ b/app/redacao/PhotoUpload.tsx @@ -4,8 +4,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { OCR_ALLOWED_MIME_TYPES, ocrResponseSchema, - type OcrErrorCode, } from '@/lib/contracts/ocr'; +import { ApiError, apiError, failureMessage } from '@/lib/client/api-errors'; +import { useRetryDelay } from '@/lib/client/use-retry-delay'; +import FlowStatus from '@/app/components/shared/FlowStatus'; import { OcrImagePreparationError, prepareOcrImage, @@ -19,13 +21,6 @@ const PHOTO_GUIDANCE = [ 'Confira se a foto está nítida e a escrita está legível.', ] as const; -class OcrRequestError extends Error { - constructor(message: string, public readonly code?: OcrErrorCode) { - super(message); - this.name = 'OcrRequestError'; - } -} - function CameraIcon() { return (
  • - {item} + {item}
  • ))} @@ -89,17 +84,23 @@ function PhotoGuidance() { interface PhotoUploadProps { onTextExtracted: (text: string) => void; + currentText: string; disabled?: boolean; } -export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadProps) { +export default function PhotoUpload({ onTextExtracted, currentText, disabled }: PhotoUploadProps) { const [state, setState] = useState('idle'); const [previewUrl, setPreviewUrl] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [optimized, setOptimized] = useState(false); const [extractedText, setExtractedText] = useState(''); const [errorMessage, setErrorMessage] = useState(''); - const [errorCode, setErrorCode] = useState(null); + const [retryAllowed, setRetryAllowed] = useState(false); + const [retryAt, setRetryAt] = useState(null); + const [confirmReplace, setConfirmReplace] = useState(false); + const secondsToRetry = useRetryDelay(retryAt); + const reviewRef = useRef(null); + const confirmRef = useRef(null); const fileInputRef = useRef(null); const requestControllerRef = useRef(null); const selectionVersionRef = useRef(0); @@ -127,7 +128,8 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr setOptimized(false); setExtractedText(''); setErrorMessage(''); - setErrorCode(null); + setRetryAllowed(false); + setConfirmReplace(false); if (fileInputRef.current) fileInputRef.current.value = ''; }, []); @@ -137,7 +139,7 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr const handleFileSelect = useCallback(async (event: React.ChangeEvent) => { const sourceFile = event.target.files?.[0]; - if (!sourceFile) return; + if (!sourceFile || disabled) return; const selectionVersion = selectionVersionRef.current + 1; selectionVersionRef.current = selectionVersion; @@ -148,7 +150,8 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr setPreviewUrl(null); setOptimized(false); setErrorMessage(''); - setErrorCode(null); + setRetryAllowed(false); + setConfirmReplace(false); try { const prepared = await prepareOcrImage(sourceFile); @@ -167,17 +170,17 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr : 'Não foi possível preparar a foto. Escolha outra imagem.', ); } - }, []); + }, [disabled]); const handleExtract = useCallback(async () => { - if (!selectedFile) return; + if (!selectedFile || disabled || secondsToRetry || requestControllerRef.current) return; - requestControllerRef.current?.abort(); const controller = new AbortController(); requestControllerRef.current = controller; setState('extracting'); setErrorMessage(''); - setErrorCode(null); + setRetryAllowed(false); + setConfirmReplace(false); try { const formData = new FormData(); @@ -191,48 +194,47 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr const payload: unknown = await response.json().catch(() => null); if (!response.ok) { - const code = payload && typeof payload === 'object' && 'code' in payload - ? String(payload.code) as OcrErrorCode - : undefined; - const serverMessage = payload && typeof payload === 'object' && 'message' in payload - ? String(payload.message) - : undefined; - - if (response.status === 401) { - throw new OcrRequestError('Sessão expirada. Faça login novamente.', code); - } - if (response.status === 429) { - throw new OcrRequestError('Muitas tentativas. Aguarde antes de tentar novamente.', code); - } - throw new OcrRequestError( - serverMessage ?? 'Não foi possível extrair o texto. Tente com outra foto.', - code, - ); + const code = payload && typeof payload === 'object' && 'code' in payload ? String(payload.code) : ''; + const problem = apiError(response, payload, 'Não foi possível extrair o texto agora. Sua foto foi mantida para tentar novamente.'); + setRetryAllowed(response.status >= 500 || [401, 408, 429].includes(response.status)); + setRetryAt(problem.retryAt); + if (code === 'OCR_UNREADABLE') throw new ApiError('Não foi possível ler a redação. Tire outra foto com foco, boa iluminação e texto legível.', 'invalid'); + if (response.status === 400) throw new ApiError('A foto não pôde ser lida. Escolha uma imagem JPG, PNG ou WebP nítida.', 'invalid'); + throw problem; } - const parsed = ocrResponseSchema.safeParse(payload); if (!parsed.success) { - throw new OcrRequestError('O serviço retornou um texto inválido. Tente novamente.'); + setRetryAllowed(true); + throw new ApiError('Não conseguimos abrir o texto extraído. Tente extrair novamente com a mesma foto.', 'unavailable'); } + if (controller.signal.aborted) return; setExtractedText(parsed.data.text); setState('review'); } catch (error) { if (controller.signal.aborted) return; setState('error'); - setErrorMessage(error instanceof Error ? error.message : 'Erro de conexão. Tente novamente.'); - setErrorCode(error instanceof OcrRequestError ? error.code ?? null : null); + setErrorMessage(failureMessage(error, 'Não foi possível conectar. Sua foto foi mantida para tentar novamente.')); + if (!(error instanceof ApiError)) setRetryAllowed(true); } finally { if (requestControllerRef.current === controller) { requestControllerRef.current = null; } } - }, [selectedFile]); + }, [selectedFile, disabled, secondsToRetry]); - const handleUseText = useCallback(() => { + const applyText = useCallback(() => { + if (disabled) return; onTextExtracted(extractedText); reset(); - }, [extractedText, onTextExtracted, reset]); + }, [disabled, extractedText, onTextExtracted, reset]); + + const handleUseText = () => { + if (currentText.trim() && currentText !== extractedText) setConfirmReplace(true); + else applyText(); + }; + useEffect(() => { if (state === 'review') reviewRef.current?.focus(); }, [state]); + useEffect(() => { if (confirmReplace) confirmRef.current?.focus(); }, [confirmReplace]); const fileInput = ( { event.currentTarget.value = ''; }} onChange={handleFileSelect} - aria-describedby="photo-upload-guidance" + aria-label="Selecionar foto da redação" className="hidden" /> ); @@ -268,7 +270,7 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr ); } - const canRetrySamePhoto = errorCode === 'OCR_UNAVAILABLE' && selectedFile !== null; + const canRetrySamePhoto = retryAllowed && selectedFile !== null; return (
    @@ -282,6 +284,7 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr
    )} - {(state === 'preview' || state === 'extracting') && previewUrl && ( + {previewUrl && (
    {/* Blob URLs are local previews and cannot use the Next.js image optimizer. */} {/* eslint-disable-next-line @next/next/no-img-element */} - Prévia da redação + Foto da redação para comparação com o texto extraído {state === 'extracting' && (
    @@ -310,16 +313,18 @@ export default function PhotoUpload({ onTextExtracted, disabled }: PhotoUploadPr )}
    + Abrir foto em tamanho completo {optimized && (

    A foto foi otimizada no seu navegador antes do envio.

    )} {state === 'preview' && ( -
    +