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,340 @@
|
||||
<script setup lang="ts">
|
||||
// Reusable country picker. v-model stores ISO 3166-1 alpha-2 codes ('DK',
|
||||
// 'GB', ...), displays the full name in the input, and filters on either
|
||||
// name or code as the user types. Keyboard nav: ↑/↓ to move, Enter to
|
||||
// select, Esc to close.
|
||||
//
|
||||
// Lives in packages/ui/components and is auto-imported by both Nuxt apps
|
||||
// (portal + operator) via their nuxt.config.ts `components.dirs` entry.
|
||||
// The docker-compose mount `../../packages:/shared-packages:ro` makes the
|
||||
// directory visible inside each dev container.
|
||||
//
|
||||
// To extend the country list, add { code, name } pairs to COUNTRIES below.
|
||||
// Codes must be 2 uppercase letters (matches the platform-api DTO).
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Limits how many options render at once. Default 60 — enough for the
|
||||
* common-case "type a few letters" workflow without dumping all ~250
|
||||
* countries into the DOM on focus. */
|
||||
maxResults?: number
|
||||
}>(),
|
||||
{ placeholder: 'Country', disabled: false, maxResults: 60 },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
||||
|
||||
interface Country { code: string; name: string }
|
||||
|
||||
// ISO 3166-1 alpha-2 list. Trimmed to the entries we expect Dezky tenants
|
||||
// + partners to come from in practice — primarily EU/EEA/Nordic, plus
|
||||
// English-speaking + major economies. Add more as customer demand surfaces.
|
||||
const COUNTRIES: readonly Country[] = [
|
||||
{ code: 'AD', name: 'Andorra' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czechia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FO', name: 'Faroe Islands' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'GL', name: 'Greenland' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'LI', name: 'Liechtenstein' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'MC', name: 'Monaco' },
|
||||
{ code: 'MT', name: 'Malta' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'SM', name: 'San Marino' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'TR', name: 'Türkiye' },
|
||||
{ code: 'TW', name: 'Taiwan' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'VA', name: 'Vatican City' },
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
] as const
|
||||
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const activeIndex = ref(-1)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const listRef = ref<HTMLUListElement | null>(null)
|
||||
|
||||
// Fixed-position coordinates for the teleported dropdown. Recomputed on
|
||||
// open and on scroll/resize so the dropdown follows the input even when
|
||||
// the user scrolls the modal body underneath it.
|
||||
const dropdownStyle = ref<{ top: string; left: string; width: string }>({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
})
|
||||
|
||||
function reposition() {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
dropdownStyle.value = {
|
||||
top: `${r.bottom + 4}px`,
|
||||
left: `${r.left}px`,
|
||||
width: `${r.width}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const selected = computed<Country | undefined>(() =>
|
||||
COUNTRIES.find((c) => c.code === props.modelValue.toUpperCase()),
|
||||
)
|
||||
|
||||
// What the input shows: the filter query while the dropdown is open (so
|
||||
// typing keeps appearing), or the selected country's full name otherwise.
|
||||
const displayValue = computed(() =>
|
||||
open.value ? query.value : (selected.value?.name ?? ''),
|
||||
)
|
||||
|
||||
const filtered = computed<Country[]>(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return COUNTRIES.slice(0, props.maxResults)
|
||||
return COUNTRIES.filter(
|
||||
(c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase() === q,
|
||||
).slice(0, props.maxResults)
|
||||
})
|
||||
|
||||
function selectCountry(c: Country) {
|
||||
emit('update:modelValue', c.code)
|
||||
query.value = ''
|
||||
activeIndex.value = -1
|
||||
open.value = false
|
||||
inputRef.value?.blur()
|
||||
}
|
||||
|
||||
function clear() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
open.value = true
|
||||
reposition()
|
||||
// Seed activeIndex to the currently selected entry if visible, so ↓ jumps
|
||||
// to the next one rather than restarting from the top.
|
||||
if (selected.value) {
|
||||
const i = filtered.value.findIndex((c) => c.code === selected.value!.code)
|
||||
activeIndex.value = i
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the teleported dropdown anchored to the input while it's open. We
|
||||
// listen with `capture` + `passive` so scrolls in any ancestor (including
|
||||
// the modal body) trigger reposition without intercepting events.
|
||||
onMounted(() => {
|
||||
const onScrollOrResize = () => {
|
||||
if (open.value) reposition()
|
||||
}
|
||||
window.addEventListener('scroll', onScrollOrResize, { capture: true, passive: true })
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true })
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', onScrollOrResize, { capture: true } as EventListenerOptions)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
})
|
||||
|
||||
function onBlur() {
|
||||
// Defer so a click on an option commits before we close the dropdown.
|
||||
// (mousedown.prevent on the option avoids the blur-then-close race, but
|
||||
// keeping the delay handles edge cases like tab-away.)
|
||||
setTimeout(() => {
|
||||
open.value = false
|
||||
query.value = ''
|
||||
activeIndex.value = -1
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
query.value = (e.target as HTMLInputElement).value
|
||||
open.value = true
|
||||
reposition()
|
||||
activeIndex.value = filtered.value.length > 0 ? 0 : -1
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
open.value = true
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, filtered.value.length - 1)
|
||||
scrollActiveIntoView()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
||||
scrollActiveIntoView()
|
||||
} else if (e.key === 'Enter') {
|
||||
if (activeIndex.value >= 0 && filtered.value[activeIndex.value]) {
|
||||
e.preventDefault()
|
||||
selectCountry(filtered.value[activeIndex.value])
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
activeIndex.value = -1
|
||||
inputRef.value?.blur()
|
||||
} else if (e.key === 'Backspace' && !query.value && selected.value) {
|
||||
// Empty input + Backspace clears the current selection.
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
nextTick(() => {
|
||||
const el = listRef.value?.querySelector('li.active') as HTMLElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs">
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
name="dezky-country-search"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
data-bwignore="true"
|
||||
data-form-type="other"
|
||||
:value="displayValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@input="onInput"
|
||||
@keydown="onKey"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<ul
|
||||
v-if="open && filtered.length > 0"
|
||||
ref="listRef"
|
||||
class="cs-dropdown"
|
||||
role="listbox"
|
||||
:style="dropdownStyle"
|
||||
>
|
||||
<li
|
||||
v-for="(c, i) in filtered"
|
||||
:key="c.code"
|
||||
role="option"
|
||||
:aria-selected="c.code === modelValue"
|
||||
:class="{ active: i === activeIndex, selected: c.code === modelValue }"
|
||||
@mousedown.prevent="selectCountry(c)"
|
||||
>
|
||||
<span class="name">{{ c.name }}</span>
|
||||
<span class="code">{{ c.code }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cs {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--border-hi); }
|
||||
input:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Dropdown styles must NOT be scoped — the <ul> teleports to body, where
|
||||
scoped-component selectors don't match. Top/left/width come from the
|
||||
reposition() inline style; everything else lives here. */
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.cs-dropdown {
|
||||
position: fixed;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--elevated, var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cs-dropdown li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cs-dropdown li:hover,
|
||||
.cs-dropdown li.active {
|
||||
background: var(--surface);
|
||||
}
|
||||
.cs-dropdown li.selected .name { font-weight: 600; }
|
||||
.cs-dropdown li .code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@dezky/ui",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Shared UI components used by apps/portal and apps/operator. Auto-imported via each app's nuxt.config.ts components.dirs entry — no build step.",
|
||||
"type": "module"
|
||||
}
|
||||
Reference in New Issue
Block a user