feat: Add Jus IA Start Kit — MPA wizard for legal prompt assembly

Fastify v5 + TypeScript + Nunjucks + Tailwind CSS v4 application that
guides Brazilian lawyers through a step-by-step flow to collect case
details and assemble optimized prompts for Jus IA.

Includes:
- Flow engine with registry pattern for area/subtipo flows
- Complete trabalhista/horas-extras flow (2 steps, legal references)
- MPA state management via hidden form fields
- Chip-selector UI with 44px touch targets
- Optional LLM integration for contextual refinement questions
- Prompt builder with template interpolation and URL delivery
- Progressive enhancement JS (copy-to-clipboard)
- Deep link support (/:area/:subtipo)
- Brand design tokens from Jusbrasil guidelines

https://claude.ai/code/session_01CvrcMDqfCKWV2hC3xpRbx3
This commit is contained in:
Claude 2026-03-09 00:00:28 +00:00
parent 872d4ca147
commit 001bd7de0a
No known key found for this signature in database
37 changed files with 1572 additions and 0 deletions

View File

@ -0,0 +1,15 @@
# LLM Provider (openai or anthropic)
LLM_PROVIDER=openai
LLM_API_KEY=sk-your-api-key
LLM_MODEL=gpt-4o-mini
# Server
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
# Analytics (optional)
ANALYTICS_ID=
# Error Tracking (optional)
SENTRY_DSN=

5
jus-ia-start-kit/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.env
src/public/css/app.css
*.tsbuildinfo

View File

@ -0,0 +1,35 @@
{
"name": "jus-ia-start-kit",
"version": "0.1.0",
"description": "Assistente web que guia advogados na construção de pedidos otimizados para o Jus IA",
"type": "module",
"scripts": {
"dev": "concurrently \"npm run dev:server\" \"npm run dev:css\"",
"dev:server": "tsx watch src/server.ts",
"dev:css": "npx @tailwindcss/cli -i src/styles/input.css -o src/public/css/app.css --watch",
"build": "npm run build:css && npm run build:server",
"build:server": "tsc",
"build:css": "npx @tailwindcss/cli -i src/styles/input.css -o src/public/css/app.css --minify",
"start": "node dist/server.js",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fastify/formbody": "^8.0.2",
"@fastify/static": "^8.1.0",
"@fastify/view": "^10.0.1",
"fastify": "^5.2.1",
"nunjucks": "^3.2.4"
},
"devDependencies": {
"@tailwindcss/cli": "^4.0.14",
"@types/node": "^22.12.0",
"@types/nunjucks": "^3.2.6",
"concurrently": "^9.1.2",
"pino-pretty": "^13.1.3",
"tailwindcss": "^4.0.14",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,14 @@
/** Max URL length for Jus IA redirect (conservative to account for encoding) */
export const MAX_URL_LENGTH = 1800;
/** Jus IA base URL for redirect */
export const JUS_IA_BASE_URL = "https://ia.jusbrasil.com.br/conversa";
/** LLM timeout in milliseconds */
export const LLM_TIMEOUT_MS = 10_000;
/** Max input text length for user responses */
export const MAX_INPUT_LENGTH = 500;
/** Max refinement questions from LLM */
export const MAX_REFINEMENT_QUESTIONS = 3;

View File

@ -0,0 +1,60 @@
import type { FlowConfig } from "../flows/types.js";
import { horasExtrasFlow } from "../flows/trabalhista/horas-extras.js";
/** Registry of all available flows */
const flowRegistry: Map<string, FlowConfig> = new Map();
function registerFlow(flow: FlowConfig): void {
const key = `${flow.area}/${flow.subtipo}`;
flowRegistry.set(key, flow);
}
// Register all flows
registerFlow(horasExtrasFlow);
// TODO: Register remaining 9 flows as they are built
/** Get a flow by area/subtipo */
export function getFlow(area: string, subtipo: string): FlowConfig | undefined {
return flowRegistry.get(`${area}/${subtipo}`);
}
/** Get all flows for an area */
export function getFlowsByArea(area: string): FlowConfig[] {
const flows: FlowConfig[] = [];
for (const [key, flow] of flowRegistry) {
if (key.startsWith(`${area}/`)) {
flows.push(flow);
}
}
return flows;
}
/** Get all available areas */
export function getAreas(): Array<{ value: string; label: string }> {
const areas = new Map<string, string>();
for (const flow of flowRegistry.values()) {
areas.set(flow.area, flow.areaLabel);
}
return Array.from(areas, ([value, label]) => ({ value, label }));
}
/** Get all available task types */
export function getTiposTarefa(): Array<{ value: string; label: string }> {
return [
{ value: "peticao-inicial", label: "Petição Inicial" },
{ value: "contestacao", label: "Contestação" },
{ value: "pesquisa-jurisprudencia", label: "Pesquisa de Jurisprudência" },
{ value: "parecer", label: "Parecer Jurídico" },
{ value: "contrato", label: "Contrato" },
];
}
/** Check if a flow exists */
export function flowExists(area: string, subtipo: string): boolean {
return flowRegistry.has(`${area}/${subtipo}`);
}
/** Get all registered flows */
export function getAllFlows(): FlowConfig[] {
return Array.from(flowRegistry.values());
}

View File

@ -0,0 +1,12 @@
export const config = {
port: parseInt(process.env.PORT || "3000", 10),
host: process.env.HOST || "0.0.0.0",
nodeEnv: process.env.NODE_ENV || "development",
llm: {
provider: process.env.LLM_PROVIDER || "openai",
apiKey: process.env.LLM_API_KEY || "",
model: process.env.LLM_MODEL || "gpt-4o-mini",
},
analyticsId: process.env.ANALYTICS_ID || "",
sentryDsn: process.env.SENTRY_DSN || "",
} as const;

View File

@ -0,0 +1,11 @@
export const civelArea = {
value: "civel",
label: "Cível",
subtipos: [
{ value: "cobranca", label: "Cobrança", available: false },
{ value: "indenizacao", label: "Indenização", available: false },
{ value: "obrigacao-fazer", label: "Obrigação de Fazer", available: false },
{ value: "contestacao", label: "Contestação Cível", available: false },
{ value: "contrato", label: "Contrato (Revisão)", available: false },
],
};

View File

@ -0,0 +1,168 @@
import type { FlowConfig } from "../types.js";
export const horasExtrasFlow: FlowConfig = {
area: "trabalhista",
areaLabel: "Trabalhista",
subtipo: "horas-extras",
subtipoLabel: "Horas Extras",
tipoTarefa: "peticao-inicial",
steps: [
{
stepNumber: 1,
title: "Dados do Caso",
requiresLlm: false,
groups: [
{
title: "Relação de Trabalho",
questions: [
{
id: "empregador_tipo",
text: "O empregador é pessoa jurídica ou física?",
type: "select",
options: ["Pessoa Jurídica (empresa)", "Pessoa Física"],
required: true,
},
{
id: "regime",
text: "Qual o regime de trabalho?",
type: "select",
options: ["CLT", "PJ", "Autônomo", "Temporário"],
required: true,
},
{
id: "jornada_contratual",
text: "Qual a jornada contratual?",
type: "select",
options: ["44h semanais", "36h semanais", "30h semanais", "Outra"],
required: true,
},
],
},
{
title: "Período",
questions: [
{
id: "data_inicio",
text: "Data de início do contrato",
type: "date",
required: true,
},
{
id: "data_fim",
text: "Data de término (ou atual se ainda empregado)",
type: "date",
required: true,
},
{
id: "ainda_empregado",
text: "Ainda está empregado?",
type: "select",
options: ["Sim", "Não"],
required: true,
},
],
},
{
title: "Horas Extras",
questions: [
{
id: "horas_extras_semana",
text: "Quantas horas extras estimadas por semana?",
type: "select",
options: [
"Até 5 horas",
"5 a 10 horas",
"10 a 20 horas",
"Mais de 20 horas",
],
required: true,
},
{
id: "banco_horas",
text: "Havia banco de horas?",
type: "select",
options: ["Sim, formal", "Sim, informal", "Não"],
required: true,
},
],
},
],
},
{
stepNumber: 2,
title: "Detalhes do Caso",
requiresLlm: true,
groups: [
{
title: "Provas e Evidências",
questions: [
{
id: "registro_ponto",
text: "Havia registro de ponto?",
type: "select",
options: [
"Sim, eletrônico",
"Sim, manual",
"Não havia controle",
],
required: true,
},
{
id: "testemunhas",
text: "Existem testemunhas?",
type: "select",
options: ["Sim", "Não", "Não sei"],
required: true,
},
{
id: "pagamento_parcial",
text: "Houve pagamento parcial de horas extras?",
type: "select",
options: [
"Sim, parcial (algumas horas pagas)",
"Não, nenhum pagamento",
"Sim, todas pagas mas sem adicional correto",
],
required: true,
},
],
},
],
},
],
promptTemplate: `Elabore uma petição inicial trabalhista de horas extras com os seguintes dados:
**Partes:**
- Reclamante: [a ser preenchido pelo advogado]
- Reclamada: {{empregador_tipo}}
**Vínculo empregatício:**
- Regime: {{regime}}
- Jornada contratual: {{jornada_contratual}}
- Período: {{data_inicio}} a {{data_fim}}
- Situação atual: {{ainda_empregado}}
**Horas extras:**
- Volume estimado: {{horas_extras_semana}} por semana
- Banco de horas: {{banco_horas}}
- Registro de ponto: {{registro_ponto}}
- Pagamento anterior: {{pagamento_parcial}}
- Testemunhas: {{testemunhas}}
{{#refinement_context}}
**Fundamente com base em:**
- Art. 59 da CLT (limite de horas extras)
- Art. 71 da CLT (intervalo intrajornada)
- Súmula 85 do TST (compensação de jornada)
- Súmula 338 do TST (ônus da prova do registro de ponto)
Inclua pedidos de: horas extras com adicional de 50% (dias úteis) e 100% (domingos/feriados), reflexos em DSR, férias + 1/3, 13º salário, FGTS + 40%, e honorários advocatícios.`,
legalReferences: [
"art. 59 CLT",
"art. 71 CLT",
"Súmula 85 TST",
"Súmula 338 TST",
],
};

View File

@ -0,0 +1,11 @@
export const trabalhistaArea = {
value: "trabalhista",
label: "Trabalhista",
subtipos: [
{ value: "horas-extras", label: "Horas Extras" },
{ value: "rescisao-indireta", label: "Rescisão Indireta", available: false },
{ value: "dano-moral", label: "Dano Moral", available: false },
{ value: "acumulo-funcao", label: "Acúmulo de Função", available: false },
{ value: "contestacao", label: "Contestação Trabalhista", available: false },
],
};

View File

@ -0,0 +1,63 @@
/** A single question in a flow step */
export interface FlowQuestion {
id: string;
text: string;
type: "select" | "multiselect" | "text" | "date";
options?: string[];
placeholder?: string;
required: boolean;
}
/** A group of questions displayed together (mental moment) */
export interface QuestionGroup {
title: string;
questions: FlowQuestion[];
}
/** A single step in a flow */
export interface FlowStep {
stepNumber: number;
title: string;
groups: QuestionGroup[];
requiresLlm: boolean;
}
/** Complete flow configuration for a legal subtype */
export interface FlowConfig {
area: string;
areaLabel: string;
subtipo: string;
subtipoLabel: string;
tipoTarefa: string;
steps: FlowStep[];
promptTemplate: string;
legalReferences: string[];
}
/** Accumulated state across MPA pages */
export interface FlowState {
area: string;
subtipo: string;
tipoTarefa: string;
currentStep: number;
totalSteps: number;
responses: Record<string, string | string[]>;
}
/** Parsed LLM refinement response */
export interface RefinementQuestion {
id: string;
text: string;
type: "select" | "multiselect" | "text";
options?: string[];
placeholder?: string;
}
/** Assembled prompt ready for delivery */
export interface AssembledPrompt {
text: string;
legalReferences: string[];
charCount: number;
fitsInUrl: boolean;
encodedUrl?: string;
}

View File

@ -0,0 +1,64 @@
/**
* Progressive Enhancement Jus IA Start Kit
* Everything works without this file (MPA form submissions).
* This adds: copy-to-clipboard, chip visual feedback, smooth interactions.
*/
// Copy to clipboard
function setupCopyButtons() {
document.querySelectorAll('[id^="copy-btn"]').forEach((btn) => {
btn.addEventListener("click", async () => {
const textarea = document.getElementById("prompt-text");
const text = textarea
? textarea.value
: btn.dataset.promptText || "";
try {
await navigator.clipboard.writeText(text);
const originalHTML = btn.innerHTML;
btn.innerHTML = `
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
Copiado!
`;
btn.classList.add("bg-[var(--color-success)]");
setTimeout(() => {
btn.innerHTML = originalHTML;
btn.classList.remove("bg-[var(--color-success)]");
}, 2000);
} catch {
// Fallback: select text in textarea
if (textarea) {
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
}
}
});
});
}
// Chip visual feedback (check icon on selection)
function setupChipFeedback() {
document.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach((input) => {
if (input.closest(".peer")) {
input.addEventListener("change", () => {
// For radio buttons, remove check from siblings
if (input.type === "radio" && input.name) {
document.querySelectorAll(`input[name="${input.name}"]`).forEach((sibling) => {
const span = sibling.nextElementSibling;
if (span) {
span.querySelectorAll(".check-icon").forEach((icon) => icon.remove());
}
});
}
});
}
});
}
// Initialize on DOM ready
document.addEventListener("DOMContentLoaded", () => {
setupCopyButtons();
setupChipFeedback();
});

View File

@ -0,0 +1,186 @@
import type { FastifyInstance } from "fastify";
import { getFlow, flowExists, getAllFlows } from "../config/flows.js";
import { getCurrentStep, calculateTotalSteps, getVisualStep } from "../services/flow-engine.js";
import { buildPrompt } from "../services/prompt-builder.js";
import { getJusIaDirectUrl } from "../services/url-builder.js";
import { getRefinementQuestions } from "../services/llm-client.js";
import { parseFlowState, serializeFlowState } from "../utils/parse-flow-state.js";
import type { FlowState, QuestionGroup } from "../flows/types.js";
export async function flowRoutes(app: FastifyInstance): Promise<void> {
// POST /:area/iniciar — Start a flow after subtipo selection
app.post<{ Params: { area: string }; Body: Record<string, string> }>(
"/:area/iniciar",
async (request, reply) => {
const { area } = request.params;
const subtipo = request.body.subtipo;
const tipoTarefa = request.body._tipo_tarefa || "peticao-inicial";
if (!flowExists(area, subtipo)) {
return reply.view("pages/not-available.njk", {
availableFlows: getAllFlows(),
});
}
const flow = getFlow(area, subtipo)!;
const totalSteps = calculateTotalSteps(flow, false);
const flowState: FlowState = {
area,
subtipo,
tipoTarefa,
currentStep: 1,
totalSteps,
responses: {},
};
const step = flow.steps[0];
if (!step) {
return reply.view("pages/error.njk", {
errorMessage: "Fluxo sem perguntas configuradas.",
retryable: true,
retryUrl: "/",
});
}
return reply.view("pages/flow-step.njk", {
stepTitle: step.title,
flowLabel: `${flow.areaLabel}${flow.subtipoLabel}`,
groups: step.groups,
flowState,
serializedResponses: serializeFlowState(flowState),
formAction: `/${area}/${subtipo}/step/1`,
backHref: "/",
isLastStep: flow.steps.length === 1,
currentVisualStep: getVisualStep(flow, 1, false),
totalSteps,
});
},
);
// GET /:area/:subtipo — Deep link entry (skip selection)
app.get<{ Params: { area: string; subtipo: string } }>(
"/:area/:subtipo",
async (request, reply) => {
const { area, subtipo } = request.params;
if (!flowExists(area, subtipo)) {
return reply.view("pages/not-available.njk", {
availableFlows: getAllFlows(),
});
}
const flow = getFlow(area, subtipo)!;
const totalSteps = calculateTotalSteps(flow, true);
const flowState: FlowState = {
area,
subtipo,
tipoTarefa: flow.tipoTarefa,
currentStep: 1,
totalSteps,
responses: {},
};
const step = flow.steps[0]!;
return reply.view("pages/flow-step.njk", {
stepTitle: step.title,
flowLabel: `${flow.areaLabel}${flow.subtipoLabel}`,
groups: step.groups,
flowState,
serializedResponses: serializeFlowState(flowState),
formAction: `/${area}/${subtipo}/step/1`,
backHref: "/",
isLastStep: flow.steps.length === 1,
currentVisualStep: getVisualStep(flow, 1, true),
totalSteps,
});
},
);
// POST /:area/:subtipo/step/:stepNumber — Handle step submission
app.post<{ Params: { area: string; subtipo: string; stepNumber: string }; Body: Record<string, unknown> }>(
"/:area/:subtipo/step/:stepNumber",
async (request, reply) => {
const { area, subtipo, stepNumber } = request.params;
const currentStepNum = parseInt(stepNumber, 10);
const flow = getFlow(area, subtipo);
if (!flow) {
return reply.view("pages/not-available.njk", {
availableFlows: getAllFlows(),
});
}
// Parse accumulated state + new responses
const flowState = parseFlowState(request.body as Record<string, unknown>);
flowState.area = area;
flowState.subtipo = subtipo;
const hasDeepLink = flowState.totalSteps < 5; // Heuristic: deep link has fewer total steps
const nextStepNum = currentStepNum + 1;
const nextStep = flow.steps.find((s) => s.stepNumber === nextStepNum);
// If there's a next step, render it
if (nextStep) {
flowState.currentStep = nextStepNum;
// If next step requires LLM, get refinement questions
let groups: QuestionGroup[] = nextStep.groups;
if (nextStep.requiresLlm) {
const refinementQuestions = await getRefinementQuestions(
flowState,
flow.areaLabel,
flow.subtipoLabel,
);
if (refinementQuestions.length > 0) {
// Add LLM questions to the existing groups
groups = [
...nextStep.groups,
{
title: "Perguntas específicas do seu caso",
questions: refinementQuestions.map((q) => ({
...q,
required: true,
})),
},
];
}
}
return reply.view("pages/flow-step.njk", {
stepTitle: nextStep.title,
flowLabel: `${flow.areaLabel}${flow.subtipoLabel}`,
groups,
flowState,
serializedResponses: serializeFlowState(flowState),
formAction: `/${area}/${subtipo}/step/${nextStepNum}`,
backHref: `/${area}/${subtipo}/step/${currentStepNum - 1}`,
isLastStep: !flow.steps.find((s) => s.stepNumber === nextStepNum + 1),
currentVisualStep: getVisualStep(flow, nextStepNum, hasDeepLink),
totalSteps: flowState.totalSteps,
});
}
// No more steps — build prompt and show preview
const prompt = buildPrompt(flow, flowState);
return reply.view("pages/preview.njk", {
flowLabel: `uma ${flow.subtipoLabel.toLowerCase()} ${flow.areaLabel.toLowerCase()}`,
promptText: prompt.text,
legalReferences: prompt.legalReferences,
fitsInUrl: prompt.fitsInUrl,
encodedUrl: prompt.encodedUrl,
jusIaUrl: getJusIaDirectUrl(),
currentVisualStep: flowState.totalSteps,
totalSteps: flowState.totalSteps,
});
},
);
// GET /health — Health check
app.get("/health", async () => {
return { status: "ok" };
});
}

View File

@ -0,0 +1,51 @@
import type { FastifyInstance } from "fastify";
import { getAreas, getTiposTarefa } from "../config/flows.js";
import { trabalhistaArea } from "../flows/trabalhista/index.js";
import { civelArea } from "../flows/civel/index.js";
export async function homeRoutes(app: FastifyInstance): Promise<void> {
// GET / — Landing page
app.get("/", async (_request, reply) => {
return reply.view("pages/home.njk", {
tiposTarefa: getTiposTarefa(),
areas: getAreas(),
});
});
// POST /selecionar — Handle tipo + area selection
app.post<{ Body: { tipo_tarefa: string; area: string } }>(
"/selecionar",
async (request, reply) => {
const { tipo_tarefa, area } = request.body;
// Get subtipos for the selected area
let areaConfig;
let areaLabel = "";
if (area === "trabalhista") {
areaConfig = trabalhistaArea;
areaLabel = "Trabalhista";
} else if (area === "civel") {
areaConfig = civelArea;
areaLabel = "Cível";
}
if (!areaConfig) {
return reply.view("pages/not-available.njk", {
availableFlows: [],
});
}
const tiposTarefa = getTiposTarefa();
const tipoTarefaLabel =
tiposTarefa.find((t) => t.value === tipo_tarefa)?.label || tipo_tarefa;
return reply.view("pages/select-subtipo.njk", {
area,
areaLabel,
tipoTarefa: tipo_tarefa,
tipoTarefaLabel,
subtipos: areaConfig.subtipos,
});
},
);
}

View File

@ -0,0 +1,61 @@
import Fastify from "fastify";
import fastifyView from "@fastify/view";
import fastifyStatic from "@fastify/static";
import fastifyFormbody from "@fastify/formbody";
import nunjucks from "nunjucks";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { config } from "./config/index.js";
import { homeRoutes } from "./routes/home.js";
import { flowRoutes } from "./routes/flow.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = Fastify({
logger: {
level: config.nodeEnv === "production" ? "info" : "debug",
transport:
config.nodeEnv === "development"
? { target: "pino-pretty", options: { translateTime: "HH:MM:ss" } }
: undefined,
},
});
// Parse form body (application/x-www-form-urlencoded)
await app.register(fastifyFormbody);
// Serve static assets
await app.register(fastifyStatic, {
root: join(__dirname, "public"),
prefix: "/",
});
// Nunjucks template engine
const nunjucksEnv = nunjucks.configure(join(__dirname, "templates"), {
autoescape: true,
noCache: config.nodeEnv === "development",
});
await app.register(fastifyView, {
engine: { nunjucks },
templates: join(__dirname, "templates"),
options: {
onConfigure: (env: nunjucks.Environment) => {
// Add any custom filters here
},
},
});
// Register routes
await app.register(homeRoutes);
await app.register(flowRoutes);
// Start server
try {
const address = await app.listen({ port: config.port, host: config.host });
app.log.info(`Jus IA Start Kit running at ${address}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

View File

@ -0,0 +1,49 @@
import type { FlowConfig, FlowState, FlowStep } from "../flows/types.js";
import { getFlow } from "../config/flows.js";
/** Get the current step configuration for a flow state */
export function getCurrentStep(state: FlowState): FlowStep | null {
const flow = getFlow(state.area, state.subtipo);
if (!flow) return null;
const step = flow.steps.find((s) => s.stepNumber === state.currentStep);
return step ?? null;
}
/** Get the full flow config */
export function getFlowConfig(area: string, subtipo: string): FlowConfig | null {
return getFlow(area, subtipo) ?? null;
}
/** Calculate total steps: selection screens + flow steps + preview */
export function calculateTotalSteps(flow: FlowConfig, hasDeepLink: boolean): number {
const selectionSteps = hasDeepLink ? 0 : 2; // tipo+area, subtipo
const flowSteps = flow.steps.length;
const previewStep = 1;
return selectionSteps + flowSteps + previewStep;
}
/** Determine the visual step number for the progress indicator */
export function getVisualStep(
flow: FlowConfig,
currentFlowStep: number,
hasDeepLink: boolean,
): number {
const offset = hasDeepLink ? 0 : 2;
return offset + currentFlowStep;
}
/** Check if all required questions in a step are answered */
export function isStepComplete(step: FlowStep, responses: Record<string, string | string[]>): boolean {
for (const group of step.groups) {
for (const question of group.questions) {
if (question.required) {
const answer = responses[question.id];
if (!answer || (typeof answer === "string" && !answer.trim())) {
return false;
}
}
}
}
return true;
}

View File

@ -0,0 +1,117 @@
import type { RefinementQuestion, FlowState } from "../flows/types.js";
import { config } from "../config/index.js";
import { LLM_TIMEOUT_MS, MAX_REFINEMENT_QUESTIONS } from "../config/constants.js";
const SYSTEM_PROMPT = `Você é um assistente jurídico especializado em direito brasileiro.
Dado o contexto de um caso jurídico, gere perguntas de refinamento para capturar nuances importantes.
Regras:
- Gere no máximo ${MAX_REFINEMENT_QUESTIONS} perguntas
- Cada pergunta deve ser relevante ao caso e área do direito
- Prefira perguntas de seleção (com opções) a perguntas de texto livre
- Use linguagem jurídica profissional mas acessível
- As perguntas devem ajudar a construir um pedido mais preciso
Responda APENAS em JSON no formato:
{
"questions": [
{
"id": "identificador_unico",
"text": "Texto da pergunta",
"type": "select",
"options": ["Opção 1", "Opção 2", "Opção 3"]
}
]
}`;
/** Call LLM for contextual refinement questions */
export async function getRefinementQuestions(
state: FlowState,
areaLabel: string,
subtipoLabel: string,
): Promise<RefinementQuestion[]> {
if (!config.llm.apiKey) {
// No LLM configured — return empty (skip refinement)
return [];
}
const userMessage = `Caso: ${areaLabel} - ${subtipoLabel}
Dados coletados até agora:
${Object.entries(state.responses)
.map(([key, value]) => `- ${key}: ${Array.isArray(value) ? value.join(", ") : value}`)
.join("\n")}
Gere perguntas de refinamento para capturar nuances deste caso.`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LLM_TIMEOUT_MS);
let response: Response;
if (config.llm.provider === "anthropic") {
response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.llm.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: config.llm.model,
max_tokens: 1024,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMessage }],
}),
signal: controller.signal,
});
} else {
// Default: OpenAI-compatible
response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.llm.apiKey}`,
},
body: JSON.stringify({
model: config.llm.model,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
temperature: 0.3,
response_format: { type: "json_object" },
}),
signal: controller.signal,
});
}
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`LLM API error: ${response.status}`);
}
const data = await response.json();
let content: string;
if (config.llm.provider === "anthropic") {
content = data.content?.[0]?.text || "{}";
} else {
content = data.choices?.[0]?.message?.content || "{}";
}
const parsed = JSON.parse(content);
const questions: RefinementQuestion[] = (parsed.questions || []).slice(
0,
MAX_REFINEMENT_QUESTIONS,
);
return questions;
} catch (error) {
// LLM failure is non-blocking — skip refinement
console.error("LLM refinement failed:", (error as Error).message);
return [];
}
}

View File

@ -0,0 +1,32 @@
import type { AssembledPrompt, FlowConfig, FlowState } from "../flows/types.js";
import { MAX_URL_LENGTH, JUS_IA_BASE_URL } from "../config/constants.js";
/** Build the final prompt from template + responses */
export function buildPrompt(flow: FlowConfig, state: FlowState): AssembledPrompt {
let text = flow.promptTemplate;
// Replace template variables with responses
for (const [key, value] of Object.entries(state.responses)) {
const displayValue = Array.isArray(value) ? value.join(", ") : value;
text = text.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), displayValue);
}
// Remove any unreplaced template variables
text = text.replace(/\{\{#?\w+\}\}/g, "");
// Clean up extra whitespace
text = text.replace(/\n{3,}/g, "\n\n").trim();
const charCount = text.length;
const encodedQuery = encodeURIComponent(text);
const fullUrl = `${JUS_IA_BASE_URL}?q=${encodedQuery}&send`;
const fitsInUrl = fullUrl.length <= MAX_URL_LENGTH;
return {
text,
legalReferences: flow.legalReferences,
charCount,
fitsInUrl,
encodedUrl: fitsInUrl ? fullUrl : undefined,
};
}

View File

@ -0,0 +1,12 @@
import { JUS_IA_BASE_URL } from "../config/constants.js";
/** Build the Jus IA redirect URL from assembled prompt text */
export function buildRedirectUrl(promptText: string): string {
const encoded = encodeURIComponent(promptText);
return `${JUS_IA_BASE_URL}?q=${encoded}&send`;
}
/** Get the direct Jus IA URL (without query) for copy-paste fallback */
export function getJusIaDirectUrl(): string {
return JUS_IA_BASE_URL;
}

View File

@ -0,0 +1,63 @@
@import "tailwindcss";
@theme {
/* Brand Colors — Jus IA aligned */
--color-primary: #007A5F;
--color-primary-light: #009B78;
--color-primary-dark: #005C47;
--color-accent: #D4A843;
--color-surface: #FFFFFF;
--color-background: #F8F9FA;
--color-text-primary: #5C6F8A;
--color-text-dark: #0F172A;
--color-text-secondary: #6B7280;
--color-border: #B3C0D0;
/* Semantic Colors */
--color-success: #7AB441;
--color-warning: #D97706;
--color-error: #DC2626;
--color-info: #378CC8;
/* Typography */
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", sans-serif;
/* Spacing base: 8px */
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 12px;
--spacing-4: 16px;
--spacing-6: 24px;
--spacing-8: 32px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
}
/* Base styles */
html {
font-family: var(--font-sans);
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100dvh;
}
/* Focus visible for keyboard navigation */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Touch target minimum */
button, a, input, select, [role="radio"], [role="checkbox"] {
min-height: 44px;
}

View File

@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Jus IA Start Kit{% endblock %}</title>
{% block ogTags %}
<meta property="og:title" content="Jus IA Start Kit — Monte seu pedido jurídico">
<meta property="og:description" content="Responda perguntas sobre seu caso e receba um pedido otimizado pronto para gerar resultado na primeira tentativa.">
<meta property="og:type" content="website">
<meta property="og:image" content="/images/og-default.png">
{% endblock %}
<link rel="stylesheet" href="/css/app.css">
<link rel="icon" href="/favicon.ico">
</head>
<body class="min-h-dvh bg-[var(--color-background)] text-[var(--color-text-primary)] font-sans">
<main class="mx-auto max-w-[640px] px-4 pb-8">
{% block header %}
<header class="py-4 flex items-center justify-between">
<a href="/" class="text-[var(--color-text-dark)] font-semibold text-lg no-underline">
Jus IA Start Kit
</a>
{% if totalSteps %}
{% include "partials/_stepper-progress.njk" %}
{% endif %}
</header>
{% endblock %}
{% block content %}{% endblock %}
</main>
{% block scripts %}
<script src="/js/app.js" defer></script>
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,24 @@
{% extends "layouts/base.njk" %}
{% block title %}Erro — Jus IA Start Kit{% endblock %}
{% block content %}
<div class="mt-8 text-center">
<h1 class="text-[var(--color-text-dark)] font-bold text-xl mb-3">
{{ errorTitle or "Algo deu errado" }}
</h1>
<p class="text-[var(--color-text-primary)] text-base mb-6">
{{ errorMessage or "Não conseguimos processar seu pedido. Tente novamente." }}
</p>
{% if retryable %}
<a href="{{ retryUrl or '/' }}" class="inline-flex items-center justify-center px-6 py-3 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base no-underline shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px]">
Tentar novamente
</a>
{% endif %}
<a href="/" class="block mt-4 text-sm text-[var(--color-text-primary)] underline hover:text-[var(--color-primary)]">
Voltar ao início
</a>
</div>
{% endblock %}

View File

@ -0,0 +1,74 @@
{% extends "layouts/base.njk" %}
{% block title %}{{ stepTitle }} — Jus IA Start Kit{% endblock %}
{% block content %}
{% set backUrl = backHref %}
{% include "partials/_back-button.njk" %}
<div class="mb-6">
<h1 class="text-[var(--color-text-dark)] font-bold text-xl leading-tight mb-1">
{{ stepTitle }}
</h1>
<p class="text-[var(--color-text-secondary)] text-sm">
{{ flowLabel }}
</p>
</div>
<form method="POST" action="{{ formAction }}" class="space-y-6">
{% include "partials/_hidden-state.njk" %}
{% for group in groups %}
<div class="bg-white rounded-[var(--radius-sm)] border border-[var(--color-border)]/50 p-4 shadow-[0_2px_4px_rgba(25,52,102,0.04)]">
<h2 class="text-[var(--color-text-dark)] font-semibold text-sm mb-3 uppercase tracking-wide">
{{ group.title }}
</h2>
{% for question in group.questions %}
{% if question.type == "select" or question.type == "multiselect" %}
{% set selectedValue = flowState.responses[question.id] %}
{% include "partials/_chip-selector.njk" %}
{% elif question.type == "date" %}
<div class="mb-4">
<label for="{{ question.id }}" class="block text-[var(--color-text-dark)] font-semibold text-base mb-2">
{{ question.text }}
</label>
<input
type="date"
id="{{ question.id }}"
name="{{ question.id }}"
value="{{ flowState.responses[question.id] or '' }}"
{% if question.required %}required{% endif %}
class="w-full px-3 py-2.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm min-h-[44px] focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)] outline-none"
>
</div>
{% elif question.type == "text" %}
<div class="mb-4">
<label for="{{ question.id }}" class="block text-[var(--color-text-dark)] font-semibold text-base mb-2">
{{ question.text }}
</label>
<input
type="text"
id="{{ question.id }}"
name="{{ question.id }}"
value="{{ flowState.responses[question.id] or '' }}"
placeholder="{{ question.placeholder or '' }}"
{% if question.required %}required{% endif %}
maxlength="500"
class="w-full px-3 py-2.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm min-h-[44px] focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)] outline-none placeholder:text-[var(--color-text-secondary)]"
>
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="w-full py-3 px-4 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px]">
{% if isLastStep %}
Ver meu pedido →
{% else %}
Continuar →
{% endif %}
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,62 @@
{% extends "layouts/base.njk" %}
{% block title %}Jus IA Start Kit — Monte seu pedido jurídico{% endblock %}
{% block content %}
<div class="mt-8 mb-6">
<h1 class="text-[var(--color-text-dark)] font-bold text-2xl leading-tight mb-3">
O que você precisa?
</h1>
<p class="text-[var(--color-text-primary)] text-base">
Responda algumas perguntas sobre seu caso e receba um pedido otimizado para o Jus IA.
</p>
</div>
<form method="POST" action="/selecionar" class="space-y-6">
{# Task type selection #}
<fieldset>
<legend class="text-[var(--color-text-dark)] font-semibold text-base mb-3">
Tipo de tarefa
</legend>
<div class="flex flex-wrap gap-2">
{% for tipo in tiposTarefa %}
<label class="relative cursor-pointer">
<input type="radio" name="tipo_tarefa" value="{{ tipo.value }}" required class="peer sr-only">
<span class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border text-sm font-medium transition-all duration-150 min-h-[44px]
peer-checked:bg-[var(--color-primary)] peer-checked:text-white peer-checked:border-[var(--color-primary)]
peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)] peer-focus-visible:ring-offset-2
border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5
">
{{ tipo.label }}
</span>
</label>
{% endfor %}
</div>
</fieldset>
{# Area selection #}
<fieldset>
<legend class="text-[var(--color-text-dark)] font-semibold text-base mb-3">
Área do direito
</legend>
<div class="flex flex-wrap gap-2">
{% for area in areas %}
<label class="relative cursor-pointer">
<input type="radio" name="area" value="{{ area.value }}" required class="peer sr-only">
<span class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border text-sm font-medium transition-all duration-150 min-h-[44px]
peer-checked:bg-[var(--color-primary)] peer-checked:text-white peer-checked:border-[var(--color-primary)]
peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)] peer-focus-visible:ring-offset-2
border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5
">
{{ area.label }}
</span>
</label>
{% endfor %}
</div>
</fieldset>
<button type="submit" class="w-full py-3 px-4 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px]">
Continuar →
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "layouts/base.njk" %}
{% block title %}Fluxo não disponível — Jus IA Start Kit{% endblock %}
{% block content %}
{% set backUrl = "/" %}
{% include "partials/_back-button.njk" %}
<div class="mt-8 text-center">
<h1 class="text-[var(--color-text-dark)] font-bold text-xl mb-3">
Este fluxo ainda não está disponível
</h1>
<p class="text-[var(--color-text-primary)] text-base mb-6">
Estamos trabalhando para adicionar mais fluxos. Veja os que já estão disponíveis:
</p>
<div class="flex flex-wrap justify-center gap-2">
{% for flow in availableFlows %}
<a href="/{{ flow.area }}/{{ flow.subtipo }}" class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border border-[var(--color-border)] text-sm font-medium text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] no-underline min-h-[44px] transition-colors">
{{ flow.areaLabel }} — {{ flow.subtipoLabel }}
</a>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "layouts/base.njk" %}
{% block title %}Seu pedido está pronto — Jus IA Start Kit{% endblock %}
{% block content %}
<div class="mt-4 mb-6">
<h1 class="text-[var(--color-text-dark)] font-bold text-xl leading-tight mb-2">
Pronto!
</h1>
<p class="text-[var(--color-text-primary)] text-base">
Montamos seu pedido otimizado para o Jus IA. Ele vai gerar {{ flowLabel }} com fundamentação jurídica na primeira tentativa.
</p>
</div>
{% include "partials/_preview-card.njk" %}
{# Always show copy option as secondary #}
{% if fitsInUrl %}
<div class="mt-4 text-center">
<button
type="button"
id="copy-btn-secondary"
class="text-sm text-[var(--color-text-primary)] underline hover:text-[var(--color-primary)] min-h-[44px]"
data-prompt-text="{{ promptText | replace('"', '&quot;') }}"
>
Copiar pedido
</button>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "layouts/base.njk" %}
{% block title %}{{ areaLabel }} — Jus IA Start Kit{% endblock %}
{% block content %}
{% set backUrl = "/" %}
{% include "partials/_back-button.njk" %}
<div class="mb-6">
<h1 class="text-[var(--color-text-dark)] font-bold text-xl leading-tight mb-2">
Que tipo de {{ tipoTarefaLabel | lower }}?
</h1>
<p class="text-[var(--color-text-primary)] text-sm">
{{ areaLabel }}
</p>
</div>
<form method="POST" action="/{{ area }}/iniciar" class="space-y-4">
<input type="hidden" name="_tipo_tarefa" value="{{ tipoTarefa }}">
<input type="hidden" name="_area" value="{{ area }}">
<fieldset>
<legend class="sr-only">Selecione o subtipo</legend>
<div class="flex flex-wrap gap-2">
{% for sub in subtipos %}
{% if sub.available !== false %}
<label class="relative cursor-pointer">
<input type="radio" name="subtipo" value="{{ sub.value }}" required class="peer sr-only">
<span class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border text-sm font-medium transition-all duration-150 min-h-[44px]
peer-checked:bg-[var(--color-primary)] peer-checked:text-white peer-checked:border-[var(--color-primary)]
peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)] peer-focus-visible:ring-offset-2
border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5
">
{{ sub.label }}
</span>
</label>
{% else %}
<span class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border text-sm font-medium min-h-[44px] border-[var(--color-border)]/50 text-[var(--color-text-secondary)] opacity-50 cursor-not-allowed">
{{ sub.label }}
<span class="ml-1 text-xs">(em breve)</span>
</span>
{% endif %}
{% endfor %}
</div>
</fieldset>
<button type="submit" class="w-full py-3 px-4 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px]">
Continuar →
</button>
</form>
{% endblock %}

View File

@ -0,0 +1,10 @@
{# Back Button — secondary action, top of page #}
{# Expects: backUrl (string, optional) #}
{% if backUrl %}
<a href="{{ backUrl }}" class="inline-flex items-center gap-1 text-sm text-[var(--color-text-primary)] hover:text-[var(--color-primary)] no-underline mb-4 min-h-[44px]">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
Voltar
</a>
{% endif %}

View File

@ -0,0 +1,35 @@
{# Chip Selector — single or multi select #}
{# Expects: question (FlowQuestion object), selectedValue (string or array) #}
<fieldset class="mb-4">
<legend class="text-[var(--color-text-dark)] font-semibold text-base mb-3">
{{ question.text }}
</legend>
<div class="flex flex-wrap gap-2">
{% for option in question.options %}
{% set isSelected = (selectedValue == option) or (selectedValue and option in selectedValue) %}
<label class="relative cursor-pointer">
<input
type="{{ 'checkbox' if question.type == 'multiselect' else 'radio' }}"
name="{{ question.id }}"
value="{{ option }}"
{% if isSelected %}checked{% endif %}
{% if question.required %}required{% endif %}
class="peer sr-only"
>
<span class="inline-flex items-center px-4 py-2.5 rounded-[var(--radius-md)] border text-sm font-medium transition-all duration-150 min-h-[44px]
peer-checked:bg-[var(--color-primary)] peer-checked:text-white peer-checked:border-[var(--color-primary)]
peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-primary)] peer-focus-visible:ring-offset-2
border-[var(--color-border)] text-[var(--color-text-primary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5
">
<span class="peer-checked:hidden">{{ option }}</span>
<span class="hidden peer-checked:inline-flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ option }}
</span>
</span>
</label>
{% endfor %}
</div>
</fieldset>

View File

@ -0,0 +1,38 @@
{# Copy Fallback — when URL exceeds limit #}
{# Expects: promptText (string), jusIaUrl (string) #}
<div class="space-y-3">
<p class="text-sm text-[var(--color-text-primary)] font-medium">
Seu pedido está pronto! Copie e cole no Jus IA:
</p>
<div class="relative">
<textarea
id="prompt-text"
readonly
rows="4"
class="w-full p-3 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-background)] text-sm text-[var(--color-text-primary)] resize-none"
aria-label="Pedido para copiar"
>{{ promptText }}</textarea>
</div>
<button
type="button"
id="copy-btn"
aria-label="Copiar pedido para a área de transferência"
class="w-full py-3 px-4 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px] flex items-center justify-center gap-2"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
Copiar pedido
</button>
<a
href="{{ jusIaUrl }}"
target="_blank"
rel="noopener"
class="block w-full text-center py-3 px-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white text-[var(--color-primary)] font-semibold text-base no-underline hover:bg-[var(--color-background)] transition-colors min-h-[48px]"
>
Abrir Jus IA
</a>
</div>

View File

@ -0,0 +1,8 @@
{# Hidden State — preserves flow state between MPA pages #}
{# Expects: flowState (FlowState object) #}
<input type="hidden" name="_area" value="{{ flowState.area }}">
<input type="hidden" name="_subtipo" value="{{ flowState.subtipo }}">
<input type="hidden" name="_tipo_tarefa" value="{{ flowState.tipoTarefa }}">
<input type="hidden" name="_step" value="{{ flowState.currentStep }}">
<input type="hidden" name="_total_steps" value="{{ flowState.totalSteps }}">
<input type="hidden" name="_responses" value='{{ serializedResponses }}'>

View File

@ -0,0 +1,8 @@
{# Legal Badge — pill with legal reference #}
{# Expects: reference (string like "art. 59 CLT") #}
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-[var(--color-accent)]/15 text-[var(--color-text-dark)] text-xs font-medium">
<svg class="w-3.5 h-3.5 text-[var(--color-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/>
</svg>
{{ reference }}
</span>

View File

@ -0,0 +1,12 @@
{# Loading State — skeleton shimmer during LLM call #}
<div aria-busy="true" aria-live="polite" class="space-y-4 animate-pulse">
<p class="text-[var(--color-text-dark)] font-medium text-center">
Analisando seu caso...
</p>
<div class="space-y-3">
<div class="h-4 bg-[var(--color-border)]/40 rounded w-3/4"></div>
<div class="h-10 bg-[var(--color-border)]/30 rounded-[var(--radius-md)]"></div>
<div class="h-10 bg-[var(--color-border)]/30 rounded-[var(--radius-md)]"></div>
<div class="h-10 bg-[var(--color-border)]/30 rounded-[var(--radius-md)]"></div>
</div>
</div>

View File

@ -0,0 +1,31 @@
{# Preview Card — the "aha moment" card #}
{# Expects: prompt (AssembledPrompt), flow (FlowConfig) #}
<article class="bg-white rounded-[var(--radius-sm)] border-2 border-[var(--color-accent)]/40 p-5 shadow-[0_2px_4px_rgba(25,52,102,0.04)]">
<h2 class="text-[var(--color-text-dark)] font-semibold text-lg mb-3">
Seu pedido otimizado para o Jus IA
</h2>
<div class="text-sm text-[var(--color-text-primary)] mb-4 leading-relaxed whitespace-pre-line">
{{ promptText | truncate(300) }}
</div>
{# Legal reference badges #}
<div class="flex flex-wrap gap-2 mb-5">
{% for ref in legalReferences %}
{% include "partials/_legal-badge.njk" %}
{% endfor %}
</div>
{# Delivery method #}
{% if fitsInUrl %}
<a href="{{ encodedUrl }}" class="block w-full text-center py-3 px-4 rounded-[var(--radius-md)] bg-[var(--color-primary)] text-white font-semibold text-base no-underline shadow-[0_2px_4px_rgba(25,52,102,0.04)] hover:bg-[var(--color-primary-dark)] transition-colors min-h-[48px] flex items-center justify-center">
Gerar no Jus IA →
</a>
{% else %}
{% include "partials/_copy-fallback.njk" %}
{% endif %}
<p class="text-center text-xs text-[var(--color-text-secondary)] mt-3">
Seus dados não são armazenados após o redirecionamento
</p>
</article>

View File

@ -0,0 +1,15 @@
{# Stepper Progress — compact visual indicator #}
{# Expects: currentVisualStep, totalSteps #}
<div role="progressbar" aria-valuenow="{{ currentVisualStep }}" aria-valuemax="{{ totalSteps }}" aria-label="Progresso do fluxo" class="flex gap-1.5 items-center">
{% for i in range(1, totalSteps + 1) %}
<div class="h-1.5 flex-1 rounded-full transition-colors duration-300
{% if i < currentVisualStep %}
bg-[var(--color-primary)]
{% elif i == currentVisualStep %}
bg-[var(--color-primary)] animate-pulse
{% else %}
bg-[var(--color-border)]
{% endif %}
"></div>
{% endfor %}
</div>

View File

@ -0,0 +1,49 @@
import type { FlowState } from "../flows/types.js";
import { sanitizeText } from "./sanitize.js";
/** Parse FlowState from form body hidden fields */
export function parseFlowState(body: Record<string, unknown>): FlowState {
const responses: Record<string, string | string[]> = {};
// Parse _responses JSON from hidden field
if (typeof body._responses === "string" && body._responses) {
try {
const parsed = JSON.parse(body._responses);
if (typeof parsed === "object" && parsed !== null) {
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") {
responses[key] = sanitizeText(value);
} else if (Array.isArray(value)) {
responses[key] = value.map((v) => sanitizeText(String(v)));
}
}
}
} catch {
// Invalid JSON, skip
}
}
// Merge current step's new responses
for (const [key, value] of Object.entries(body)) {
if (key.startsWith("_")) continue; // Skip internal fields
if (typeof value === "string") {
responses[key] = sanitizeText(value);
} else if (Array.isArray(value)) {
responses[key] = value.map((v) => sanitizeText(String(v)));
}
}
return {
area: sanitizeText(String(body._area || "")),
subtipo: sanitizeText(String(body._subtipo || "")),
tipoTarefa: sanitizeText(String(body._tipo_tarefa || "")),
currentStep: parseInt(String(body._step || "1"), 10),
totalSteps: parseInt(String(body._total_steps || "5"), 10),
responses,
};
}
/** Serialize FlowState to hidden form fields */
export function serializeFlowState(state: FlowState): string {
return JSON.stringify(state.responses);
}

View File

@ -0,0 +1,14 @@
import { MAX_INPUT_LENGTH } from "../config/constants.js";
/** Strip HTML tags and trim whitespace from user input */
export function sanitizeText(input: string): string {
return input
.replace(/<[^>]*>/g, "")
.trim()
.slice(0, MAX_INPUT_LENGTH);
}
/** Validate that a select value is in the allowed options */
export function validateSelect(value: string, allowedOptions: string[]): boolean {
return allowedOptions.includes(value);
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}