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:
parent
872d4ca147
commit
001bd7de0a
|
|
@ -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=
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
src/public/css/app.css
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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('"', '"') }}"
|
||||||
|
>
|
||||||
|
Copiar pedido
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 }}'>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue