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,204 @@
|
||||
<script setup lang="ts">
|
||||
// Side-panel template editor. Subject + body + merge tags + live HTML preview
|
||||
// wrapped in the partner's brand color. Used from the partner branding page
|
||||
// "Customer email templates" list.
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
edited: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ template: EmailTemplate | null; brandColor: string; brandName: string }>()
|
||||
const emit = defineEmits<{ close: []; save: [t: EmailTemplate] }>()
|
||||
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
|
||||
watch(
|
||||
() => props.template?.id,
|
||||
() => {
|
||||
if (props.template) {
|
||||
subject.value = props.template.subject
|
||||
body.value = props.template.body
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const MERGE_TAGS = [
|
||||
'{{user.first_name}}',
|
||||
'{{workspace.name}}',
|
||||
'{{partner.name}}',
|
||||
'{{plan.name}}',
|
||||
'{{invoice.id}}',
|
||||
'{{support.email}}',
|
||||
]
|
||||
|
||||
function insertTag(t: string) {
|
||||
body.value += (body.value.endsWith(' ') || body.value === '' ? '' : ' ') + t
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
if (!props.template) return
|
||||
emit('save', { ...props.template, subject: subject.value, body: body.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
:open="!!template"
|
||||
width="lg"
|
||||
eyebrow="Email template"
|
||||
:title="template?.name || 'Edit template'"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div v-if="template" class="grid">
|
||||
<div class="editor">
|
||||
<label class="field">
|
||||
<Eyebrow>Subject</Eyebrow>
|
||||
<input v-model="subject" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<Eyebrow>Body</Eyebrow>
|
||||
<textarea v-model="body" rows="14" />
|
||||
</label>
|
||||
|
||||
<div class="tags">
|
||||
<Eyebrow>Merge tags · click to insert</Eyebrow>
|
||||
<div class="tag-chips">
|
||||
<button v-for="t in MERGE_TAGS" :key="t" type="button" @click="insertTag(t)">
|
||||
<Mono>{{ t }}</Mono>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-wrap">
|
||||
<Eyebrow>Live preview</Eyebrow>
|
||||
<div class="preview">
|
||||
<div class="pv-header" :style="{ background: brandColor }">
|
||||
<div class="pv-mark">{{ brandName[0]?.toLowerCase() }}</div>
|
||||
<span class="pv-brand">{{ brandName }}</span>
|
||||
</div>
|
||||
<div class="pv-body">
|
||||
<div class="pv-subject">{{ subject || '(empty subject)' }}</div>
|
||||
<div class="pv-body-text">{{ body || '(empty body)' }}</div>
|
||||
<div class="pv-cta-wrap">
|
||||
<a class="pv-cta" :style="{ background: brandColor }">Open workspace</a>
|
||||
</div>
|
||||
<div class="pv-foot">
|
||||
Sent by {{ brandName }} · support@nordicmsp.dk
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" @click="emit('close')">Cancel</UiButton>
|
||||
<div style="flex:1" />
|
||||
<UiButton variant="secondary">Send test email</UiButton>
|
||||
<UiButton variant="primary" @click="onSave">
|
||||
<template #leading><UiIcon name="check" :size="14" /></template>
|
||||
Save template
|
||||
</UiButton>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.editor { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field input, .field textarea {
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.field textarea { font-family: var(--font-mono); font-size: 12px; resize: vertical; line-height: 1.6; }
|
||||
.field input:focus, .field textarea:focus { outline: none; border-color: var(--border-hi); }
|
||||
|
||||
.tags { display: flex; flex-direction: column; gap: 8px; }
|
||||
.tag-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag-chips button {
|
||||
padding: 4px 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tag-chips button:hover { background: var(--bg); }
|
||||
|
||||
.preview-wrap { display: flex; flex-direction: column; gap: 10px; position: sticky; top: 0; }
|
||||
|
||||
.preview {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: #111;
|
||||
}
|
||||
.pv-header {
|
||||
padding: 14px 18px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.pv-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.pv-brand { font-family: var(--font-display); font-size: 16px; font-weight: 600; letter-spacing: -0.015em; }
|
||||
|
||||
.pv-body { padding: 20px 18px; }
|
||||
.pv-subject {
|
||||
font-family: var(--font-display);
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pv-body-text { font-size: 13px; line-height: 1.65; white-space: pre-wrap; color: #333; }
|
||||
.pv-cta-wrap { margin-top: 18px; }
|
||||
.pv-cta {
|
||||
display: inline-block;
|
||||
padding: 9px 14px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.pv-foot {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
margin-top: 22px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user