Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions front-admin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Design/protótipo das telas: [claude.ai/artifact/3B7ZWqjBQPDFZ5vhGgNs4N](https:/

Kit de UI do tema Sertão (componentes `Ph*`, tokens, acessibilidade): [`docs/UI-KIT.md`](docs/UI-KIT.md). Use-o antes de criar markup próprio.

Layout do painel (menu, barra superior, papéis, guarda de rota, erro global): [`docs/LAYOUT.md`](docs/LAYOUT.md).

Full layer rules live in [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — read that first, it's the actual convention document, not a summary. `.claude/rules/` covers what's specific to this being a template (which module is the reference, why `HttpClient` is shaped the way it is, the two different reasons something is "shared").

`app/modules/login/` is the reference module — a complete, working example of every layer, with tests for each. Imitate its shape when creating a new module.
Expand Down Expand Up @@ -85,12 +87,11 @@ Tokens oficiais em `../docs/design/tema-sertao-tokens.png`, aplicados em `app/as
- **Tipos gerados do contrato**: `npm run api:gen` lê `http://localhost:8000/docs/api.json` (backend rodando) e escreve `app/infrastructure/http/api-types.d.ts`, commitado — mudar o contrato da API sem rodar `api:gen` de novo quebra o typecheck do front. Ver `app/application/shared/services/pingService.ts` como referência de serviço tipado a partir de `operations['<nome>']`.
- **401 em requisição autenticada** chama o handler registrado por `httpClient.setUnauthorizedHandler()` — hoje só `app/plugins/auth.client.ts` registra (limpa `authStore`, navega pro `/login`). Um 401 sem token (ex.: login errado) não dispara isso — `HttpClient` só decide que a sessão expirou quando *tinha* token.
- **Token Bearer, não cookie httpOnly** — decisão registrada em `docs/adr/0005-token-bearer-no-client.md` (o app mobile também consome a mesma API com o mesmo token).
- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa — hoje só em memória, sem API por trás; liga no módulo Community quando ele existir.
- `useEdition()` (`app/application/shared/composables/useEdition.ts`) guarda a edição ativa (store `edition`), a lista de edições da pessoa e o papel dela (`load`, `select`, `role`). Consome `GET /editions` (ainda não existe no backend; sai do OpenAPI/`api:gen` quando o módulo Community expuser). Ver `docs/LAYOUT.md`.

## Roadmap (not built yet)

- No generator/skill scaffolding a new module — creating one is by hand, following `login`'s shape and `docs/ARCHITECTURE.md`.
- `register` (the natural second consumer of `useAuthSubmit`, alongside `login`) isn't built.
- No navigation chrome (sidebar/navbar) yet.
- `backend`'s Sanctum-shaped `/auth/login`/`/auth/logout` endpoints don't exist yet — needed for this app to talk to a real backend instead of just having the client-side shape ready.
- `useEdition()` não tem fonte de dados real (ver acima).
- `GET /editions` não existe no backend: sem ele o menu mostra só a Visão geral (ver `docs/LAYOUT.md`).
2 changes: 2 additions & 0 deletions front-admin/app/application/locales/pt-br/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import login from '../../../modules/login/locales/pt-br'
import ui from './ui'
import shell from '../../../modules/shell/locales/pt-br'

// Every module's locale is registered here — see docs/ARCHITECTURE.md,
// "Registro obrigatório". Add one import + one key per new module.
export default {
login,
shell,
ui,
}
41 changes: 41 additions & 0 deletions front-admin/app/application/shared/components/layout/AppTopbar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { Bell, Menu, Search } from '@lucide/vue'
import { useId } from 'vue'
import { useEdition } from '@/application/shared/composables/useEdition'
import locale from '@/modules/shell/locales/pt-br'

const emit = defineEmits<{ 'open-menu': [] }>()

const { current } = useEdition()
const searchId = `ph-${useId()}-search`
</script>

<template>
<header class="flex flex-wrap items-center gap-3 border-b border-base-300 bg-(--ph-bg) px-4 py-3.5 lg:gap-4 lg:px-10">
<button type="button" class="ph-topbtn lg:hidden" :aria-label="locale.openMenu" @click="emit('open-menu')">
<Menu class="size-5" :stroke-width="1.75" aria-hidden="true" />
</button>

<EditionSwitcher />

<form role="search" class="relative order-last w-full flex-[1_1_320px] lg:order-none lg:max-w-[520px]" @submit.prevent>
<Search class="pointer-events-none absolute left-3.5 top-3 size-5 text-(--ph-ink-3)" :stroke-width="1.75" aria-hidden="true" />
<label :for="searchId" class="sr-only">{{ locale.search }}</label>
<input :id="searchId" type="search" class="input w-full pl-10" :placeholder="locale.searchPlaceholder">
</form>

<div class="ml-auto flex items-center gap-3">
<a
v-if="current?.siteUrl"
:href="current.siteUrl"
class="ph-topbtn hidden px-4 sm:inline-flex"
target="_blank"
rel="noopener noreferrer"
>{{ locale.viewSite }}</a>
<button type="button" class="ph-topbtn" :aria-label="locale.notifications">
<Bell class="size-5" :stroke-width="1.75" aria-hidden="true" />
</button>
<UserMenu />
</div>
</header>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { Check, ChevronDown } from '@lucide/vue'
import { useEdition } from '@/application/shared/composables/useEdition'
import locale from '@/modules/shell/locales/pt-br'

const { editions, current, status, select, load } = useEdition()

async function choose(id: number, close: () => void) {
close()
await select(id)
}
</script>

<template>
<MenuPopover :label="locale.switchEdition" align="start">
<template #trigger="{ open }">
<span class="flex size-7 items-center justify-center rounded-lg bg-(--ph-brand) text-xs font-semibold text-(--ph-on-brand)" aria-hidden="true">{{ current?.yearBadge ?? '–' }}</span>
<span class="flex flex-col text-left leading-tight">
<span class="text-sm font-semibold">{{ current?.name ?? locale.noEdition }}</span>
<span class="text-xs font-normal text-(--ph-ink-3)">{{ current ? `${current.city} · ${current.state}` : '' }}</span>
</span>
<ChevronDown class="size-4 transition-transform" :class="{ 'rotate-180': open }" aria-hidden="true" />
</template>
<template #default="{ close }">
<p v-if="status === 'error'" role="alert" class="p-2 text-sm">
{{ locale.editionsError }}
<button type="button" class="link" @click="load()">{{ locale.retry }}</button>
</p>
<p v-else-if="editions.length === 0" class="p-2 text-sm text-(--ph-ink-2)">{{ locale.noEditionHint }}</p>
<ul v-else :aria-label="locale.editionList" class="flex flex-col gap-0.5">
<li v-for="edition in editions" :key="edition.id">
<button
type="button"
class="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-sm hover:bg-base-200"
:aria-current="edition.id === current?.id ? 'true' : undefined"
@click="choose(edition.id, close)"
>
<span class="flex flex-col leading-tight">
<span class="font-medium">{{ edition.name }}</span>
<span class="text-xs text-(--ph-ink-3)">{{ edition.city }} · {{ edition.state }}</span>
</span>
<Check v-if="edition.id === current?.id" class="ml-auto size-4" aria-hidden="true" />
</button>
</li>
</ul>
</template>
</MenuPopover>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, useId, watch } from 'vue'

defineProps<{ label: string; align?: 'start' | 'end' }>()

const open = ref(false)
const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLButtonElement | null>(null)
const panelId = `ph-${useId()}-panel`

function close(returnFocus = true) {
open.value = false
if (returnFocus) trigger.value?.focus()
}

function onDocumentClick(event: MouseEvent) {
if (root.value && !root.value.contains(event.target as Node)) close(false)
}

function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open.value) {
event.stopPropagation()
close()
}
}

watch(open, (value) => {
if (value) document.addEventListener('click', onDocumentClick)
else document.removeEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))

defineExpose({ close })
</script>

<template>
<div ref="root" class="relative" @keydown="onKeydown">
<button
ref="trigger"
type="button"
class="ph-topbtn"
:aria-label="label"
:aria-expanded="open"
:aria-controls="panelId"
aria-haspopup="true"
@click="open = !open"
>
<slot name="trigger" :open="open" />
</button>
<div
v-show="open"
:id="panelId"
class="absolute top-full z-40 mt-2 min-w-64 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
:class="align === 'end' ? 'right-0' : 'left-0'"
>
<slot :close="close" />
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { describe, expect, it } from 'vitest'
import MobileMenu from './MobileMenu.vue'

describe('MobileMenu', () => {
it('abre e fecha com v-model e tem botão de fechar nomeado', async () => {
const wrapper = await mountSuspended(MobileMenu, { props: { modelValue: false } })
const dialog = wrapper.find('dialog')
expect((dialog.element as HTMLDialogElement).open).toBe(false)
await wrapper.setProps({ modelValue: true })
expect((dialog.element as HTMLDialogElement).open).toBe(true)
await wrapper.find('button[aria-label="Fechar menu"]').trigger('click')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
})

it('só aparece em telas estreitas', async () => {
const wrapper = await mountSuspended(MobileMenu, { props: { modelValue: false } })
expect(wrapper.find('dialog').classes()).toContain('lg:hidden')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue'
import { X } from '@lucide/vue'
import { useDialog } from '@/application/shared/composables/useDialog'
import locale from '@/modules/shell/locales/pt-br'

const open = defineModel<boolean>({ default: false })
const dialog = ref<HTMLDialogElement | null>(null)
useDialog(dialog, open)
</script>

<template>
<dialog
ref="dialog"
class="modal modal-start lg:hidden"
:aria-label="locale.mainNav"
@close="open = false"
@click.self="open = false"
>
<div class="modal-box relative h-full max-h-none w-[min(18rem,85vw)] max-w-none rounded-none p-0" style="background: var(--ph-ink)">
<button
type="button"
class="btn btn-ghost btn-square absolute right-2 top-2 z-10 text-(--ph-side-text)"
:aria-label="locale.closeMenu"
@click="open = false"
>
<X class="size-5" aria-hidden="true" />
</button>
<SideNav @navigate="open = false" />
</div>
</dialog>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import editionService from '@/modules/shell/services/editionService'
import { useEdition } from '@/application/shared/composables/useEdition'
import SideNav from './SideNav.vue'

const { path } = vi.hoisted(() => ({ path: { value: '/' } }))
mockNuxtImport('useRoute', () => () => ({ path: path.value }))

async function asRole(role: string) {
vi.spyOn(editionService, 'list').mockResolvedValue({
data: [{ id: 1, slug: 'ed-1', name: 'PHPeste 2027', city: 'João Pessoa', state: 'PB', year: 2027, site_url: null, role }],
})
await useEdition().load()
}

beforeEach(() => {
vi.restoreAllMocks()
path.value = '/'
useEdition().reset()
})

describe('SideNav', () => {
it('organizador vê as 5 seções e os 17 itens, num <nav> nomeado', async () => {
await asRole('organizador')
const wrapper = await mountSuspended(SideNav)
expect(wrapper.find('nav').attributes('aria-label')).toBe('Principal')
expect(wrapper.findAll('nav h2').map((h) => h.text())).toEqual(['Edição', 'Conteúdo', 'Vendas', 'Organização', 'Sistema'])
expect(wrapper.findAll('nav a')).toHaveLength(17)
})

it('esconde o que o papel não permite (e seções vazias)', async () => {
await asRole('financeiro')
const wrapper = await mountSuspended(SideNav)
const labels = wrapper.findAll('nav a').map((a) => a.text())
expect(labels).toEqual(['Visão geral', 'Ingressos', 'Pedidos', 'Pagamentos', 'Finanças'])
expect(wrapper.text()).not.toContain('Configurar CFP')
expect(wrapper.findAll('nav h2').map((h) => h.text())).toEqual(['Edição', 'Vendas', 'Organização'])
})

it('marca só o item ativo com aria-current, o mais específico', async () => {
await asRole('organizador')
path.value = '/cfp/avaliacao'
const wrapper = await mountSuspended(SideNav)
const current = wrapper.findAll('[aria-current="page"]')
expect(current).toHaveLength(1)
expect(current[0]!.text()).toBe('Avaliar propostas')
})

it('mostra a edição ativa no rodapé', async () => {
await asRole('organizador')
const wrapper = await mountSuspended(SideNav)
expect(wrapper.text()).toContain('PHPeste 2027 · João Pessoa')
})

it('avisa a gaveta para fechar ao navegar', async () => {
await asRole('organizador')
const wrapper = await mountSuspended(SideNav)
await wrapper.find('nav a').trigger('click')
expect(wrapper.emitted('navigate')).toHaveLength(1)
})
})
74 changes: 74 additions & 0 deletions front-admin/app/application/shared/components/layout/SideNav.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAV_GROUPS, type NavItem } from '@/application/shared/layout/navigation'
import { usePermissions } from '@/application/shared/composables/usePermissions'
import { useEdition } from '@/application/shared/composables/useEdition'
import locale from '@/modules/shell/locales/pt-br'

const emit = defineEmits<{ navigate: [] }>()

const route = useRoute()
const { visibleItems } = usePermissions()
const { current } = useEdition()

const groups = computed(() =>
NAV_GROUPS.map((group) => ({ group, items: visibleItems.value.filter((i) => i.group === group) })).filter((g) => g.items.length > 0),
)

// O item mais específico vence: /cfp/avaliacao não acende "Configurar CFP".
function isActive(item: NavItem): boolean {
const path = route.path.replace(/\/+$/, '') || '/'
if (item.to === '/') return path === '/'
if (!(path === item.to || path.startsWith(`${item.to}/`))) return false
return !visibleItems.value.some((o) => o.to.length > item.to.length && (path === o.to || path.startsWith(`${o.to}/`)))
}
</script>

<template>
<div class="ph-side flex min-h-full w-full flex-col">
<div class="flex items-center gap-3 px-5 pb-2 pt-6">
<div class="flex size-10 items-center justify-center rounded-[10px] bg-(--ph-accent) text-[15px] font-semibold text-(--ph-ink)" aria-hidden="true">PH</div>
<div class="flex flex-col leading-tight">
<span class="text-[17px] font-semibold text-(--ph-on-brand)">{{ locale.brandName }}</span>
<span class="text-xs text-(--ph-side-muted)">{{ locale.brandSubtitle }}</span>
</div>
</div>

<nav :aria-label="locale.mainNav" class="flex-1 px-3">
<template v-for="g in groups" :key="g.group">
<h2 class="mx-3.5 mb-1.5 mt-5 text-[11px] font-medium uppercase tracking-[.09em] text-(--ph-side-muted)">{{ locale.groups[g.group] }}</h2>
<ul class="flex flex-col gap-0.5">
<li v-for="item in g.items" :key="item.key">
<NuxtLink
:to="item.to"
class="ph-nav"
:class="{ 'is-active': isActive(item) }"
:aria-current="isActive(item) ? 'page' : undefined"
@click="emit('navigate')"
>
<component :is="item.icon" class="size-5 shrink-0" :stroke-width="1.75" aria-hidden="true" />
{{ locale.nav[item.key] }}
</NuxtLink>
</li>
</ul>
</template>
</nav>

<p class="px-5 pb-4 pt-5 text-xs text-(--ph-side-muted)">
{{ locale.activeEdition }}<br>
<span class="font-medium text-(--ph-side-text)">{{ current ? `${current.name} · ${current.city}` : locale.noEdition }}</span>
</p>
<PhStripe />
</div>
</template>

<style scoped>
.ph-side { background: var(--ph-ink); color: var(--ph-side-text); }
.ph-nav {
display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 14px;
border-radius: 10px; color: var(--ph-side-text); text-decoration: none; font-size: 14px; font-weight: 500;
}
.ph-nav:hover { background: color-mix(in srgb, var(--ph-side-text) 10%, transparent); color: var(--ph-on-brand); }
.ph-nav.is-active { background: var(--ph-accent); color: var(--ph-ink); }
.ph-nav:focus-visible { outline-color: var(--ph-accent); }
</style>
Loading
Loading