feat: scaffold inicial do portal público de autoatendimento fiscal

- Vue 3.5 + Vite 8 + PrimeVue 4 (Aura) + TailwindCSS 4 + DM Sans
- Sistema de tenant multi-prefeitura: bootstrap, prefeituraStore, getTenant
- Tema dinâmico por município via applyTemplate (9 paletas)
- Logo e foto de fundo resolvidos a partir do VITE_API_URL + path relativo
- HomeView: hero split com foto/gradiente, carousel de avisos, cards de serviços
- LoginView: fluxo 2 etapas (documento na home → senha em /login)
- Roteamento completo: público (/), serviços (/servicos/*), portal autenticado (/portal/*)
- authStore + authService estruturados para Keycloak PKCE (integração pendente)
- Placeholders para todas as telas da área logada

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Bezerra 2026-05-18 00:20:07 -03:00
commit 5a7f4ba07a
48 changed files with 5937 additions and 0 deletions

4
.env.development Normal file
View File

@ -0,0 +1,4 @@
VITE_KEYCLOAK_URL=
VITE_KEYCLOAK_REALM=
VITE_KEYCLOAK_CLIENT_ID=
VITE_API_URL=

4
.env.production Normal file
View File

@ -0,0 +1,4 @@
VITE_KEYCLOAK_URL=
VITE_KEYCLOAK_REALM=
VITE_KEYCLOAK_CLIENT_ID=
VITE_API_URL=

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
* text=auto eol=lf
*.{jpg,jpeg,png,webp,svg,ico,woff,woff2,ttf,eot} binary

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal do Contribuinte</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

4322
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "portal-modumfiscal-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint --fix . --ext .vue,.js --ignore-path .gitignore"
},
"dependencies": {
"@primeuix/themes": "^2.0.3",
"axios": "^1.16.1",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"pkce-challenge": "^6.0.0",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"tailwindcss-primeui": "^0.6.1",
"vue": "^3.5.34",
"vue-router": "^5.0.7",
"zod": "^4.4.3"
},
"devDependencies": {
"@primevue/auto-import-resolver": "^4.5.5",
"@rushstack/eslint-patch": "^1.16.1",
"@tailwindcss/vite": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.5.0",
"eslint-plugin-vue": "^10.9.1",
"postcss": "^8.5.14",
"sass": "^1.99.0",
"tailwindcss": "^4.3.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.12"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

17
src/App.vue Normal file
View File

@ -0,0 +1,17 @@
<script setup>
import { onMounted } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { applyTemplate, applySurface } from '@/config/theme.config'
const prefeitura = usePrefeituraStore()
onMounted(() => {
applyTemplate(prefeitura.template ?? 'blue')
applySurface('slate')
})
</script>
<template>
<RouterView />
<Toast />
</template>

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,25 @@
/* Estilos globais de layout — sem @apply (regra do projeto) */
html,
body {
height: 100%;
margin: 0;
padding: 0;
font-family: 'DM Sans', system-ui, sans-serif;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Transições de rota */
.page-enter-active,
.page-leave-active {
transition: opacity 0.18s ease;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}

14
src/assets/main.css Normal file
View File

@ -0,0 +1,14 @@
@import "tailwindcss";
@plugin "tailwindcss-primeui";
@import "primeicons/primeicons.css";
@theme {
--font-sans: 'DM Sans', system-ui, sans-serif;
}
/* Remove setas de number input */
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,37 @@
import { prefeituraService } from '@/services/prefeituraService'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { getTenant } from '@/utils/tenant'
const API_URL = import.meta.env.VITE_API_URL ?? ''
function resolverUrl(path) {
if (!path) return null
if (path.startsWith('http')) return path
return `${API_URL}${path}`
}
export async function bootstrapPrefeitura(pinia) {
const store = usePrefeituraStore(pinia)
const dominio = getTenant()
try {
const { data } = await prefeituraService.getPrefeituraInfo(dominio)
const info = data.data
store.$patch({
codigoMunicipio: info.codigoMunicipio,
nomePrefeitura: info.nomePrefeitura,
dominio: info.dominio,
template: info.template,
pathLogo: resolverUrl(info.pathLogo),
pathBackground: resolverUrl(info.pathBackground),
})
localStorage.setItem('current_municipio', info.codigoMunicipio)
localStorage.setItem('current_dominio', info.dominio)
return { success: true }
} catch {
return { success: false }
}
}

View File

@ -0,0 +1,58 @@
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const input = ref(props.modelValue)
watch(() => props.modelValue, (v) => { input.value = v })
const apenasDigitos = computed(() => input.value.replace(/\D/g, ''))
const isCnpj = computed(() => apenasDigitos.value.length > 11)
const valorFormatado = computed(() => {
const d = apenasDigitos.value
if (d.length <= 11) {
return d
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
return d
.replace(/(\d{2})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1/$2')
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
})
const placeholder = computed(() => isCnpj.value ? '00.000.000/0000-00' : '000.000.000-00')
function onInput(e) {
const raw = e.target.value.replace(/\D/g, '').slice(0, 14)
input.value = raw
emit('update:modelValue', raw)
}
</script>
<template>
<div class="relative">
<InputText
:value="valorFormatado"
:placeholder="placeholder"
inputmode="numeric"
class="w-full text-lg tracking-wide"
size="large"
@input="onInput"
/>
<span
v-if="apenasDigitos.length > 0"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 font-medium"
>
{{ isCnpj ? 'CNPJ' : 'CPF' }}
</span>
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup>
</script>
<template>
<footer class="bg-white border-t border-slate-200 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-slate-500">
<i class="pi pi-building text-blue-600" />
<span>Portal do Contribuinte ModumFiscal</span>
</div>
<div class="flex items-center gap-4 text-xs text-slate-400">
<a href="#" class="hover:text-slate-600 transition-colors">Política de Privacidade</a>
<a href="#" class="hover:text-slate-600 transition-colors">Termos de Uso</a>
<a href="#" class="hover:text-slate-600 transition-colors">Acessibilidade</a>
</div>
</div>
</div>
</footer>
</template>

View File

@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import logoFallback from '@/assets/images/logo-modum-fiscal.png'
const auth = useAuthStore()
const prefeitura = usePrefeituraStore()
const logoSrc = computed(() => prefeitura.pathLogo || logoFallback)
</script>
<template>
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink :to="{ name: 'home' }" class="flex items-center gap-3 min-w-0">
<img
:src="logoSrc"
alt="Logo da Prefeitura"
class="h-9 w-auto object-contain flex-shrink-0"
/>
<div class="leading-tight min-w-0">
<p class="text-xs text-slate-500 font-normal truncate">Portal do Contribuinte</p>
<p class="text-sm font-semibold text-slate-800 truncate">
{{ prefeitura.nomePrefeitura || 'ModumFiscal' }}
</p>
</div>
</RouterLink>
<nav class="hidden md:flex items-center gap-4">
<RouterLink
:to="{ name: 'servicos' }"
class="text-sm text-slate-600 hover:text-primary transition-colors"
>
Serviços
</RouterLink>
</nav>
<div class="flex items-center gap-3">
<template v-if="auth.isAuthenticated">
<RouterLink :to="{ name: 'painel' }">
<Button label="Meu Painel" icon="pi pi-user" size="small" />
</RouterLink>
</template>
<template v-else>
<RouterLink :to="{ name: 'home' }">
<Button label="Entrar" icon="pi pi-sign-in" size="small" />
</RouterLink>
</template>
</div>
</div>
</header>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
defineProps({
icon: { type: String, required: true },
titulo: { type: String, required: true },
descricao: { type: String, default: '' },
to: { type: [String, Object], default: null },
requiresAuth: { type: Boolean, default: false },
})
</script>
<template>
<component
:is="to ? 'RouterLink' : 'div'"
:to="to ?? undefined"
class="group flex flex-col gap-3 bg-white rounded-xl border border-slate-200 p-5 hover:border-blue-300 hover:shadow-md transition-all duration-200 cursor-pointer"
>
<div class="flex items-start justify-between">
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center group-hover:bg-blue-100 transition-colors">
<i :class="['pi', icon, 'text-blue-700 text-lg']" />
</div>
<i v-if="requiresAuth" class="pi pi-lock text-slate-300 text-xs" title="Requer login" />
</div>
<div>
<p class="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors">{{ titulo }}</p>
<p v-if="descricao" class="text-xs text-slate-500 mt-1 leading-relaxed">{{ descricao }}</p>
</div>
<div class="flex items-center gap-1 text-xs text-blue-600 font-medium opacity-0 group-hover:opacity-100 transition-opacity">
<span>Acessar</span>
<i class="pi pi-arrow-right text-xs" />
</div>
</component>
</template>

41
src/config/apiClient.js Normal file
View File

@ -0,0 +1,41 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
const BASE_URL = `${import.meta.env.VITE_API_URL ?? ''}/api/v1`
function addTenantHeaders(config) {
const prefeitura = usePrefeituraStore()
if (prefeitura.codigoMunicipio) config.headers['X-Municipio'] = prefeitura.codigoMunicipio
if (prefeitura.dominio) config.headers['X-Dominio'] = prefeitura.dominio
return config
}
// ─── Cliente público (sem autenticação) ───────────────────────────────────────
// Usado no bootstrap de prefeitura e em serviços públicos (certidão, IPTU)
export const apiClientPublico = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClientPublico.interceptors.request.use((config) => addTenantHeaders(config))
// ─── Cliente autenticado ───────────────────────────────────────────────────────
// Usado nas rotas /portal/* que exigem login
const apiClient = axios.create({ baseURL: BASE_URL, timeout: 10000 })
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.token) config.headers.Authorization = `Bearer ${auth.token}`
return addTenantHeaders(config)
})
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore().clearSession()
window.location.href = '/'
}
return Promise.reject(error)
},
)
export default apiClient

View File

@ -0,0 +1,37 @@
import Aura from '@primeuix/themes/aura'
// A cor primária NÃO é hardcoded aqui — é aplicada dinamicamente via
// applyTemplate() no bootstrap, conforme o template de cada prefeitura.
export const primeVueConfig = {
pt: {
global: {
css: `* { font-family: 'DM Sans', system-ui, sans-serif; }`,
},
},
theme: {
preset: Aura,
options: {
darkModeSelector: '.app-dark',
},
},
locale: {
firstDayOfWeek: 1,
dayNames: ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'],
dayNamesShort: ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'],
dayNamesMin: ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'],
monthNames: ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'],
monthNamesShort: ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'],
today: 'Hoje',
clear: 'Limpar',
accept: 'Sim',
reject: 'Não',
choose: 'Escolher',
upload: 'Enviar',
cancel: 'Cancelar',
apply: 'Aplicar',
close: 'Fechar',
emptyMessage: 'Nenhum resultado encontrado',
emptySearchMessage: 'Nenhum resultado encontrado',
emptySelectionMessage: 'Nenhum item selecionado',
},
}

View File

@ -0,0 +1,80 @@
import { updatePreset, updateSurfacePalette } from '@primeuix/themes'
import { ref } from 'vue'
export const templates = ref([
{ name: 'sistema', palette: null },
{
name: 'tutoia',
palette: { 50:'#fff7ed',100:'#ffedd5',200:'#fed7aa',300:'#fdba74',400:'#fb923c',500:'#f97316',600:'#ea580c',700:'#c2410c',800:'#9a3412',900:'#7c2d12',950:'#431407' },
},
{
name: 'amber',
palette: { 50:'#fffbeb',100:'#fef3c7',200:'#fde68a',300:'#fcd34d',400:'#fbbf24',500:'#f59e0b',600:'#d97706',700:'#b45309',800:'#92400e',900:'#78350f',950:'#451a03' },
},
{
name: 'blue',
palette: { 50:'#eff6ff',100:'#dbeafe',200:'#bfdbfe',300:'#93c5fd',400:'#60a5fa',500:'#3b82f6',600:'#2563eb',700:'#1d4ed8',800:'#1e40af',900:'#1e3a8a',950:'#172554' },
},
{
name: 'indigo',
palette: { 50:'#eef2ff',100:'#e0e7ff',200:'#c7d2fe',300:'#a5b4fc',400:'#818cf8',500:'#6366f1',600:'#4f46e5',700:'#4338ca',800:'#3730a3',900:'#312e81',950:'#1e1b4b' },
},
{
name: 'violet',
palette: { 50:'#f5f3ff',100:'#ede9fe',200:'#ddd6fe',300:'#c4b5fd',400:'#a78bfa',500:'#8b5cf6',600:'#7c3aed',700:'#6d28d9',800:'#5b21b6',900:'#4c1d95',950:'#2e1065' },
},
{
name: 'emerald',
palette: { 50:'#ecfdf5',100:'#d1fae5',200:'#a7f3d0',300:'#6ee7b7',400:'#34d399',500:'#10b981',600:'#059669',700:'#047857',800:'#065f46',900:'#064e3b',950:'#022c22' },
},
{
name: 'teal',
palette: { 50:'#f0fdfa',100:'#ccfbf1',200:'#99f6e4',300:'#5eead4',400:'#2dd4bf',500:'#14b8a6',600:'#0d9488',700:'#0f766e',800:'#115e59',900:'#134e4a',950:'#042f2e' },
},
{
name: 'rose',
palette: { 50:'#fff1f2',100:'#ffe4e6',200:'#fecdd3',300:'#fda4af',400:'#fb7185',500:'#f43f5e',600:'#e11d48',700:'#be123c',800:'#9f1239',900:'#881337',950:'#4c0519' },
},
])
export const surfaces = ref([
{
name: 'slate',
palette: { 0:'#ffffff',50:'#f8fafc',100:'#f1f5f9',200:'#e2e8f0',300:'#cbd5e1',400:'#94a3b8',500:'#64748b',600:'#475569',700:'#334155',800:'#1e293b',900:'#0f172a',950:'#020617' },
},
])
export function getPresetExt(templateName) {
const t = templates.value.find((t) => t.name === templateName) ?? templates.value.find((t) => t.name === 'blue')
if (!t || t.palette === null) {
return {
semantic: {
primary: { 50:'{surface.50}',100:'{surface.100}',200:'{surface.200}',300:'{surface.300}',400:'{surface.400}',500:'{surface.500}',600:'{surface.600}',700:'{surface.700}',800:'{surface.800}',900:'{surface.900}',950:'{surface.950}' },
colorScheme: {
light: { primary: { color:'{primary.950}',contrastColor:'#ffffff',hoverColor:'{primary.800}',activeColor:'{primary.700}' } },
dark: { primary: { color:'{primary.50}', contrastColor:'{primary.950}',hoverColor:'{primary.200}',activeColor:'{primary.300}' } },
},
},
}
}
return {
semantic: {
primary: t.palette,
colorScheme: {
light: { primary: { color:'{primary.600}',contrastColor:'{surface.50}',hoverColor:'{primary.700}',activeColor:'{primary.800}' } },
dark: { primary: { color:'{primary.400}',contrastColor:'{surface.900}',hoverColor:'{primary.300}',activeColor:'{primary.200}' } },
},
},
}
}
export function applyTemplate(templateName) {
updatePreset(getPresetExt(templateName))
}
export function applySurface(surfaceName) {
const s = surfaces.value.find((s) => s.name === surfaceName)
if (s) updateSurfacePalette(s.palette)
}

View File

@ -0,0 +1,69 @@
<script setup>
import { useAuthStore } from '@/stores/authStore'
import { authService } from '@/services/authService'
const auth = useAuthStore()
const navItems = [
{ name: 'painel', label: 'Painel' },
{ name: 'debitos', label: 'Débitos' },
{ name: 'certidoes-portal', label: 'Certidões' },
{ name: 'alvaras', label: 'Alvarás' },
{ name: 'pagamentos', label: 'Pagamentos' },
{ name: 'dados', label: 'Dados Cadastrais' },
]
function sair() {
auth.clearSession()
authService.logout()
}
</script>
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<header class="bg-white border-b border-slate-200 sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<RouterLink :to="{ name: 'painel' }" class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-700 rounded-lg flex items-center justify-center">
<i class="pi pi-building text-white text-sm" />
</div>
<span class="font-semibold text-slate-800">Portal do Contribuinte</span>
</RouterLink>
<nav class="hidden md:flex items-center gap-1">
<RouterLink
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="px-3 py-2 rounded-lg text-sm text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
active-class="bg-blue-50 text-blue-700 font-medium"
>
{{ item.label }}
</RouterLink>
</nav>
<div class="flex items-center gap-3">
<span class="hidden sm:block text-sm text-slate-600">{{ auth.nomeUsuario }}</span>
<Button
label="Sair"
severity="secondary"
size="small"
icon="pi pi-sign-out"
outlined
@click="sair"
/>
</div>
</div>
</header>
<main class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<AppFooter />
</div>
</template>

View File

@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div class="min-h-screen flex flex-col bg-slate-50">
<AppHeader />
<main class="flex-1">
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<AppFooter />
</div>
</template>

34
src/main.js Normal file
View File

@ -0,0 +1,34 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import { primeVueConfig } from './config/primevue.config'
import { bootstrapPrefeitura } from './bootstrap/prefeituraBoot'
import '@/assets/main.css'
import '@/assets/layout/layout.scss'
async function startApp() {
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
await bootstrapPrefeitura(pinia)
app.use(router)
app.use(PrimeVue, primeVueConfig)
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')
}
startApp()

106
src/router/index.js Normal file
View File

@ -0,0 +1,106 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0 }),
routes: [
{
path: '/',
component: () => import('@/layouts/PublicLayout.vue'),
children: [
{
path: '',
name: 'home',
component: () => import('@/views/public/HomeView.vue'),
},
{
path: 'login',
name: 'login',
component: () => import('@/views/public/LoginView.vue'),
},
{
path: 'primeiro-acesso',
name: 'primeiro-acesso',
component: () => import('@/views/public/PrimeiroAcessoView.vue'),
},
{
path: 'credenciamento',
name: 'credenciamento',
component: () => import('@/views/public/CredenciamentoView.vue'),
},
{
path: 'servicos',
name: 'servicos',
component: () => import('@/views/servicos/ServicosHubView.vue'),
},
{
path: 'servicos/certidao',
name: 'certidao',
component: () => import('@/views/servicos/CertidaoView.vue'),
},
{
path: 'servicos/iptu',
name: 'iptu',
component: () => import('@/views/servicos/IptuView.vue'),
},
],
},
{
path: '/portal',
component: () => import('@/layouts/PortalLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: { name: 'painel' },
},
{
path: 'painel',
name: 'painel',
component: () => import('@/views/portal/PainelView.vue'),
},
{
path: 'debitos',
name: 'debitos',
component: () => import('@/views/portal/DebitosView.vue'),
},
{
path: 'certidoes',
name: 'certidoes-portal',
component: () => import('@/views/portal/CertidoesView.vue'),
},
{
path: 'alvaras',
name: 'alvaras',
component: () => import('@/views/portal/AlvarasView.vue'),
},
{
path: 'pagamentos',
name: 'pagamentos',
component: () => import('@/views/portal/PagamentosView.vue'),
},
{
path: 'dados',
name: 'dados',
component: () => import('@/views/portal/DadosView.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: { name: 'home' },
},
],
})
router.beforeEach((to) => {
if (!to.meta.requiresAuth) return true
const auth = useAuthStore()
if (!auth.isAuthenticated) {
return { name: 'home' }
}
})
export default router

View File

@ -0,0 +1,34 @@
/**
* Serviço de autenticação Keycloak PKCE
*
* Fluxo:
* 1. Home coleta o documento (CPF/CNPJ)
* 2. Redireciona para /login?doc=XXX
* 3. LoginView exibe o documento e solicita a senha
* 4. Ao submeter, este serviço inicia o PKCE e redireciona ao Keycloak
* 5. Keycloak devolve ao callback com o code
* 6. handleCallback troca o code pelo token e salva na authStore
*/
const KEYCLOAK_BASE = import.meta.env.VITE_KEYCLOAK_URL ?? 'https://sso.modumfiscal.com.br/realms/modumfiscal-dev'
const CLIENT_ID = import.meta.env.VITE_KEYCLOAK_CLIENT_ID ?? 'portal-modumfiscal'
const REDIRECT_URI = `${window.location.origin}/callback`
export const authService = {
async iniciarLogin(documento) {
// Implementar PKCE com pkce-challenge quando integrar com Keycloak
// Por ora redireciona para /login com o documento
return { documento }
},
async handleCallback(code) {
// Trocar code por token via Keycloak
// A ser implementado na fase de integração
throw new Error('Keycloak callback não implementado nesta fase')
},
logout() {
const logoutUrl = `${KEYCLOAK_BASE}/protocol/openid-connect/logout?redirect_uri=${encodeURIComponent(window.location.origin)}`
window.location.href = logoutUrl
},
}

View File

@ -0,0 +1,7 @@
import { apiClientPublico } from '@/config/apiClient'
export const prefeituraService = {
getPrefeituraInfo(dominio) {
return apiClientPublico.get(`/publico/prefeitura/${dominio}`)
},
}

31
src/stores/authStore.js Normal file
View File

@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore(
'auth',
() => {
const token = ref(null)
const userInfo = ref(null)
const isAuthenticated = computed(() => !!token.value)
const nomeUsuario = computed(() => userInfo.value?.name ?? '')
const documento = computed(() => userInfo.value?.preferred_username ?? '')
function setSession(accessToken, info) {
token.value = accessToken
userInfo.value = info
}
function clearSession() {
token.value = null
userInfo.value = null
}
return { token, userInfo, isAuthenticated, nomeUsuario, documento, setSession, clearSession }
},
{
persist: {
paths: ['token', 'userInfo'],
},
},
)

View File

@ -0,0 +1,13 @@
import { defineStore } from 'pinia'
export const usePrefeituraStore = defineStore('prefeitura', {
state: () => ({
codigoMunicipio: null,
nomePrefeitura: null,
dominio: null,
template: null,
pathLogo: null,
pathBackground: null, // URL de foto de fundo do município (quando a API suportar)
}),
persist: true,
})

26
src/utils/tenant.js Normal file
View File

@ -0,0 +1,26 @@
export function getTenant() {
const tenant = window.location.hostname.split('.')[0]
if (isValidTenant(tenant)) return tenant
const stored = localStorage.getItem('current_dominio')
if (stored && isValidTenant(stored)) return stored
return 'sistema'
}
export function setTenant(tenant) {
if (isValidTenant(tenant)) {
localStorage.setItem('current_dominio', tenant)
}
}
export function clearTenant() {
localStorage.removeItem('current_dominio')
}
function isValidTenant(tenant) {
if (!tenant || typeof tenant !== 'string') return false
const invalidos = ['0.0.0.0', 'www', 'api', 'admin', 'test', 'dev', 'development', 'staging', 'production', 'localhost']
return !invalidos.includes(tenant.toLowerCase()) && tenant.length > 1 && /^[a-zA-Z0-9-]+$/.test(tenant)
}

View File

@ -0,0 +1,12 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Alvarás</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-briefcase text-4xl mb-3 block" />
<p>Acompanhamento de alvarás e processos a ser implementado.</p>
</div>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Certidões</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-file-edit text-4xl mb-3 block" />
<p>Certidões ativas e reemissão a ser implementado.</p>
</div>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Dados Cadastrais</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-user text-4xl mb-3 block" />
<p>Visualização e atualização de dados cadastrais a ser implementado.</p>
</div>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Débitos e Guias</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-receipt text-4xl mb-3 block" />
<p>Consulta de débitos e emissão de guias a ser implementado.</p>
</div>
</div>
</template>

View File

@ -0,0 +1,12 @@
<script setup>
</script>
<template>
<div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Histórico de Pagamentos</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
<i class="pi pi-credit-card text-4xl mb-3 block" />
<p>Histórico de pagamentos com comprovantes a ser implementado.</p>
</div>
</div>
</template>

View File

@ -0,0 +1,55 @@
<script setup>
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const cards = [
{ icon: 'pi-receipt', label: 'Débitos em Aberto', valor: '—', cor: 'red' },
{ icon: 'pi-file-check', label: 'Certidões Ativas', valor: '—', cor: 'green' },
{ icon: 'pi-briefcase', label: 'Alvarás em Andamento', valor: '—', cor: 'orange' },
{ icon: 'pi-credit-card', label: 'Último Pagamento', valor: '—', cor: 'blue' },
]
const corMap = {
red: 'bg-red-50 text-red-700',
green: 'bg-green-50 text-green-700',
orange: 'bg-orange-50 text-orange-700',
blue: 'bg-blue-50 text-blue-700',
}
</script>
<template>
<div class="space-y-8">
<div>
<h1 class="text-2xl font-bold text-slate-800">
Olá, {{ auth.nomeUsuario || 'Contribuinte' }}
</h1>
<p class="text-slate-500 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div>
<!-- Cards resumo -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="card in cards"
:key="card.label"
class="bg-white rounded-xl border border-slate-200 p-5 space-y-3"
>
<div :class="['w-10 h-10 rounded-lg flex items-center justify-center', corMap[card.cor]]">
<i :class="['pi', card.icon]" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800">{{ card.valor }}</p>
<p class="text-xs text-slate-500 mt-0.5">{{ card.label }}</p>
</div>
</div>
</div>
<!-- Placeholder conteúdo -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-6 text-center">
<i class="pi pi-info-circle text-blue-500 text-2xl mb-3 block" />
<p class="text-sm text-slate-600">
Os dados do painel serão carregados quando a integração com a API estiver configurada.
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup>
</script>
<template>
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-user-plus text-green-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Credenciamento</h1>
<p class="text-slate-500 mb-8">
Wizard de cadastro do contribuinte (6 etapas) a ser implementado.
</p>
<RouterLink :to="{ name: 'home' }">
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,354 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
import DocumentoInput from '@/components/auth/DocumentoInput.vue'
import ServiceCard from '@/components/common/ServiceCard.vue'
import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
const router = useRouter()
const prefeitura = usePrefeituraStore()
const documento = ref('')
const erro = ref('')
// Hero background
// Mapa template imagem estática (Vite resolve na build).
// Para adicionar novo município: importar a foto e adicionar a chave abaixo.
const heroBgMap = {
tutoia: bgTutoia,
}
const heroBgUrl = computed(() => heroBgMap[prefeitura.template] ?? null)
const heroBgStyle = computed(() => {
const url = heroBgUrl.value
if (!url) return {}
return {
backgroundImage: `linear-gradient(to bottom right, rgba(15,23,42,0.70), rgba(15,23,42,0.50)), url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
})
const heroHasPhoto = computed(() => !!heroBgUrl.value)
// Avisos (carousel)
// Dados mockados conectar ao endpoint /api/v1/publico/avisos/{dominio} futuramente
const avisos = ref([
{
id: 1,
tipo: 'prazo',
icone: 'pi-calendar',
titulo: 'IPTU 2025 — Parcela única com desconto',
descricao: 'Pague até 31/07 e ganhe 10% de desconto. Emita seu boleto agora mesmo.',
cor: 'amber',
acao: { label: 'Emitir boleto', to: { name: 'iptu' } },
},
{
id: 2,
tipo: 'novidade',
icone: 'pi-star',
titulo: 'Novo serviço: Certidão Online Instantânea',
descricao: 'Emita certidões negativas em segundos, sem sair de casa e com validade legal.',
cor: 'green',
acao: { label: 'Emitir agora', to: { name: 'certidao' } },
},
{
id: 3,
tipo: 'info',
icone: 'pi-info-circle',
titulo: 'Atualização cadastral obrigatória',
descricao: 'Contribuintes com dados desatualizados devem regularizar até 30/08 para evitar multas.',
cor: 'blue',
acao: null,
},
])
const corAviso = {
amber: { bg: 'bg-amber-50', borda: 'border-amber-200', icone: 'text-amber-600', tag: 'bg-amber-100 text-amber-700' },
green: { bg: 'bg-emerald-50', borda: 'border-emerald-200', icone: 'text-emerald-600', tag: 'bg-emerald-100 text-emerald-700' },
blue: { bg: 'bg-blue-50', borda: 'border-blue-200', icone: 'text-blue-600', tag: 'bg-blue-100 text-blue-700' },
}
// Serviços
const servicosPublicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Certidão negativa, positiva e situação fiscal.', to: { name: 'certidao' } },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê de pagamento.', to: { name: 'iptu' } },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Verifique a autenticidade de certidões emitidas.', to: { name: 'servicos' } },
]
const servicosAutenticados = [
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: { name: 'debitos' } },
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: { name: 'certidoes-portal' } },
{ icon: 'pi-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: { name: 'alvaras' } },
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: { name: 'pagamentos' } },
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: { name: 'dados' } },
]
function continuar() {
if (documento.value.replace(/\D/g, '').length < 11) {
erro.value = 'Informe um CPF (11 dígitos) ou CNPJ (14 dígitos) válido.'
return
}
erro.value = ''
router.push({ name: 'login', query: { doc: documento.value } })
}
</script>
<template>
<div>
<!-- HERO -->
<section
class="text-white relative overflow-hidden"
:class="heroHasPhoto ? '' : 'bg-gradient-to-br from-slate-900 via-primary-900 to-slate-800'"
:style="heroBgStyle"
>
<!-- Padrão geométrico sutil (visível sem foto) -->
<div
v-if="!heroHasPhoto"
class="absolute inset-0 opacity-5 pointer-events-none"
style="background-image: radial-gradient(circle at 25px 25px, white 2px, transparent 0); background-size: 50px 50px;"
/>
<!-- Gradiente lateral esquerdo garante legibilidade dos textos
independente do conteúdo da foto de fundo -->
<div
v-if="heroHasPhoto"
class="absolute inset-0 pointer-events-none"
style="background: linear-gradient(to right, rgba(15,23,42,0.88) 0%, rgba(15,23,42,0.65) 55%, rgba(15,23,42,0.15) 100%);"
/>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-14 lg:py-20">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<!-- Esquerda -->
<div>
<!-- Identidade do município -->
<div class="flex items-center gap-3 mb-6">
<img
v-if="prefeitura.pathLogo"
:src="prefeitura.pathLogo"
alt="Logo"
class="h-12 w-auto object-contain drop-shadow-md"
/>
<div
v-else
class="w-12 h-12 bg-white/15 backdrop-blur-sm rounded-xl flex items-center justify-center"
>
<i class="pi pi-building text-white text-xl" />
</div>
<div>
<p class="text-white/60 text-xs uppercase tracking-widest font-medium">Portal do Contribuinte</p>
<p class="text-white font-bold text-lg leading-tight">
{{ prefeitura.nomePrefeitura || 'Prefeitura Municipal' }}
</p>
</div>
</div>
<h1 class="text-3xl lg:text-[2.6rem] font-bold leading-tight mb-4 tracking-tight">
Serviços municipais<br />
<span class="text-white/80">na palma da mão</span>
</h1>
<p class="text-white/65 text-base mb-8 leading-relaxed max-w-md">
Emita certidões, consulte débitos e acesse todos os serviços
da prefeitura sem precisar sair de casa.
</p>
<!-- Lista de serviços públicos -->
<ul class="space-y-3 mb-8">
<li
v-for="s in servicosPublicos"
:key="s.titulo"
class="flex items-center gap-3 group cursor-pointer"
@click="router.push(s.to)"
>
<div class="w-9 h-9 bg-white/10 rounded-lg flex items-center justify-center flex-shrink-0 group-hover:bg-white/20 transition-colors">
<i :class="['pi', s.icon, 'text-white text-sm']" />
</div>
<div>
<p class="text-sm font-semibold text-white group-hover:text-white/80 transition-colors">{{ s.titulo }}</p>
<p class="text-xs text-white/50">{{ s.descricao }}</p>
</div>
<i class="pi pi-chevron-right text-white/30 text-xs ml-auto opacity-0 group-hover:opacity-100 transition-opacity" />
</li>
</ul>
<RouterLink
:to="{ name: 'servicos' }"
class="inline-flex items-center gap-2 text-sm text-white/60 hover:text-white transition-colors font-medium"
>
Ver todos os serviços disponíveis
<i class="pi pi-arrow-right text-xs" />
</RouterLink>
</div>
<!-- Direita Card de acesso -->
<div class="flex justify-center lg:justify-end">
<div class="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm backdrop-blur-sm">
<!-- Cabeçalho do card -->
<div class="flex items-center gap-3 mb-7">
<div class="w-11 h-11 bg-primary rounded-xl flex items-center justify-center shadow-sm">
<i class="pi pi-lock-open text-white text-lg" />
</div>
<div>
<p class="font-bold text-slate-800 text-base">Área do Contribuinte</p>
<p class="text-xs text-slate-500">Acesso seguro ao portal</p>
</div>
</div>
<!-- Formulário -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">
CPF ou CNPJ
</label>
<DocumentoInput
v-model="documento"
@keyup.enter="continuar"
/>
<p v-if="erro" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
<i class="pi pi-exclamation-circle text-xs" />
{{ erro }}
</p>
</div>
<Button
label="Continuar"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
@click="continuar"
/>
</div>
<Divider>
<span class="text-xs text-slate-400 font-normal">Primeiro acesso?</span>
</Divider>
<div class="space-y-2.5 text-center">
<RouterLink
:to="{ name: 'primeiro-acesso' }"
class="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border border-slate-200 text-sm text-slate-700 hover:bg-slate-50 hover:border-slate-300 transition-colors font-medium"
>
<i class="pi pi-key text-slate-500 text-sm" />
Criar minha senha
</RouterLink>
<RouterLink
:to="{ name: 'credenciamento' }"
class="block text-xs text-slate-400 hover:text-slate-600 transition-colors"
>
Ainda não cadastrado? <span class="text-primary font-semibold">Credenciar-se</span>
</RouterLink>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- CAROUSEL DE AVISOS -->
<section class="bg-white border-b border-slate-100">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Carousel
:value="avisos"
:num-visible="1"
:num-scroll="1"
:autoplay-interval="6000"
circular
class="aviso-carousel"
>
<template #item="{ data: aviso }">
<div
:class="[
'mx-2 rounded-xl border p-4 flex items-start gap-4',
corAviso[aviso.cor].bg,
corAviso[aviso.cor].borda,
]"
>
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm',
]"
>
<i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-semibold text-slate-800 text-sm">{{ aviso.titulo }}</p>
<p class="text-xs text-slate-600 mt-0.5 leading-relaxed">{{ aviso.descricao }}</p>
</div>
<RouterLink
v-if="aviso.acao"
:to="aviso.acao.to"
class="flex-shrink-0"
>
<Button
:label="aviso.acao.label"
size="small"
outlined
class="whitespace-nowrap"
/>
</RouterLink>
</div>
</div>
</div>
</template>
</Carousel>
</div>
</section>
<!-- SERVIÇOS AUTENTICADOS -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-bold text-slate-800">Área logada</h2>
<p class="text-sm text-slate-500 mt-0.5">Serviços disponíveis após login</p>
</div>
<span class="hidden sm:flex items-center gap-1.5 bg-primary/8 text-primary text-xs font-semibold px-3 py-1.5 rounded-full border border-primary/15">
<i class="pi pi-lock text-xs" />
Requer login
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
<ServiceCard
v-for="s in servicosAutenticados"
:key="s.titulo"
v-bind="s"
:require-auth="true"
/>
</div>
<!-- CTA credenciamento -->
<div class="mt-10 bg-gradient-to-r from-primary/5 to-primary/10 border border-primary/15 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-5">
<div>
<p class="font-bold text-slate-800 text-base">Ainda não tem acesso ao portal?</p>
<p class="text-sm text-slate-500 mt-1 max-w-md">
Solicite seu credenciamento e passe a gerenciar todos os seus tributos municipais de forma online.
</p>
</div>
<RouterLink :to="{ name: 'credenciamento' }">
<Button label="Solicitar Credenciamento" icon="pi pi-user-plus" class="whitespace-nowrap" />
</RouterLink>
</div>
</section>
</div>
</template>
<style scoped>
/* Remove as setas de navegação padrão do Carousel no contexto dos avisos */
.aviso-carousel :deep(.p-carousel-prev),
.aviso-carousel :deep(.p-carousel-next) {
width: 1.75rem;
height: 1.75rem;
}
</style>

View File

@ -0,0 +1,160 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const senha = ref('')
const carregando = ref(false)
const erro = ref('')
const docBruto = computed(() => (route.query.doc ?? '').replace(/\D/g, ''))
const docFormatado = computed(() => {
const d = docBruto.value
if (d.length <= 11) {
return d
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d{1,2})$/, '$1-$2')
}
return d
.replace(/(\d{2})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1.$2')
.replace(/(\d{3})(\d)/, '$1/$2')
.replace(/(\d{4})(\d{1,2})$/, '$1-$2')
})
const tipoDoc = computed(() => docBruto.value.length > 11 ? 'CNPJ' : 'CPF')
function trocarDocumento() {
router.push({ name: 'home' })
}
async function entrar() {
if (!senha.value) {
erro.value = 'Informe a senha.'
return
}
erro.value = ''
carregando.value = true
// Integração com Keycloak PKCE a ser implementado
setTimeout(() => {
carregando.value = false
erro.value = 'Integração com Keycloak ainda não configurada nesta fase.'
}, 800)
}
</script>
<template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<!-- Card de login -->
<div class="bg-white rounded-2xl shadow-lg border border-slate-200 overflow-hidden">
<!-- Cabeçalho azul -->
<div class="bg-gradient-to-r from-blue-700 to-blue-800 px-8 py-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-white/20 rounded-xl flex items-center justify-center">
<i class="pi pi-lock text-white text-lg" />
</div>
<div>
<p class="text-white font-semibold">Acesso seguro</p>
<p class="text-blue-200 text-xs">Portal do Contribuinte</p>
</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-8 py-8 space-y-6">
<!-- Documento identificado -->
<div>
<p class="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">
Entrando como
</p>
<div class="flex items-center justify-between bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="pi pi-id-card text-blue-700 text-sm" />
</div>
<div>
<p class="font-mono font-medium text-slate-800 text-sm">{{ docFormatado }}</p>
<p class="text-xs text-slate-500">{{ tipoDoc }}</p>
</div>
</div>
<button
class="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1 transition-colors"
@click="trocarDocumento"
>
<i class="pi pi-pencil text-xs" />
Trocar
</button>
</div>
</div>
<!-- Senha -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">
Senha
</label>
<Password
v-model="senha"
:feedback="false"
toggle-mask
placeholder="Digite sua senha"
class="w-full"
input-class="w-full"
size="large"
@keyup.enter="entrar"
/>
<p v-if="erro" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
<i class="pi pi-exclamation-circle" />
{{ erro }}
</p>
</div>
<!-- Botão entrar -->
<Button
label="Entrar"
icon="pi pi-sign-in"
class="w-full"
size="large"
:loading="carregando"
@click="entrar"
/>
<!-- Links secundários -->
<div class="text-center space-y-2">
<RouterLink
:to="{ name: 'primeiro-acesso' }"
class="block text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
Esqueci minha senha
</RouterLink>
<RouterLink
:to="{ name: 'credenciamento' }"
class="block text-xs text-slate-500 hover:text-slate-700 transition-colors"
>
Ainda não tem acesso? Credenciar-se
</RouterLink>
</div>
</div>
</div>
<!-- Voltar -->
<div class="text-center mt-6">
<button
class="text-sm text-slate-500 hover:text-slate-700 transition-colors flex items-center gap-1.5 mx-auto"
@click="router.push({ name: 'home' })"
>
<i class="pi pi-arrow-left text-xs" />
Voltar à página inicial
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup>
</script>
<template>
<div class="max-w-2xl mx-auto px-4 py-16 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-key text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Primeiro Acesso / Esqueci minha senha</h1>
<p class="text-slate-500 mb-8">
Fluxo de criação e recuperação de senha a ser implementado.
</p>
<RouterLink :to="{ name: 'home' }">
<Button label="Voltar ao início" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup>
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-file-check text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">Emissão de Certidão</h1>
<p class="text-slate-500 mb-8">
Consulta de situação fiscal e emissão de certidão em PDF a ser implementado.
</p>
<RouterLink :to="{ name: 'servicos' }">
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,17 @@
<script setup>
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-12 text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<i class="pi pi-home text-blue-700 text-2xl" />
</div>
<h1 class="text-2xl font-bold text-slate-800 mb-3">IPTU Débitos e Carnê</h1>
<p class="text-slate-500 mb-8">
Consulta por inscrição imobiliária ou CPF/CNPJ e impressão do carnê a ser implementado.
</p>
<RouterLink :to="{ name: 'servicos' }">
<Button label="Voltar aos serviços" severity="secondary" icon="pi pi-arrow-left" outlined />
</RouterLink>
</div>
</template>

View File

@ -0,0 +1,23 @@
<script setup>
import ServiceCard from '@/components/common/ServiceCard.vue'
const servicos = [
{ icon: 'pi-file-check', titulo: 'Emissão de Certidão', descricao: 'Emita certidões de situação fiscal.', to: { name: 'certidao' } },
{ icon: 'pi-home', titulo: 'IPTU — Débitos e Carnê', descricao: 'Consulte débitos e imprima o carnê.', to: { name: 'iptu' } },
{ icon: 'pi-verified', titulo: 'Validar Documento', descricao: 'Valide certidões e documentos emitidos.' },
{ icon: 'pi-map-marker', titulo: 'Consulta Cadastral', descricao: 'Consulte dados cadastrais do imóvel ou empresa.' },
{ icon: 'pi-building', titulo: 'Alvará — Consulta Pública', descricao: 'Situação de alvarás pelo número do processo.' },
]
</script>
<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-800">Todos os Serviços</h1>
<p class="text-slate-500 mt-1">Serviços disponíveis sem necessidade de login.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<ServiceCard v-for="s in servicos" :key="s.titulo" v-bind="s" />
</div>
</div>
</template>

21
vite.config.js Normal file
View File

@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import Components from 'unplugin-vue-components/vite'
import { PrimeVueResolver } from '@primevue/auto-import-resolver'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
Components({
resolvers: [PrimeVueResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})