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
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user