432 lines
18 KiB
Vue
432 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { useReferralChannels, type ReferralChannel } from '~/composables/useReferralChannels'
|
|
|
|
usePageTitle('Referral Channels · Settings')
|
|
|
|
const { channels, addChannel, updateChannel, removeChannel } = useReferralChannels()
|
|
const toast = useToast()
|
|
|
|
/* ── Form state ── */
|
|
const formOpen = ref(false)
|
|
const editingId = ref<string | null>(null)
|
|
|
|
const fname = ref('')
|
|
const ftype = ref<ReferralChannel['type']>('person')
|
|
const fcontactName = ref('')
|
|
const fcontactPhone = ref('')
|
|
const fcontactEmail = ref('')
|
|
const fnote = ref('')
|
|
const factive = ref(true)
|
|
|
|
const typeOptions = [
|
|
{ label: 'Person', value: 'person' as const },
|
|
{ label: 'Company', value: 'company' as const },
|
|
{ label: 'Digital / Online', value: 'digital' as const },
|
|
{ label: 'Event', value: 'event' as const },
|
|
{ label: 'Other', value: 'other' as const },
|
|
]
|
|
|
|
function resetForm() {
|
|
fname.value = ''
|
|
ftype.value = 'person'
|
|
fcontactName.value = ''
|
|
fcontactPhone.value = ''
|
|
fcontactEmail.value = ''
|
|
fnote.value = ''
|
|
factive.value = true
|
|
editingId.value = null
|
|
formOpen.value = false
|
|
}
|
|
|
|
function editChannel(ch: ReferralChannel) {
|
|
editingId.value = ch.id
|
|
fname.value = ch.name
|
|
ftype.value = ch.type
|
|
fcontactName.value = ch.contactName
|
|
fcontactPhone.value = ch.contactPhone
|
|
fcontactEmail.value = ch.contactEmail
|
|
fnote.value = ch.note
|
|
factive.value = ch.active
|
|
formOpen.value = true
|
|
}
|
|
|
|
function submit() {
|
|
if (!fname.value.trim()) return
|
|
if (editingId.value) {
|
|
updateChannel(editingId.value, {
|
|
name: fname.value.trim(),
|
|
type: ftype.value,
|
|
contactName: fcontactName.value.trim(),
|
|
contactPhone: fcontactPhone.value.trim(),
|
|
contactEmail: fcontactEmail.value.trim(),
|
|
note: fnote.value.trim(),
|
|
active: factive.value,
|
|
})
|
|
toast.add({ title: 'Channel updated', color: 'success' })
|
|
} else {
|
|
addChannel({
|
|
name: fname.value.trim(),
|
|
type: ftype.value,
|
|
contactName: fcontactName.value.trim(),
|
|
contactPhone: fcontactPhone.value.trim(),
|
|
contactEmail: fcontactEmail.value.trim(),
|
|
note: fnote.value.trim(),
|
|
active: factive.value,
|
|
})
|
|
toast.add({ title: 'Channel added', description: `${fname.value} added to referral channels`, color: 'success' })
|
|
}
|
|
resetForm()
|
|
}
|
|
|
|
function confirmRemove(id: string) {
|
|
removeChannel(id)
|
|
toast.add({ title: 'Channel removed', color: 'neutral' })
|
|
}
|
|
|
|
function toggleActive(id: string) {
|
|
const ch = channels.value.find(c => c.id === id)
|
|
if (ch) updateChannel(id, { active: !ch.active })
|
|
}
|
|
|
|
/* ── Filter ── */
|
|
type ListFilter = 'all' | 'active' | 'inactive'
|
|
const activeFilter = ref<ListFilter>('all')
|
|
|
|
const filteredChannels = computed(() => {
|
|
if (activeFilter.value === 'active') return channels.value.filter(c => c.active)
|
|
if (activeFilter.value === 'inactive') return channels.value.filter(c => !c.active)
|
|
return channels.value
|
|
})
|
|
|
|
const filterCounts = computed(() => ({
|
|
all: channels.value.length,
|
|
active: channels.value.filter(c => c.active).length,
|
|
inactive: channels.value.filter(c => !c.active).length,
|
|
}))
|
|
|
|
/* ── Helpers ── */
|
|
const typeMeta: Record<string, { label: string; icon: string; class: string }> = {
|
|
person: { label: 'Person', icon: 'i-heroicons-user', class: 'rc-type-person' },
|
|
company: { label: 'Company', icon: 'i-heroicons-building-office', class: 'rc-type-company' },
|
|
digital: { label: 'Digital', icon: 'i-heroicons-globe-alt', class: 'rc-type-digital' },
|
|
event: { label: 'Event', icon: 'i-heroicons-calendar-days', class: 'rc-type-event' },
|
|
other: { label: 'Other', icon: 'i-heroicons-tag', class: 'rc-type-other' },
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rc-page">
|
|
<!-- Back -->
|
|
<NuxtLink to="/settings" class="inline-flex">
|
|
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
|
|
</NuxtLink>
|
|
|
|
<!-- Header -->
|
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
|
<div class="max-w-xl">
|
|
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Referral Channels</h1>
|
|
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
|
Manage the sources that generate leads and new business — people, companies, digital campaigns, events, and more.
|
|
</p>
|
|
</div>
|
|
<button type="button" class="rc-btn-primary" @click="formOpen = !formOpen; if (!formOpen) resetForm()">
|
|
<UIcon :name="formOpen ? 'i-heroicons-chevron-up' : 'i-heroicons-plus'" style="width: 14px; height: 14px;" />
|
|
{{ formOpen ? 'Close form' : 'Add channel' }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══ Add / Edit form ═══ -->
|
|
<Transition
|
|
enter-active-class="transition-all duration-200 ease-out"
|
|
enter-from-class="opacity-0 -translate-y-2 max-h-0"
|
|
enter-to-class="opacity-100 translate-y-0 max-h-[700px]"
|
|
leave-active-class="transition-all duration-150 ease-in"
|
|
leave-from-class="opacity-100 max-h-[700px]"
|
|
leave-to-class="opacity-0 -translate-y-2 max-h-0"
|
|
>
|
|
<div v-if="formOpen" class="rc-form-card">
|
|
<div class="rc-section">
|
|
<p class="rc-section-title">{{ editingId ? 'Edit channel' : 'New referral channel' }}</p>
|
|
<div class="rc-fields">
|
|
<div class="rc-field rc-field-full">
|
|
<label class="rc-label">Channel name <span class="rc-required">*</span></label>
|
|
<UInput v-model="fname" placeholder="e.g. Roberto Jiménez, Instagram Ads, Expo 2026" size="sm" />
|
|
</div>
|
|
<div class="rc-field">
|
|
<label class="rc-label">Type</label>
|
|
<USelect v-model="ftype" :items="typeOptions" size="sm" />
|
|
</div>
|
|
<div class="rc-field">
|
|
<label class="rc-label">Status</label>
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<button type="button" class="rc-toggle" :class="factive ? 'rc-toggle-on' : 'rc-toggle-off'" @click="factive = !factive">
|
|
<span class="rc-toggle-dot" :class="factive ? 'rc-dot-on' : 'rc-dot-off'" />
|
|
</button>
|
|
<span class="text-[12px]" :class="factive ? 'text-[#01696f] font-medium' : 'text-[#8a8a86]'">{{ factive ? 'Active' : 'Inactive' }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rc-divider" />
|
|
|
|
<div class="rc-section">
|
|
<p class="rc-section-title">Contact info</p>
|
|
<div class="rc-fields">
|
|
<div class="rc-field">
|
|
<label class="rc-label">Contact name</label>
|
|
<UInput v-model="fcontactName" placeholder="Primary contact person" size="sm" />
|
|
</div>
|
|
<div class="rc-field">
|
|
<label class="rc-label">Phone</label>
|
|
<UInput v-model="fcontactPhone" placeholder="+506 0000-0000" size="sm" />
|
|
</div>
|
|
<div class="rc-field">
|
|
<label class="rc-label">Email</label>
|
|
<UInput v-model="fcontactEmail" placeholder="email@company.com" size="sm" type="email" />
|
|
</div>
|
|
<div class="rc-field rc-field-full">
|
|
<label class="rc-label">Notes</label>
|
|
<UTextarea v-model="fnote" placeholder="Context, relationship, expected lead volume..." size="sm" :rows="2" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rc-footer">
|
|
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
|
<UIcon name="i-heroicons-link" style="width: 14px; height: 14px; opacity: 0.5;" />
|
|
{{ editingId ? 'Editing existing channel' : 'Creates a new referral source' }}
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button type="button" class="rc-cancel-btn" @click="resetForm">Cancel</button>
|
|
<button type="button" class="rc-btn-primary" :class="!fname.trim() ? 'rc-btn-disabled' : ''" @click="submit">
|
|
<UIcon :name="editingId ? 'i-heroicons-check' : 'i-heroicons-plus'" style="width: 14px; height: 14px;" />
|
|
{{ editingId ? 'Save changes' : 'Add Channel' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- ═══ KPI strip ═══ -->
|
|
<div class="rc-kpi-strip">
|
|
<div class="rc-kpi">
|
|
<p class="rc-kpi-label">Total channels</p>
|
|
<p class="rc-kpi-value">{{ channels.length }}</p>
|
|
</div>
|
|
<div class="rc-kpi">
|
|
<p class="rc-kpi-label">Active</p>
|
|
<p class="rc-kpi-value" style="color: #01696f;">{{ filterCounts.active }}</p>
|
|
</div>
|
|
<div class="rc-kpi">
|
|
<p class="rc-kpi-label">Inactive</p>
|
|
<p class="rc-kpi-value">{{ filterCounts.inactive }}</p>
|
|
</div>
|
|
<div class="rc-kpi">
|
|
<p class="rc-kpi-label">Types</p>
|
|
<p class="rc-kpi-value">{{ new Set(channels.map(c => c.type)).size }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══ Filter tabs ═══ -->
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="rc-filter-tabs">
|
|
<button
|
|
v-for="f in ([
|
|
{ id: 'all', label: 'All' },
|
|
{ id: 'active', label: 'Active' },
|
|
{ id: 'inactive', label: 'Inactive' },
|
|
] as { id: ListFilter; label: string }[])"
|
|
:key="f.id"
|
|
type="button"
|
|
class="rc-filter-tab"
|
|
:class="activeFilter === f.id ? 'rc-filter-on' : 'rc-filter-off'"
|
|
@click="activeFilter = f.id"
|
|
>
|
|
{{ f.label }}
|
|
<span class="rc-filter-count" :class="activeFilter === f.id ? 'rc-filter-count-on' : ''">{{ filterCounts[f.id] }}</span>
|
|
</button>
|
|
</div>
|
|
<span class="text-[11px] text-[var(--text-muted)]">{{ filteredChannels.length }} results</span>
|
|
</div>
|
|
|
|
<!-- ═══ Channel list ═══ -->
|
|
<div v-if="filteredChannels.length === 0" class="rc-empty">
|
|
<UIcon name="i-heroicons-link" style="width: 32px; height: 32px; color: #c0c0bc;" />
|
|
<p class="text-[13px] text-[var(--text-muted)] mt-2">No referral channels yet.</p>
|
|
<button type="button" class="rc-btn-primary mt-3" @click="formOpen = true">
|
|
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
|
Add your first channel
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="rc-list">
|
|
<div v-for="ch in filteredChannels" :key="ch.id" class="rc-card group">
|
|
<div class="rc-card-top">
|
|
<div class="rc-card-icon" :class="typeMeta[ch.type]?.class">
|
|
<UIcon :name="typeMeta[ch.type]?.icon ?? 'i-heroicons-tag'" style="width: 16px; height: 16px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ ch.name }}</p>
|
|
<span class="rc-type-badge" :class="typeMeta[ch.type]?.class">{{ typeMeta[ch.type]?.label ?? ch.type }}</span>
|
|
<span v-if="!ch.active" class="rc-inactive-badge">Inactive</span>
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--text-muted)]">
|
|
<span v-if="ch.contactName">{{ ch.contactName }}</span>
|
|
<span v-if="ch.contactPhone">{{ ch.contactPhone }}</span>
|
|
<span v-if="ch.contactEmail">{{ ch.contactEmail }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="rc-card-actions">
|
|
<button type="button" class="rc-action-btn" :class="ch.active ? 'rc-action-toggle-on' : 'rc-action-toggle-off'" :title="ch.active ? 'Deactivate' : 'Activate'" @click="toggleActive(ch.id)">
|
|
<UIcon :name="ch.active ? 'i-heroicons-eye' : 'i-heroicons-eye-slash'" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
<button type="button" class="rc-action-btn" title="Edit" @click="editChannel(ch)">
|
|
<UIcon name="i-heroicons-pencil-square" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
<button type="button" class="rc-action-btn rc-action-delete" title="Remove" @click="confirmRemove(ch.id)">
|
|
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="ch.note" class="rc-card-note">
|
|
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width: 11px; height: 11px; color: #8a8a86; flex-shrink: 0;" />
|
|
<span>{{ ch.note }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rc-page {
|
|
max-width: 48rem; margin: 0 auto;
|
|
display: flex; flex-direction: column; gap: 20px; padding-bottom: 3rem;
|
|
}
|
|
|
|
/* ── Buttons ── */
|
|
.rc-btn-primary {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 16px; border-radius: 8px;
|
|
background: #01696f; color: #fff;
|
|
font-size: 13px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.rc-btn-primary:hover { background: #015458; }
|
|
.rc-btn-disabled { opacity: 0.5; pointer-events: none; }
|
|
.rc-cancel-btn {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 8px 14px; border-radius: 8px;
|
|
background: transparent; color: var(--text-muted);
|
|
font-size: 13px; font-weight: 500;
|
|
border: 1px solid rgba(0,0,0,0.08); cursor: pointer;
|
|
transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.rc-cancel-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
|
|
|
|
/* ── Form card ── */
|
|
.rc-form-card {
|
|
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
|
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.03); overflow: hidden;
|
|
}
|
|
.rc-section { padding: 20px; }
|
|
.rc-section-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
|
|
.rc-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
|
@media (max-width: 639px) { .rc-fields { grid-template-columns: 1fr; } }
|
|
.rc-field-full { grid-column: 1 / -1; }
|
|
.rc-field { display: flex; flex-direction: column; gap: 6px; }
|
|
.rc-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; }
|
|
.rc-required { color: #c13838; }
|
|
.rc-divider { height: 1px; background: rgba(0,0,0,0.06); margin: 0 20px; }
|
|
.rc-footer {
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
padding: 16px 20px; border-top: 1px solid rgba(0,0,0,0.06); background: rgba(0,0,0,0.015);
|
|
}
|
|
|
|
/* Toggle */
|
|
.rc-toggle {
|
|
width: 32px; height: 18px; border-radius: 9px; border: none;
|
|
cursor: pointer; position: relative; transition: background 150ms ease;
|
|
}
|
|
.rc-toggle-on { background: #01696f; }
|
|
.rc-toggle-off { background: rgba(0,0,0,0.12); }
|
|
.rc-toggle-dot {
|
|
display: block; width: 14px; height: 14px; border-radius: 7px;
|
|
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
|
|
}
|
|
.rc-dot-on { left: 16px; }
|
|
.rc-dot-off { left: 2px; }
|
|
|
|
/* ── KPI strip ── */
|
|
.rc-kpi-strip {
|
|
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
|
|
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
|
background: rgba(0,0,0,0.06); box-shadow: 0 1px 3px rgba(0,0,0,0.03); overflow: hidden;
|
|
}
|
|
.rc-kpi { padding: 14px 18px; background: #fff; }
|
|
.rc-kpi:first-child { border-radius: 12px 0 0 12px; }
|
|
.rc-kpi:last-child { border-radius: 0 12px 12px 0; }
|
|
.rc-kpi-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; }
|
|
.rc-kpi-value { margin-top: 4px; font-size: 22px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
|
@media (max-width: 640px) { .rc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
|
|
|
/* ── Filter tabs ── */
|
|
.rc-filter-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,0.04); }
|
|
.rc-filter-tab {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 6px 12px; border-radius: 8px;
|
|
font-size: 12px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.rc-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
.rc-filter-off { background: transparent; color: var(--text-muted); }
|
|
.rc-filter-off:hover { color: var(--text-primary); }
|
|
.rc-filter-count { font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
|
.rc-filter-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
|
|
|
|
/* ── Channel cards ── */
|
|
.rc-list { display: flex; flex-direction: column; gap: 6px; }
|
|
.rc-card {
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
padding: 14px 16px; border-radius: 10px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
transition: all 150ms ease;
|
|
}
|
|
.rc-card:hover { border-color: rgba(1,105,111,0.15); box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
|
.rc-card-top { display: flex; align-items: center; gap: 10px; }
|
|
.rc-card-icon {
|
|
width: 36px; height: 36px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
}
|
|
.rc-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease; flex-shrink: 0; }
|
|
.rc-card:hover .rc-card-actions { opacity: 1; }
|
|
.rc-action-btn {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
|
|
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
|
|
}
|
|
.rc-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
|
.rc-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
|
|
.rc-action-toggle-on:hover { background: rgba(1,105,111,0.1); color: #01696f; }
|
|
.rc-action-toggle-off { opacity: 0.5; }
|
|
.rc-action-toggle-off:hover { background: rgba(194,123,26,0.08); color: #c27b1a; opacity: 1; }
|
|
|
|
.rc-card-note {
|
|
display: flex; align-items: flex-start; gap: 5px; padding-left: 46px;
|
|
font-size: 12px; color: var(--text-muted); line-height: 1.4;
|
|
}
|
|
|
|
/* ── Type badges ── */
|
|
.rc-type-badge { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; white-space: nowrap; }
|
|
.rc-type-person { background: rgba(59,130,246,0.08); color: #3b82f6; }
|
|
.rc-type-company { background: rgba(1,105,111,0.08); color: #01696f; }
|
|
.rc-type-digital { background: rgba(147,51,234,0.08); color: #9333ea; }
|
|
.rc-type-event { background: rgba(194,123,26,0.08); color: #c27b1a; }
|
|
.rc-type-other { background: rgba(0,0,0,0.04); color: #8a8a86; }
|
|
.rc-inactive-badge { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: #8a8a86; white-space: nowrap; }
|
|
|
|
/* ── Empty state ── */
|
|
.rc-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 16px; text-align: center; }
|
|
</style>
|