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