Files
dezky/apps/portal/components/enduser/EnduserPresenceSelector.vue
T
Ronni Baslund 0bd4e5498e feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library
  (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables,
  layouts, partner-routing middleware, and supporting server APIs
- pricing: Price schema/module with operator CRUD, pricing.vue catalog UI,
  Subscription extended with cycle/currency/perSeatAmount/seats snapshots
  for stable MRR aggregation
- partner staff: User.partnerId, invite-partner-user DTO and flow,
  /partners/:slug/users endpoints, InvitePartnerUserModal, shared
  dezky-partner-staff Authentik group
- /me: partner-aware endpoint returning user + partner context so portal
  can route between end-user and partner-admin surfaces
- tenant: seats field for portfolio displays and future MRR calculations
- operator: pricing page, signed-out page, useMe/useToast composables,
  ToastStack
2026-05-28 20:00:33 +02:00

130 lines
3.9 KiB
Vue

<script setup lang="ts">
// Presence selector for the dashboard greeting strip. Drop-down trigger styled
// as a pill with a status dot, label, and short hint. Visual only — no API call.
type Presence = 'available' | 'meeting' | 'focus' | 'away'
interface PresenceOption {
value: Presence
label: string
hint: string
color: string
}
const opts: PresenceOption[] = [
{ value: 'available', label: 'Available', hint: 'visible to teammates', color: 'var(--ok)' },
{ value: 'meeting', label: 'In a meeting', hint: 'silenced · auto-clears', color: 'var(--warn)' },
{ value: 'focus', label: 'Focus', hint: 'no notifications', color: 'var(--info)' },
{ value: 'away', label: 'Away', hint: 'be right back', color: 'var(--text-mute)' },
]
const props = defineProps<{ modelValue: Presence }>()
const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0])
function pick(v: Presence) {
emit('update:modelValue', v)
open.value = false
}
function onDocClick(e: MouseEvent) {
if (!rootRef.value) return
if (!rootRef.value.contains(e.target as Node)) open.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocClick))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick))
</script>
<template>
<div ref="rootRef" class="presence">
<button class="trigger" @click="open = !open" :aria-expanded="open">
<StatusDot :color="current.color" :size="8" />
<div class="trigger-text">
<span class="trigger-label">{{ current.label }}</span>
<span class="trigger-hint">{{ current.hint }}</span>
</div>
<UiIcon name="chevDown" :size="12" />
</button>
<Transition name="pop">
<div v-if="open" class="menu">
<button
v-for="o in opts"
:key="o.value"
class="opt"
:class="{ active: o.value === modelValue }"
@click="pick(o.value)"
>
<StatusDot :color="o.color" :size="8" />
<div class="opt-text">
<span class="opt-label">{{ o.label }}</span>
<span class="opt-hint">{{ o.hint }}</span>
</div>
<UiIcon v-if="o.value === modelValue" name="check" :size="13" />
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.presence { position: relative; }
.trigger {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border);
cursor: pointer;
font-family: inherit;
color: var(--text);
}
.trigger:hover { border-color: var(--border-hi); }
.trigger-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.trigger-label { font-size: 13px; font-weight: 500; }
.trigger-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 220px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
padding: 4px;
z-index: 50;
}
.opt {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: inherit;
color: var(--text);
text-align: left;
}
.opt:hover, .opt.active { background: var(--row-hover); }
.opt-text { display: flex; flex-direction: column; flex: 1; gap: 1px; }
.opt-label { font-size: 13px; font-weight: 500; }
.opt-hint { font-family: var(--font-mono); font-size: 10px; color: var(--text-mute); }
.pop-enter-active, .pop-leave-active { transition: opacity 0.12s, transform 0.12s; }
.pop-enter-from, .pop-leave-to { opacity: 0; transform: translateY(-4px); }
</style>