WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

View File

@@ -0,0 +1,439 @@
<script setup lang="ts">
/**
* Global command palette — searches customers, policies, claims, and app pages.
* Keyboard: Ctrl/Cmd+K to focus, Escape to close, ↑↓ to navigate, Enter to go.
*/
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
const router = useRouter()
const open = ref(false)
const q = ref('')
const inputRef = ref<HTMLElement | null>(null)
const activeIndex = ref(-1)
/* ── Build searchable records from mock data ── */
type SearchHit = {
id: string
kind: 'customer' | 'policy' | 'claim' | 'page'
icon: string
title: string
meta: string
detail?: string
to: string
}
const allRecords = computed<SearchHit[]>(() => {
const hits: SearchHit[] = []
for (const c of MOCK_CUSTOMERS) {
// Customer record
hits.push({
id: `cust-${c.id}`,
kind: 'customer',
icon: 'i-heroicons-user',
title: c.name,
meta: `${c.type} · ${c.documentId}`,
detail: `${c.policies.length} policies · ${fmtMoney(c.policies.reduce((s, p) => s + p.premium, 0))}/yr · Agent: ${c.agent}`,
to: `/customers/${c.id}`
})
// Each policy
for (const p of c.policies) {
hits.push({
id: `pol-${p.id}`,
kind: 'policy',
icon: p.icon,
title: p.id,
meta: `${p.line} · ${p.carrier} · ${c.name}`,
detail: p.product,
to: `/customers/${c.id}`
})
}
// Each claim
for (const cl of c.claims) {
hits.push({
id: `claim-${cl.id}`,
kind: 'claim',
icon: 'i-heroicons-shield-exclamation',
title: cl.id,
meta: `${cl.type} · ${cl.status} · ${c.name}`,
detail: `Policy ${cl.policy} · $${cl.amount.toLocaleString()}`,
to: `/customers/${c.id}`
})
}
}
return hits
})
/* ── App pages / destinations ── */
const APP_PAGES: SearchHit[] = [
{ id: 'p-home', kind: 'page', icon: 'i-heroicons-squares-2x2', title: 'My Dashboard', meta: 'Home', to: '/' },
{ id: 'p-calendar', kind: 'page', icon: 'i-heroicons-calendar-days', title: 'Calendar', meta: 'Agenda & reminders', to: '/calendar' },
// Quotes
{ id: 'p-quotes', kind: 'page', icon: 'i-heroicons-calculator', title: 'Quotes Overview', meta: 'Quotes', to: '/quotes' },
{ id: 'p-quotes-auto', kind: 'page', icon: 'i-heroicons-truck', title: 'Auto Quotes', meta: 'Quotes · Motor & fleet', to: '/quotes/auto' },
{ id: 'p-quotes-health', kind: 'page', icon: 'i-heroicons-heart', title: 'Health Quotes', meta: 'Quotes · Collective & individual', to: '/quotes/health' },
{ id: 'p-quotes-life', kind: 'page', icon: 'i-heroicons-shield-check', title: 'Life Quotes', meta: 'Quotes · Individual & corporate', to: '/quotes/life' },
{ id: 'p-quotes-risk', kind: 'page', icon: 'i-heroicons-building-office-2', title: 'General Risk', meta: 'Quotes · Liability & specialty', to: '/quotes/general-risk' },
// Sales
{ id: 'p-ql', kind: 'page', icon: 'i-heroicons-bolt', title: 'Quick Lead', meta: 'Sales · Fast lead capture', to: '/sales/quick-lead' },
{ id: 'p-pipeline', kind: 'page', icon: 'i-heroicons-funnel', title: 'Sales Pipeline', meta: 'Sales · Kanban board', to: '/onboarding' },
{ id: 'p-solicitud', kind: 'page', icon: 'i-heroicons-document-text', title: 'New Solicitud', meta: 'Sales · Onboarding intake', to: '/onboarding/solicitud' },
{ id: 'p-emissions', kind: 'page', icon: 'i-heroicons-paper-airplane', title: 'Emissions Review', meta: 'Sales · QA & carrier submit', to: '/onboarding/emissions' },
{ id: 'p-nombra', kind: 'page', icon: 'i-heroicons-document-arrow-up', title: 'Nombramiento', meta: 'Sales · Broker-of-record transfer', to: '/onboarding/policy-upload/new' },
// Operations
{ id: 'p-customers', kind: 'page', icon: 'i-heroicons-users', title: 'Customers', meta: 'Operations · CRM', to: '/customers' },
{ id: 'p-new-customer', kind: 'page', icon: 'i-heroicons-user-plus', title: 'New Customer', meta: 'Operations · Registration', to: '/customers/new' },
{ id: 'p-policies', kind: 'page', icon: 'i-heroicons-briefcase', title: 'Policies', meta: 'Operations · Book of business', to: '/policies' },
// Workstations
{ id: 'p-collectivos', kind: 'page', icon: 'i-heroicons-user-group', title: 'Collectivos', meta: 'Workstations · Group management', to: '/workstation/collectivos' },
{ id: 'p-collections', kind: 'page', icon: 'i-heroicons-banknotes', title: 'Collections', meta: 'Workstations', to: '/workstation/collections' },
{ id: 'p-claims-ws', kind: 'page', icon: 'i-heroicons-shield-exclamation', title: 'Claims', meta: 'Workstations', to: '/workstation/claims' },
{ id: 'p-renewals', kind: 'page', icon: 'i-heroicons-arrow-path', title: 'Renewals', meta: 'Workstations', to: '/workstation/renewals' },
{ id: 'p-cs', kind: 'page', icon: 'i-heroicons-chat-bubble-left-right', title: 'Customer Service', meta: 'Workstations', to: '/workstation/customer-service' },
{ id: 'p-sales-factory', kind: 'page', icon: 'i-heroicons-rocket-launch', title: 'Sales Factory', meta: 'AI Tools · Lead gen & cross-sell', to: '/ai-tools/sales-factory' },
{ id: 'p-facturacion', kind: 'page', icon: 'i-heroicons-document-text', title: 'Facturación', meta: 'Workstations · Invoicing', to: '/workstation/facturacion' },
// AI Tools
{ id: 'p-comparator', kind: 'page', icon: 'i-heroicons-scale', title: 'Policy Comparator', meta: 'AI Tools', to: '/ai-tools/policy-comparator' },
{ id: 'p-email', kind: 'page', icon: 'i-heroicons-envelope', title: 'Email Writer', meta: 'AI Tools', to: '/ai-tools/email-writer' },
{ id: 'p-case', kind: 'page', icon: 'i-heroicons-light-bulb', title: 'Case Assistant', meta: 'AI Tools', to: '/ai-tools/case-assistant' },
// Reports
{ id: 'p-production', kind: 'page', icon: 'i-heroicons-chart-bar', title: 'Production Report', meta: 'Reports & Analysis', to: '/analysis/production' },
{ id: 'p-commissions', kind: 'page', icon: 'i-heroicons-currency-dollar', title: 'Commissions', meta: 'Reports & Analysis', to: '/analysis/commissions' },
{ id: 'p-claims-report', kind: 'page', icon: 'i-heroicons-chart-pie', title: 'Claims Report', meta: 'Reports & Analysis', to: '/analysis/claims' },
// Settings
{ id: 'p-account', kind: 'page', icon: 'i-heroicons-user-circle', title: 'My Account', meta: 'Settings · Profile & theme', to: '/account' },
{ id: 'p-settings', kind: 'page', icon: 'i-heroicons-cog-6-tooth', title: 'Software Settings', meta: 'Settings', to: '/settings' },
{ id: 'p-agents', kind: 'page', icon: 'i-heroicons-user-group', title: 'Agents & Commissions', meta: 'Settings · Producer management', to: '/settings/agents' },
{ id: 'p-org', kind: 'page', icon: 'i-heroicons-building-office', title: 'Organization', meta: 'Settings · Company & logo', to: '/settings/organization' },
{ id: 'p-forms', kind: 'page', icon: 'i-heroicons-clipboard-document-list', title: 'Forms Library', meta: 'Settings · Insurer forms', to: '/settings/forms' },
{ id: 'p-providers', kind: 'page', icon: 'i-heroicons-building-storefront', title: 'Providers', meta: 'Settings · Carrier setup', to: '/settings/providers' },
{ id: 'p-permissions', kind: 'page', icon: 'i-heroicons-lock-closed', title: 'Permissions', meta: 'Settings · Roles & access', to: '/settings/permissions' },
// Tasks
{ id: 'p-tasks', kind: 'page', icon: 'i-heroicons-clipboard-document-check', title: 'Tasks', meta: 'Work management', to: '/tasks' },
]
/* ── Search filtering ── */
const needle = computed(() => q.value.trim().toLowerCase())
const filteredRecords = computed(() => {
if (!needle.value) return []
const n = needle.value
return allRecords.value.filter(
(x) =>
x.title.toLowerCase().includes(n) ||
x.meta.toLowerCase().includes(n) ||
(x.detail?.toLowerCase().includes(n) ?? false)
).slice(0, 8)
})
const filteredPages = computed(() => {
if (!needle.value) return APP_PAGES.slice(0, 6) // show top pages when empty
const n = needle.value
return APP_PAGES.filter(
(x) =>
x.title.toLowerCase().includes(n) ||
x.meta.toLowerCase().includes(n)
).slice(0, 8)
})
const allFiltered = computed(() => [...filteredRecords.value, ...filteredPages.value])
/* ── Keyboard navigation ── */
function navigate(hit: SearchHit) {
open.value = false
q.value = ''
activeIndex.value = -1
router.push(hit.to)
}
function onKeydown(e: KeyboardEvent) {
if (!open.value) return
const total = allFiltered.value.length
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = activeIndex.value <= 0 ? total - 1 : activeIndex.value - 1
} else if (e.key === 'Enter' && activeIndex.value >= 0 && activeIndex.value < total) {
e.preventDefault()
navigate(allFiltered.value[activeIndex.value]!)
} else if (e.key === 'Escape') {
e.preventDefault()
open.value = false
activeIndex.value = -1
}
}
/* ── Global Cmd/Ctrl+K ── */
function onGlobalKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
open.value = true
nextTick(() => {
const el = inputRef.value
if (el) {
const input = el.querySelector('input') ?? (el as HTMLInputElement)
input?.focus()
}
})
}
}
/* ── Click outside ── */
const containerRef = ref<HTMLElement | null>(null)
function onDocClick(e: MouseEvent) {
const el = containerRef.value
if (!el || !open.value) return
if (!el.contains(e.target as Node)) {
open.value = false
activeIndex.value = -1
}
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onGlobalKey)
})
onUnmounted(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onGlobalKey)
})
watch(q, () => {
open.value = true
activeIndex.value = -1
})
/* ── Kind colors ── */
const kindColor: Record<string, string> = {
customer: '#01696f',
policy: '#7c3aed',
claim: '#c13838',
page: '#8a8a86',
}
</script>
<template>
<div ref="containerRef" class="relative w-full max-w-xl min-w-0 flex-1">
<div ref="inputRef">
<UInput
v-model="q"
icon="i-heroicons-magnifying-glass"
placeholder="Search customers, policies, pages… (⌘K)"
class="w-full"
@focus="open = true"
@keydown="onKeydown"
/>
</div>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="open"
class="cs-dropdown"
>
<div class="max-h-[min(70vh,460px)] overflow-y-auto">
<!-- Records section (customers, policies, claims) -->
<template v-if="filteredRecords.length > 0">
<div class="cs-section-head">
<span>Records</span>
<span class="cs-section-count">{{ filteredRecords.length }}</span>
</div>
<ul class="py-1">
<li v-for="(r, i) in filteredRecords" :key="r.id">
<button
type="button"
class="cs-hit"
:class="activeIndex === i ? 'cs-hit-active' : ''"
@click="navigate(r)"
@mouseenter="activeIndex = i"
>
<div class="cs-hit-icon" :style="`color: ${kindColor[r.kind] || '#8a8a86'}`">
<UIcon :name="r.icon" style="width: 16px; height: 16px;" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-[13px] font-medium text-[var(--text-primary)]">{{ r.title }}</p>
<span class="cs-kind-badge" :style="`background: ${kindColor[r.kind]}15; color: ${kindColor[r.kind]}`">
{{ r.kind }}
</span>
</div>
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ r.meta }}</p>
<p v-if="r.detail" class="truncate text-[11px] text-[var(--text-muted)] opacity-70">{{ r.detail }}</p>
</div>
<UIcon name="i-heroicons-arrow-right" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
</button>
</li>
</ul>
</template>
<!-- No record results message -->
<template v-if="needle && filteredRecords.length === 0">
<div class="cs-section-head">
<span>Records</span>
</div>
<p class="px-4 py-3 text-[12px] text-[var(--text-muted)]">
No customers, policies, or claims match "{{ q.trim() }}"
</p>
</template>
<!-- Pages / Go to section -->
<template v-if="filteredPages.length > 0">
<div class="cs-section-head" :class="filteredRecords.length > 0 ? 'cs-section-border' : ''">
<span>{{ needle ? 'Pages' : 'Quick access' }}</span>
</div>
<ul class="py-1 pb-2">
<li v-for="(a, ai) in filteredPages" :key="a.id">
<button
type="button"
class="cs-hit"
:class="activeIndex === filteredRecords.length + ai ? 'cs-hit-active' : ''"
@click="navigate(a)"
@mouseenter="activeIndex = filteredRecords.length + ai"
>
<div class="cs-hit-icon" style="color: #8a8a86;">
<UIcon :name="a.icon" style="width: 16px; height: 16px;" />
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] text-[var(--text-primary)]">{{ a.title }}</p>
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ a.meta }}</p>
</div>
<UIcon name="i-heroicons-arrow-top-right-on-square" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
</button>
</li>
</ul>
</template>
<!-- Keyboard hint -->
<div class="cs-footer">
<span class="cs-kbd"></span> navigate
<span class="cs-kbd"></span> go
<span class="cs-kbd">esc</span> close
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.cs-dropdown {
position: absolute;
left: 0; right: 0;
top: calc(100% + 6px);
z-index: 50;
overflow: hidden;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.08);
background: var(--surface, #ffffff);
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
}
.cs-section-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #8a8a86;
}
.cs-section-border {
border-top: 1px solid rgba(0,0,0,0.06);
margin-top: 2px;
padding-top: 10px;
}
.cs-section-count {
font-size: 9px;
font-weight: 600;
padding: 0 4px;
border-radius: 4px;
background: rgba(0,0,0,0.05);
color: #8a8a86;
}
.cs-hit {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 100ms ease;
}
.cs-hit:hover,
.cs-hit-active {
background: rgba(1,105,111,0.04);
}
.cs-hit-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(0,0,0,0.03);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cs-kind-badge {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: 4px;
flex-shrink: 0;
}
.cs-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid rgba(0,0,0,0.06);
font-size: 10px;
color: #a0a09c;
}
.cs-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 4px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.1);
background: rgba(0,0,0,0.03);
font-size: 9px;
font-weight: 600;
font-family: inherit;
color: #8a8a86;
}
</style>

View File

@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { AppThemeId } from '~/types/app-theme'
import { APP_THEME_OPTIONS } from '~/types/app-theme'
defineProps<{
sidebarCollapsed: boolean
brandTitle?: string
}>()
const emit = defineEmits<{
toggleSidebar: []
refresh: []
}>()
const route = useRoute()
const router = useRouter()
const isHome = computed(() => route.path === '/')
const { themeId, applyTheme } = useAppTheme()
const themeIcons: Record<string, string> = {
light: 'i-heroicons-sun',
purple: 'i-heroicons-sparkles',
dark: 'i-heroicons-moon',
'dark-purple': 'i-heroicons-star',
}
const userMenuOpen = ref(false)
const userMenuRoot = ref<HTMLElement | null>(null)
const themeMenuOpen = ref(false)
const themeMenuRoot = ref<HTMLElement | null>(null)
function closeUserMenu() {
userMenuOpen.value = false
}
function onDocClick(e: MouseEvent) {
const userEl = userMenuRoot.value
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
userMenuOpen.value = false
}
const themeEl = themeMenuRoot.value
if (themeEl && themeMenuOpen.value && !themeEl.contains(e.target as Node)) {
themeMenuOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocClick))
onUnmounted(() => document.removeEventListener('click', onDocClick))
</script>
<template>
<header class="app-topbar">
<!-- Left: sidebar toggle + brand -->
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="app-topbar-icon-btn"
title="Show / hide sidebar"
aria-label="Toggle sidebar"
@click="emit('toggleSidebar')"
>
<UIcon :name="sidebarCollapsed ? 'i-heroicons-bars-3' : 'i-heroicons-chevron-double-left'" style="width: 16px; height: 16px;" />
</button>
<NuxtLink to="/" class="flex items-center gap-1.5 rounded-lg px-1.5 py-1 transition hover:opacity-80">
<span class="text-[12px] font-medium tracking-tight text-[#a0a09c]">{{ brandTitle || 'Segur-OS' }}</span>
<span class="rounded-sm px-0.5 py-px text-[8px] font-medium uppercase tracking-wider text-[#c0c0bc]">Beta</span>
</NuxtLink>
</div>
<!-- Center: search (absolutely centered) -->
<div class="app-topbar-search-wrap">
<LayoutAppCommandSearch />
</div>
<!-- Center-right: contextual actions (home page) -->
<div v-if="isHome" class="ml-auto mr-1 hidden items-center gap-1.5 sm:flex">
<NuxtLink to="/onboarding">
<button type="button" class="app-topbar-action-btn">
<UIcon name="i-heroicons-arrow-trending-up" style="width: 13px; height: 13px;" />
Pipeline
</button>
</NuxtLink>
<NuxtLink to="/sales/quick-lead">
<button type="button" class="app-topbar-action-btn">
<UIcon name="i-heroicons-bolt" style="width: 13px; height: 13px;" />
Quick Lead
</button>
</NuxtLink>
<NuxtLink to="/quotes">
<button type="button" class="app-topbar-action-btn app-topbar-action-primary">
<UIcon name="i-heroicons-document-text" style="width: 13px; height: 13px;" />
New quote
</button>
</NuxtLink>
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
</div>
<!-- Right: actions -->
<div class="flex shrink-0 items-center gap-1" :class="isHome ? '' : 'ml-auto'">
<button
type="button"
class="app-topbar-icon-btn hidden sm:inline-flex"
title="Refresh data"
aria-label="Refresh"
@click="emit('refresh')"
>
<UIcon name="i-heroicons-arrow-path" style="width: 16px; height: 16px;" />
</button>
<!-- Quick theme switcher -->
<div ref="themeMenuRoot" class="relative">
<button
type="button"
class="app-topbar-icon-btn"
title="Switch theme"
aria-label="Theme"
@click.stop="themeMenuOpen = !themeMenuOpen"
>
<UIcon :name="themeIcons[themeId] ?? 'i-heroicons-swatch'" style="width: 16px; height: 16px;" />
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95 translate-y-1"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-show="themeMenuOpen"
class="absolute right-0 top-[calc(100%+8px)] z-50 w-52 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1.5 shadow-xl ring-1 ring-black/5"
>
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Theme</p>
<button
v-for="opt in APP_THEME_OPTIONS"
:key="opt.id"
type="button"
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-[var(--brand-faint)]"
:class="themeId === opt.id ? 'text-[var(--brand)] font-medium' : 'text-[var(--text-primary)]'"
@click="applyTheme(opt.id as AppThemeId); themeMenuOpen = false"
>
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4 shrink-0" :class="themeId === opt.id ? 'text-[var(--brand)]' : 'opacity-60'" />
<span class="flex-1">{{ opt.label }}</span>
<UIcon
v-if="themeId === opt.id"
name="i-heroicons-check"
class="h-3.5 w-3.5 text-[var(--brand)]"
/>
</button>
<div class="mx-3 my-1.5 border-t border-[var(--sidebar-border)]" />
<NuxtLink
to="/account"
class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-[var(--text-muted)] transition hover:text-[var(--brand)]"
@click="themeMenuOpen = false"
>
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
All appearance settings
</NuxtLink>
</div>
</Transition>
</div>
<NuxtLink to="/settings" class="inline-flex" title="Software settings">
<span class="app-topbar-icon-btn">
<UIcon name="i-heroicons-cog-6-tooth" style="width: 16px; height: 16px;" />
</span>
</NuxtLink>
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
<!-- User / Account -->
<div ref="userMenuRoot" class="relative">
<button
type="button"
class="inline-flex w-auto items-center gap-1 rounded-md px-1.5 py-0.5 text-[#a0a09c] transition hover:bg-[rgba(0,0,0,0.04)] hover:text-[#6b6b68]"
aria-label="Account menu"
:aria-expanded="userMenuOpen"
@click.stop="userMenuOpen = !userMenuOpen"
>
<UIcon name="i-heroicons-user-circle" style="width: 15px; height: 15px;" />
<span class="hidden text-[11px] font-medium lg:inline">Account</span>
<UIcon name="i-heroicons-chevron-down" style="width: 8px; height: 8px; opacity: 0.35;" />
</button>
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-show="userMenuOpen"
class="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1 shadow-xl ring-1 ring-black/5"
>
<NuxtLink
to="/account"
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
@click="closeUserMenu"
>
<UIcon name="i-heroicons-user-circle" class="h-4 w-4 opacity-70" />
My account
</NuxtLink>
<NuxtLink
to="/settings"
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
@click="closeUserMenu"
>
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 opacity-70" />
Software settings
</NuxtLink>
<div class="my-1 border-t border-[var(--sidebar-border)]" />
<div class="px-3 py-1.5">
<p class="text-[12px] font-medium text-[var(--text-primary)]">Session (mock)</p>
<p class="text-[11px] text-[var(--text-muted)]">broker@demo.com</p>
</div>
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
disabled
>
<UIcon name="i-heroicons-arrow-right-on-rectangle" class="h-4 w-4" />
Sign out (soon)
</button>
</div>
</Transition>
</div>
</div>
</header>
</template>
<style scoped>
.app-topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.5rem;
padding: 0 0.75rem;
border-bottom: none;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
background: var(--topbar-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
@media (min-width: 768px) {
.app-topbar { padding: 0 1rem; }
}
.app-topbar-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border-radius: 6px;
color: #a0a09c;
transition: color 150ms ease, background 150ms ease;
cursor: pointer;
border: none;
background: none;
}
.app-topbar-icon-btn:hover {
color: #6b6b68;
background: rgba(0, 0, 0, 0.04);
}
/* ---- Contextual action buttons (home) ---- */
.app-topbar-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
color: #8a8a86;
background: transparent;
border: 1px solid rgba(0,0,0,0.06);
cursor: pointer;
transition: all 150ms ease;
white-space: nowrap;
}
.app-topbar-action-btn:hover {
color: var(--text-primary);
background: rgba(0,0,0,0.03);
border-color: rgba(0,0,0,0.1);
}
.app-topbar-action-primary {
color: #ffffff;
background: #01696f;
border-color: #01696f;
}
.app-topbar-action-primary:hover {
color: #ffffff;
background: #015b60;
border-color: #015b60;
}
/* ---- Centered search bar ---- */
.app-topbar-search-wrap {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 380px;
display: none;
}
@media (min-width: 768px) {
.app-topbar-search-wrap { display: block; }
}
/* Search input — quiet at rest, reveals on focus */
.app-topbar-search-wrap :deep(input) {
border-radius: 8px !important;
padding-left: 2rem !important;
padding-right: 0.75rem !important;
height: 1.75rem;
font-size: 0.6875rem;
background: transparent !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
color: var(--text-secondary) !important;
transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
}
.app-topbar-search-wrap :deep(input):focus {
background: var(--surface) !important;
border-color: rgba(0, 0, 0, 0.1) !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06) !important;
}
/* Round the input wrapper/container */
.app-topbar-search-wrap :deep(.relative) {
border-radius: 8px;
}
.app-topbar-search-wrap :deep(> div) {
border-radius: 8px;
}
</style>