Sales Trainer v0.1: corporate sales-training simulator (Flask+Vue, 15 personas, chat simulator, judge, analytics)
- Auth/roles (no self-reg), admin user provision, JWT - Analyze: sales kit + initial pain-fit from form/upload - Persona generator: 15 personas (5/tier) w/ pain variety, negotiation, init mode, channel, latent/revealable, wrong_text special - Chat simulator: per-mode initiation, one-shot, hidden signals, judge-LLM debrief+coaching - Trainee loop: win/lose board, weak-areas, user-generated personas - Admin analytics; EN+TH Vue SPA served by Flask - Deploy: Dockerfile, docker-compose, README, eng-log + HANDOFF - Tests (mock LLM): m0/m1/routes/e2e all pass
This commit is contained in:
53
frontend/src/App.vue
Normal file
53
frontend/src/App.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<nav v-if="auth.user" class="topnav">
|
||||
<router-link to="/" class="brand">{{ i18n.t('app') }}</router-link>
|
||||
<div class="nav-right">
|
||||
<button @click="toggleLang" class="lang">{{ i18n.locale === 'th' ? 'EN' : 'TH' }}</button>
|
||||
<span class="muted">{{ auth.user.name }} ({{ auth.role }})</span>
|
||||
<button @click="logout">⏻ {{ i18n.t('logout') }}</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { auth } from './store/auth'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function toggleLang() {
|
||||
i18n.set(i18n.locale === 'th' ? 'en' : 'th')
|
||||
}
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
onMounted(() => {
|
||||
if (auth.token && !auth.user) auth.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 800; text-decoration: none; color: var(--ink); }
|
||||
.nav-right { display: flex; align-items: center; gap: 12px; }
|
||||
.lang { padding: 6px 10px; }
|
||||
.main { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
||||
</style>
|
||||
57
frontend/src/api/index.js
Normal file
57
frontend/src/api/index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// API client with JWT auth injection.
|
||||
const TOKEN_KEY = 'st_token'
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
export function setToken(t) {
|
||||
if (t) localStorage.setItem(TOKEN_KEY, t)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
async function request(method, url, body, isForm = false) {
|
||||
const headers = {}
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
let payload = body
|
||||
if (!isForm && body !== undefined && body !== null) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
payload = JSON.stringify(body)
|
||||
}
|
||||
const res = await fetch(url, { method, headers, body: payload })
|
||||
let data = null
|
||||
try {
|
||||
data = await res.json()
|
||||
} catch (e) {
|
||||
/* ignore json parse errors */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email, password) => request('POST', '/api/auth/login', { email, password }),
|
||||
me: () => request('GET', '/api/auth/me'),
|
||||
adminCreateUser: (b) => request('POST', '/api/admin/users', b),
|
||||
adminListUsers: () => request('GET', '/api/admin/users'),
|
||||
adminUpdateUser: (email, b) => request('PUT', `/api/admin/users/${email}`, b),
|
||||
createGroup: (formData) => request('POST', '/api/groups', formData, true),
|
||||
listGroups: () => request('GET', '/api/groups'),
|
||||
getGroup: (id) => request('GET', `/api/groups/${id}`),
|
||||
analyzeGroup: (id) => request('POST', `/api/groups/${id}/analyze`),
|
||||
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
|
||||
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
||||
updatePersona: (gid, pid, b) => request('PUT', `/api/groups/${gid}/personas/${pid}`, b),
|
||||
chatStart: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/start`),
|
||||
chatSend: (gid, pid, text) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/send`, { text }),
|
||||
chatFinish: (gid, pid) => request('POST', `/api/chat/${gid}/personas/${pid}/chat/finish`),
|
||||
mySessions: () => request('GET', '/api/chat/sessions'),
|
||||
myBoard: () => request('GET', '/api/me/board'),
|
||||
weakAreas: () => request('GET', '/api/me/weak-areas'),
|
||||
myPersonas: () => request('GET', '/api/me/personas'),
|
||||
generatePersona: (b) => request('POST', '/api/me/personas/generate', b),
|
||||
analytics: () => request('GET', '/api/analytics'),
|
||||
}
|
||||
122
frontend/src/i18n/index.js
Normal file
122
frontend/src/i18n/index.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Minimal i18n (EN + TH) via a reactive locale.
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
app: 'Sales Trainer',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
loginError: 'Invalid credentials',
|
||||
dashboard: 'Dashboard',
|
||||
groups: 'Persona Groups',
|
||||
myTraining: 'My Training',
|
||||
adminTools: 'Admin Tools',
|
||||
users: 'Users',
|
||||
analytics: 'Analytics',
|
||||
groupBuilder: 'Group Builder',
|
||||
create: 'Create',
|
||||
analyze: 'Analyze',
|
||||
manual: 'Manual',
|
||||
edit: 'Edit',
|
||||
product: 'Product',
|
||||
segment: 'Initial customer segment (optional)',
|
||||
description: 'Additional description / scenario (optional)',
|
||||
channel: 'Channel',
|
||||
facebook: 'Facebook',
|
||||
line: 'LINE',
|
||||
language: 'Language',
|
||||
thai: 'Thai',
|
||||
english: 'English',
|
||||
personas: 'Personas',
|
||||
tierA: 'Tier A — Ready to buy',
|
||||
tierB: 'Tier B — Unsure',
|
||||
tierC: 'Tier C — Not interested but has pain',
|
||||
selectPersona: 'Select a persona to practice',
|
||||
chat: 'Chat',
|
||||
start: 'Start',
|
||||
send: 'Send',
|
||||
finish: 'Finish & get result',
|
||||
debrief: 'Result & Coaching',
|
||||
won: 'Won',
|
||||
lost: 'Lost',
|
||||
notTried: 'Not tried',
|
||||
score: 'Score',
|
||||
pain: 'Pain',
|
||||
why: 'Reason',
|
||||
reveal: 'Revealed persona details',
|
||||
generatePersona: 'Generate my persona',
|
||||
weakAreas: 'My weak areas',
|
||||
mySessions: 'My sessions',
|
||||
openSaleTask: 'The customer did NOT message first. You must open the sale.',
|
||||
sellerInitiated: 'You must open the sale (outbound)',
|
||||
customerInitiated: 'The customer will message you first',
|
||||
},
|
||||
th: {
|
||||
app: 'ตัวฝึกขาย',
|
||||
login: 'เข้าสู่ระบบ',
|
||||
logout: 'ออกจากระบบ',
|
||||
email: 'อีเมล',
|
||||
password: 'รหัสผ่าน',
|
||||
loginError: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง',
|
||||
dashboard: 'หน้าหลัก',
|
||||
groups: 'กลุ่มลูกค้า (Persona)',
|
||||
myTraining: 'การฝึกของฉัน',
|
||||
adminTools: 'เครื่องมือ Admin',
|
||||
users: 'ผู้ใช้',
|
||||
analytics: 'สถิติ',
|
||||
groupBuilder: 'สร้างกลุ่มลูกค้า',
|
||||
create: 'สร้าง',
|
||||
analyze: 'วิเคราะห์',
|
||||
manual: 'กำหนดเอง',
|
||||
edit: 'แก้ไข',
|
||||
product: 'สินค้า/บริการ',
|
||||
segment: 'กลุ่มลูกค้าเบื้องต้น (ไม่บังคับ)',
|
||||
description: 'คำอธิบาย/สถานการณ์เพิ่มเติม (ไม่บังคับ)',
|
||||
channel: 'ช่องทาง',
|
||||
facebook: 'Facebook',
|
||||
line: 'LINE',
|
||||
language: 'ภาษา',
|
||||
thai: 'ไทย',
|
||||
english: 'อังกฤษ',
|
||||
personas: 'Persona',
|
||||
tierA: 'ระดับ A — ตั้งใจซื้อ',
|
||||
tierB: 'ระดับ B — ยังไม่แน่ใจ',
|
||||
tierC: 'ระดับ C — ไม่สนใจแต่มี pain',
|
||||
selectPersona: 'เลือก persona เพื่อฝึก',
|
||||
chat: 'แชท',
|
||||
start: 'เริ่ม',
|
||||
send: 'ส่ง',
|
||||
finish: 'สรุปผล',
|
||||
debrief: 'ผลลัพธ์และคำแนะนำ',
|
||||
won: 'ขายได้',
|
||||
lost: 'ขายไม่ได้',
|
||||
notTried: 'ยังไม่ได้ฝึก',
|
||||
score: 'คะแนน',
|
||||
pain: 'Pain',
|
||||
why: 'เหตุผล',
|
||||
reveal: 'ข้อมูล persona ที่ถูกซ่อนไว้',
|
||||
generatePersona: 'สร้าง persona ของฉัน',
|
||||
weakAreas: 'จุดที่ฉันแพ้บ่อย',
|
||||
mySessions: 'การฝึกของฉัน',
|
||||
openSaleTask: 'ลูกค้ายังไม่ได้ทักมา คุณต้องเป็นฝ่ายเปิดการขายเอง',
|
||||
sellerInitiated: 'คุณต้องเปิดการขาย (เชิงรุก)',
|
||||
customerInitiated: 'ลูกค้าจะทักมาเองก่อน',
|
||||
},
|
||||
}
|
||||
|
||||
export const i18n = reactive({
|
||||
locale: localStorage.getItem('locale') || 'th',
|
||||
t(key) {
|
||||
return (messages[this.locale] && messages[this.locale][key]) || messages.en[key] || key
|
||||
},
|
||||
set(locale) {
|
||||
this.locale = locale
|
||||
localStorage.setItem('locale', locale)
|
||||
},
|
||||
})
|
||||
|
||||
export function useT() {
|
||||
return (key) => i18n.t(key)
|
||||
}
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
37
frontend/src/router/index.js
Normal file
37
frontend/src/router/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { auth } from '../store/auth'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') },
|
||||
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
|
||||
{ path: '/my/sessions', component: () => import('../views/MySessions.vue') },
|
||||
{ path: '/my/weak-areas', component: () => import('../views/WeakAreas.vue') },
|
||||
{ path: '/my/generate', component: () => import('../views/GenPersona.vue') },
|
||||
{ path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } },
|
||||
{ path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } },
|
||||
{ path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } },
|
||||
{ path: '/admin/analytics', component: () => import('../views/Analytics.vue'), meta: { admin: true } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.meta.public) return true
|
||||
if (!auth.user) {
|
||||
await auth.load()
|
||||
}
|
||||
if (!auth.user) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.meta.admin && !auth.isAdmin) {
|
||||
return { path: '/' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
38
frontend/src/store/auth.js
Normal file
38
frontend/src/store/auth.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Auth + role store (reactive).
|
||||
import { reactive } from 'vue'
|
||||
import { getToken, setToken, api } from '../api'
|
||||
|
||||
export const auth = reactive({
|
||||
user: null,
|
||||
token: getToken(),
|
||||
get role() {
|
||||
return this.user ? this.user.role : null
|
||||
},
|
||||
get isAdmin() {
|
||||
return this.role === 'admin' || this.role === 'super_admin'
|
||||
},
|
||||
async load() {
|
||||
if (!this.token) return null
|
||||
try {
|
||||
const data = await api.me()
|
||||
this.user = data.user
|
||||
return this.user
|
||||
} catch (e) {
|
||||
this.user = null
|
||||
setToken(null)
|
||||
return null
|
||||
}
|
||||
},
|
||||
async login(email, password) {
|
||||
const data = await api.login(email, password)
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
this.user = data.user
|
||||
return data.user
|
||||
},
|
||||
logout() {
|
||||
this.user = null
|
||||
this.token = null
|
||||
setToken(null)
|
||||
},
|
||||
})
|
||||
73
frontend/src/style.css
Normal file
73
frontend/src/style.css
Normal file
@@ -0,0 +1,73 @@
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--border: #e5e8ef;
|
||||
--ink: #1a1d29;
|
||||
--muted: #6b7280;
|
||||
--accent: #4f46e5;
|
||||
--accent-2: #7c3aed;
|
||||
--green: #16a34a;
|
||||
--red: #dc2626;
|
||||
--amber: #d97706;
|
||||
--radius: 14px;
|
||||
--shadow: 0 1px 3px rgba(20, 24, 40, 0.08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans Thai', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
#app { min-height: 100vh; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
label { font-size: 13px; color: var(--muted); display: block; margin: 10px 0 4px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.A { background: #dcfce7; color: #166534; }
|
||||
.badge.B { background: #fef9c3; color: #854d0e; }
|
||||
.badge.C { background: #fee2e2; color: #991b1b; }
|
||||
.badge.won { background: #dcfce7; color: #166534; }
|
||||
.badge.lost { background: #fee2e2; color: #991b1b; }
|
||||
.badge.not_tried { background: #eef2ff; color: #4338ca; }
|
||||
.error { color: var(--red); font-size: 13px; }
|
||||
.muted { color: var(--muted); }
|
||||
.msg-seller { background: var(--accent); color: #fff; align-self: flex-end; border-radius: 16px 16px 4px 16px; }
|
||||
.msg-customer { background: #fff; align-self: flex-start; border-radius: 16px 16px 16px 4px; border: 1px solid var(--border); }
|
||||
46
frontend/src/views/AdminUsers.vue
Normal file
46
frontend/src/views/AdminUsers.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ i18n.t('users') }}</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h4>+ {{ i18n.t('create') }} user</h4>
|
||||
<div class="row">
|
||||
<input v-model="form.name" placeholder="Name" style="flex:1" />
|
||||
<input v-model="form.email" placeholder="Email" style="flex:1" />
|
||||
<input v-model="form.password" type="password" placeholder="Temp password" style="flex:1" />
|
||||
<select v-model="form.role" style="flex:1">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button class="primary" @click="create">{{ i18n.t('create') }}</button>
|
||||
</div>
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" v-for="u in users" :key="u.id" style="margin-bottom:8px;display:flex;align-items:center;gap:12px">
|
||||
<strong style="flex:1">{{ u.name }} ({{ u.email }})</strong>
|
||||
<span class="badge">{{ u.role }}</span>
|
||||
<span class="badge" :class="u.active ? 'won' : 'lost'">{{ u.active ? 'active' : 'inactive' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const users = ref([])
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', email: '', password: '', role: 'user' })
|
||||
|
||||
async function load() { users.value = (await api.adminListUsers()).users }
|
||||
async function create() {
|
||||
error.value = ''
|
||||
try {
|
||||
await api.adminCreateUser({ ...form.value })
|
||||
form.value = { name: '', email: '', password: '', role: 'user' }
|
||||
await load()
|
||||
} catch (e) { error.value = e.message }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
36
frontend/src/views/Analytics.vue
Normal file
36
frontend/src/views/Analytics.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ i18n.t('analytics') }}</h2>
|
||||
<div class="row" style="gap:16px;margin:16px 0">
|
||||
<div class="card stat"><div>Sessions</div><strong>{{ a.overall.total_sessions }}</strong></div>
|
||||
<div class="card stat"><div>Wins</div><strong style="color:var(--green)">{{ a.overall.wins }}</strong></div>
|
||||
<div class="card stat"><div>Losses</div><strong style="color:var(--red)">{{ a.overall.losses }}</strong></div>
|
||||
<div class="card stat"><div>Close rate</div><strong>{{ a.overall.close_rate }}%</strong></div>
|
||||
<div class="card stat"><div>Avg score</div><strong>{{ a.overall.avg_score }}</strong></div>
|
||||
</div>
|
||||
|
||||
<h3>Trainees: {{ a.trainee_count }}</h3>
|
||||
<h3>Hardest personas</h3>
|
||||
<div class="card" v-for="(p, i) in a.hardest_personas" :key="i" style="margin-bottom:8px">
|
||||
<strong>{{ p.persona_name }}</strong>
|
||||
<span class="badge lost">{{ p.losses }}L</span>
|
||||
<span class="badge won">{{ p.wins }}W</span>
|
||||
<span class="muted">· avg {{ p.avg_score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const a = ref({ overall: { total_sessions: 0, wins: 0, losses: 0, close_rate: 0, avg_score: 0 }, trainee_count: 0, hardest_personas: [] })
|
||||
onMounted(async () => { a.value = await api.analytics() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat { text-align: center; min-width: 110px; }
|
||||
.stat div { color: var(--muted); font-size: 12px; }
|
||||
.stat strong { font-size: 20px; }
|
||||
</style>
|
||||
136
frontend/src/views/Chat.vue
Normal file
136
frontend/src/views/Chat.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="align-items:center;margin-bottom:12px">
|
||||
<h2 style="margin:0">{{ persona ? persona.name : '...' }}</h2>
|
||||
<span class="badge" :class="persona && persona.channel">{{ persona ? persona.channel : '' }}</span>
|
||||
<span class="muted" v-if="persona">{{ persona.profession }} · {{ persona.age_group }}</span>
|
||||
<button class="danger" style="margin-left:auto" @click="finish" :disabled="messages.length === 0">
|
||||
{{ i18n.t('finish') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Seller-initiated task -->
|
||||
<div v-if="!started" class="card task" v-html="taskText"></div>
|
||||
|
||||
<!-- Chat thread -->
|
||||
<div class="thread" v-if="started" ref="thread">
|
||||
<div v-for="(m, i) in messages" :key="i" class="bubble" :class="m.role === 'seller' ? 'msg-seller' : 'msg-customer'">
|
||||
{{ m.text }}
|
||||
</div>
|
||||
<div v-if="sending" class="bubble msg-customer muted">...</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div v-if="started && !debrief" class="composer">
|
||||
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
|
||||
<button class="primary" @click="send" :disabled="sending || !text.trim()">{{ i18n.t('send') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Debrief overlay -->
|
||||
<div v-if="debrief" class="card debrief">
|
||||
<h3>{{ i18n.t('debrief') }}</h3>
|
||||
<p><span class="badge" :class="debrief.outcome">{{ debrief.outcome === 'won' ? i18n.t('won') : i18n.t('lost') }}</span>
|
||||
— {{ i18n.t('score') }}: <strong>{{ debrief.score }}</strong></p>
|
||||
<p><strong>{{ i18n.t('pain') }}:</strong> {{ debrief.pain || '—' }}</p>
|
||||
<p><strong>{{ i18n.t('why') }}:</strong> {{ debrief.why }}</p>
|
||||
<div v-if="debrief.coaching && debrief.coaching.length">
|
||||
<strong>Coaching:</strong>
|
||||
<ul><li v-for="(c, i) in debrief.coaching" :key="i">{{ c }}</li></ul>
|
||||
</div>
|
||||
<details>
|
||||
<summary>{{ i18n.t('reveal') }}</summary>
|
||||
<pre class="json">{{ JSON.stringify(debrief.revealed_persona, null, 2) }}</pre>
|
||||
</details>
|
||||
<router-link to="/"><button class="primary" style="margin-top:12px">{{ i18n.t('dashboard') }}</button></router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, nextTick, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const gid = route.params.gid
|
||||
const pid = route.params.pid
|
||||
|
||||
const persona = ref(null)
|
||||
const started = ref(false)
|
||||
const messages = ref([])
|
||||
const text = ref('')
|
||||
const sending = ref(false)
|
||||
const debrief = ref(null)
|
||||
const taskText = ref('')
|
||||
const sessionId = ref(null)
|
||||
|
||||
function scrollDown() {
|
||||
nextTick(() => {
|
||||
if (thread.value) thread.value.scrollTop = thread.value.scrollHeight
|
||||
})
|
||||
}
|
||||
const thread = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
persona.value = (await api.getPersona(gid, pid)).persona
|
||||
const res = await api.chatStart(gid, pid)
|
||||
sessionId.value = res.session.id
|
||||
if (res.session.task) {
|
||||
taskText.value = `📣 <strong>${i18n.t('sellerInitiated')}</strong><br/>${res.session.task}`
|
||||
}
|
||||
messages.value = res.session.messages || []
|
||||
started.value = true
|
||||
if (messages.value.length) scrollDown()
|
||||
})
|
||||
|
||||
async function send() {
|
||||
if (!text.value.trim()) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await api.chatSend(gid, pid, text.value.trim())
|
||||
messages.value = res.messages
|
||||
text.value = ''
|
||||
scrollDown()
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (!confirm(i18n.t('finish') + '?')) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await api.chatFinish(gid, pid)
|
||||
debrief.value = res.debrief
|
||||
messages.value = res.session.messages
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thread {
|
||||
background: #eceff4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
min-height: 320px;
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.bubble { max-width: 72%; padding: 10px 14px; white-space: pre-wrap; word-break: break-word; }
|
||||
.composer { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.task { margin-bottom: 12px; background: #fff7ed; border-color: #fed7aa; }
|
||||
.debrief { margin-top: 16px; }
|
||||
.json { background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px; font-size: 11px; overflow: auto; max-height: 260px; }
|
||||
button.danger { background: var(--red); color: #fff; border: none; }
|
||||
</style>
|
||||
64
frontend/src/views/Dashboard.vue
Normal file
64
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<h2 style="margin:0">{{ i18n.t('dashboard') }}</h2>
|
||||
<div style="margin-left:auto" v-if="auth.isAdmin">
|
||||
<router-link to="/admin/new-group"><button class="primary">{{ i18n.t('groupBuilder') }} +</button></router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="auth.isAdmin" style="gap:16px;margin-bottom:20px">
|
||||
<router-link to="/admin/users" style="text-decoration:none"><div class="card link-card">👥 {{ i18n.t('users') }}</div></router-link>
|
||||
<router-link to="/admin/analytics" style="text-decoration:none"><div class="card link-card">📊 {{ i18n.t('analytics') }}</div></router-link>
|
||||
</div>
|
||||
<div v-if="auth.role === 'user'" class="row" style="gap:16px;margin-bottom:20px">
|
||||
<router-link to="/my/sessions" style="text-decoration:none"><div class="card link-card">🎯 {{ i18n.t('myTraining') }}</div></router-link>
|
||||
<router-link to="/my/weak-areas" style="text-decoration:none"><div class="card link-card">⚠️ {{ i18n.t('weakAreas') }}</div></router-link>
|
||||
<router-link to="/my/generate" style="text-decoration:none"><div class="card link-card">✨ {{ i18n.t('generatePersona') }}</div></router-link>
|
||||
</div>
|
||||
|
||||
<h3>{{ i18n.t('groups') }}</h3>
|
||||
<div v-if="loading">...</div>
|
||||
<div v-else-if="groups.length === 0" class="card muted">—</div>
|
||||
<div class="grid">
|
||||
<div v-for="g in groups" :key="g.id" class="card group-card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>{{ g.title }}</strong>
|
||||
<span class="badge" :class="g.status">{{ g.status }}</span>
|
||||
</div>
|
||||
<div class="muted" style="margin:6px 0 12px">{{ (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || '' }}</div>
|
||||
<router-link v-if="auth.isAdmin" :to="`/admin/groups/${g.id}/edit`">
|
||||
<button>{{ i18n.t('personas') }} / {{ i18n.t('create') }}</button>
|
||||
</router-link>
|
||||
<router-link v-else-if="g.status === 'ready'" :to="`/groups/${g.id}/personas`">
|
||||
<button class="primary">{{ i18n.t('selectPersona') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const groups = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
groups.value = (await api.listGroups()).groups
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.group-card { display: flex; flex-direction: column; }
|
||||
.group-card a { margin-top: auto; }
|
||||
.link-card { text-align: center; min-width: 150px; }
|
||||
</style>
|
||||
77
frontend/src/views/GenPersona.vue
Normal file
77
frontend/src/views/GenPersona.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ i18n.t('generatePersona') }}</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<label>Mode</label>
|
||||
<div class="row">
|
||||
<button :class="{ active: mode === 'manual' }" @click="mode = 'manual'">✏️ {{ i18n.t('manual') }}</button>
|
||||
<button :class="{ active: mode === 'weak-area' }" @click="mode = 'weak-area'">🔒 Weak-area lock</button>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'manual'">
|
||||
<label>Describe the persona you want to practice against</label>
|
||||
<textarea v-model="spec" rows="4" placeholder="e.g. a price-hardball restaurant owner on LINE who stalls when I bring up costs"></textarea>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">The system will analyze your losses and auto-generate a harder persona targeting your weak points.</p>
|
||||
</template>
|
||||
|
||||
<button class="primary" style="margin-top:16px" :disabled="busy || (mode === 'manual' && !spec.trim())" @click="gen">
|
||||
{{ busy ? '...' : i18n.t('generatePersona') }}
|
||||
</button>
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<h3>My personas</h3>
|
||||
<div class="grid">
|
||||
<div v-for="p in mine" :key="p.id" class="card pcard">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="badge" :class="p.tier">Tier {{ p.tier }}</span>
|
||||
<div class="muted">{{ p.profession }} · {{ p.age_group }}</div>
|
||||
<router-link :to="`/groups/${myGid}/chat/${p.id}`" style="margin-top:auto">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const mode = ref(route.query.mode === 'weak' ? 'weak-area' : 'manual')
|
||||
const spec = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const mine = ref([])
|
||||
const myGid = ref(null)
|
||||
|
||||
async function loadMine() {
|
||||
const d = await api.myPersonas()
|
||||
myGid.value = d.group.id
|
||||
mine.value = d.personas
|
||||
}
|
||||
onMounted(loadMine)
|
||||
async function gen() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const body = mode.value === 'weak-area'
|
||||
? { mode: 'weak-area', spec: {} }
|
||||
: { mode: 'manual', spec: { description: spec.value } }
|
||||
await api.generatePersona(body)
|
||||
await loadMine()
|
||||
} catch (e) { error.value = e.message }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.pcard { display: flex; flex-direction: column; min-height: 140px; }
|
||||
</style>
|
||||
75
frontend/src/views/GroupBuilder.vue
Normal file
75
frontend/src/views/GroupBuilder.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2>{{ i18n.t('groupBuilder') }}</h2>
|
||||
<label>{{ i18n.t('product') }}</label>
|
||||
<textarea v-model="form.product" rows="3" placeholder="e.g. Cloud POS for small restaurants"></textarea>
|
||||
|
||||
<label>{{ i18n.t('segment') }}</label>
|
||||
<input v-model="form.segment" />
|
||||
|
||||
<label>{{ i18n.t('description') }}</label>
|
||||
<textarea v-model="form.description" rows="3"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<div style="flex:1">
|
||||
<label>{{ i18n.t('channel') }}</label>
|
||||
<select v-model="form.channel">
|
||||
<option value="facebook">{{ i18n.t('facebook') }}</option>
|
||||
<option value="line">{{ i18n.t('line') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<label>{{ i18n.t('language') }}</label>
|
||||
<select v-model="form.language">
|
||||
<option value="th">{{ i18n.t('thai') }}</option>
|
||||
<option value="en">{{ i18n.t('english') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>📎 Files (.pdf/.md/.txt) — {{ i18n.t('product') }} can come from here</label>
|
||||
<input type="file" multiple accept=".pdf,.md,.txt" @change="onFiles" />
|
||||
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
<button class="primary" style="margin-top:16px" :disabled="busy || (!form.product && !files.length)" @click="create">
|
||||
{{ busy ? '...' : i18n.t('create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({ product: '', segment: '', description: '', channel: 'facebook', language: 'th' })
|
||||
const files = ref([])
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
function onFiles(e) {
|
||||
files.value = Array.from(e.target.files || [])
|
||||
}
|
||||
async function create() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('product', form.value.product)
|
||||
fd.append('segment', form.value.segment)
|
||||
fd.append('description', form.value.description)
|
||||
fd.append('channel', form.value.channel)
|
||||
fd.append('language', form.value.language)
|
||||
files.value.forEach((f) => fd.append('files', f))
|
||||
const data = await api.createGroup(fd)
|
||||
const gid = data.group.id
|
||||
router.push(`/admin/groups/${gid}/edit`)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
frontend/src/views/GroupEdit.vue
Normal file
81
frontend/src/views/GroupEdit.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">{{ i18n.t('groupBuilder') }} — {{ group && group.title }}</h2>
|
||||
<button class="primary" style="margin-left:auto" @click="analyze" :disabled="busy">
|
||||
{{ busy ? '...' : i18n.t('analyze') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
|
||||
<div v-for="tier in ['A','B','C']" :key="tier" style="margin-bottom:20px">
|
||||
<h4>{{ tierLabel(tier) }}</h4>
|
||||
<div class="grid">
|
||||
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard">
|
||||
<div class="row">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="badge" v-if="p.special === 'wrong_text'">⚠️ wrong_text</span>
|
||||
</div>
|
||||
<div class="muted">{{ p.profession }} · {{ p.age_group }} · {{ p.channel }} · {{ p.initiation_mode }}</div>
|
||||
<div class="muted" style="margin-top:4px">diff {{ p.difficulty }} · {{ p.income }} · {{ p.personality }}</div>
|
||||
<details style="margin-top:8px" open>
|
||||
<summary>{{ i18n.t('reveal') }}</summary>
|
||||
<pre class="json">{{ JSON.stringify(p, null, 2) }}</pre>
|
||||
</details>
|
||||
<button @click="editPersona(p)">✏️ Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const gid = route.params.gid
|
||||
const group = ref(null)
|
||||
const personas = ref([])
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
const data = await api.getGroup(gid)
|
||||
group.value = data.group
|
||||
personas.value = (await api.listPersonas(gid)).personas
|
||||
}
|
||||
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
|
||||
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
|
||||
async function analyze() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.analyzeGroup(gid)
|
||||
personas.value = data.personas
|
||||
await load()
|
||||
} catch (e) { error.value = e.message }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function editPersona(p) {
|
||||
const json = prompt('Edit persona JSON (full fields):', JSON.stringify(p, null, 2))
|
||||
if (!json) return
|
||||
try {
|
||||
const parsed = JSON.parse(json)
|
||||
api.updatePersona(gid, p.id, parsed).then(load)
|
||||
} catch (e) { error.value = 'Invalid JSON: ' + e.message }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.pcard { display: flex; flex-direction: column; }
|
||||
.pcard button { margin-top: auto; }
|
||||
.json {
|
||||
background: #0f172a; color: #9ca3af; padding: 10px; border-radius: 8px;
|
||||
font-size: 11px; overflow: auto; max-height: 220px;
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/views/Login.vue
Normal file
48
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="login-wrap">
|
||||
<div class="card login-card">
|
||||
<h1>{{ i18n.t('app') }}</h1>
|
||||
<label>{{ i18n.t('email') }}</label>
|
||||
<input v-model="email" type="email" @keyup.enter="submit" />
|
||||
<label>{{ i18n.t('password') }}</label>
|
||||
<input v-model="password" type="password" @keyup.enter="submit" />
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
<button class="primary" style="width:100%;margin-top:16px" :disabled="loading" @click="submit">
|
||||
{{ loading ? '...' : i18n.t('login') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(email.value, password.value)
|
||||
router.push(route.query.redirect || '/')
|
||||
} catch (e) {
|
||||
error.value = i18n.t('loginError')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-wrap { display: flex; justify-content: center; padding-top: 10vh; }
|
||||
.login-card { width: 360px; }
|
||||
h1 { margin-top: 0; }
|
||||
</style>
|
||||
25
frontend/src/views/MySessions.vue
Normal file
25
frontend/src/views/MySessions.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>{{ i18n.t('myTraining') }}</h2>
|
||||
<div class="card" v-for="s in sessions" :key="s.id" style="margin-bottom:10px">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>{{ s.persona_name }}</strong>
|
||||
<span class="badge" :class="s.outcome || 'not_tried'">{{ s.outcome || '—' }}</span>
|
||||
</div>
|
||||
<div class="muted">{{ s.persona_id }} · {{ (new Date(s.created_at)).toLocaleString() }}</div>
|
||||
<div v-if="s.debrief" class="muted" style="margin-top:4px">
|
||||
Score {{ s.debrief.score }} — {{ s.debrief.why }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sessions.length === 0" class="card muted">—</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const sessions = ref([])
|
||||
onMounted(async () => { sessions.value = (await api.mySessions()).sessions })
|
||||
</script>
|
||||
58
frontend/src/views/Personas.vue
Normal file
58
frontend/src/views/Personas.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="align-items:center">
|
||||
<h2 style="margin:0">{{ i18n.t('personas') }}</h2>
|
||||
<span class="muted" style="margin-left:auto">Levels: choose one to practice (one-shot)</span>
|
||||
</div>
|
||||
|
||||
<div v-for="tier in ['A','B','C']" :key="tier" style="margin:20px 0">
|
||||
<h4>{{ tierLabel(tier) }}</h4>
|
||||
<div class="grid">
|
||||
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard">
|
||||
<div class="row">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="badge" :class="p.my_outcome">{{ outcomeLabel(p.my_outcome) }}</span>
|
||||
</div>
|
||||
<div class="muted">
|
||||
{{ p.profession }} · {{ p.age_group }} · {{ p.location }}<br />
|
||||
<span class="badge" :class="p.channel">{{ p.channel }}</span>
|
||||
<span class="muted"> · {{ p.initiation_mode === 'seller' ? i18n.t('sellerInitiated') : i18n.t('customerInitiated') }}</span>
|
||||
</div>
|
||||
<div class="muted" style="margin-top:6px">{{ p.product_context }}</div>
|
||||
<router-link v-if="p.my_outcome === 'not_tried'" :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:auto">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
|
||||
</router-link>
|
||||
<div v-else class="muted" style="margin-top:auto;font-size:12px">✓ Trained ({{ outcomeLabel(p.my_outcome) }})</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
const gid = route.params.gid
|
||||
const personas = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
try { personas.value = (await api.listPersonas(gid)).personas }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
|
||||
function tierLabel(t) { return i18n.t(t === 'A' ? 'tierA' : t === 'B' ? 'tierB' : 'tierC') }
|
||||
function outcomeLabel(o) {
|
||||
return o === 'won' ? i18n.t('won') : o === 'lost' ? i18n.t('lost') : i18n.t('notTried')
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; }
|
||||
.pcard { display: flex; flex-direction: column; min-height: 170px; }
|
||||
</style>
|
||||
39
frontend/src/views/WeakAreas.vue
Normal file
39
frontend/src/views/WeakAreas.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row" style="align-items:center">
|
||||
<h2 style="margin:0">{{ i18n.t('weakAreas') }}</h2>
|
||||
<router-link :to="`/my/generate?mode=weak`" style="margin-left:auto"><button class="primary">🔒 Generate a lock persona</button></router-link>
|
||||
</div>
|
||||
<div class="row" style="gap:16px;margin:16px 0">
|
||||
<div class="card stat"><div>Wins</div><strong>{{ insight.wins }}</strong></div>
|
||||
<div class="card stat"><div>Losses</div><strong>{{ insight.losses }}</strong></div>
|
||||
<div class="card stat"><div>Total</div><strong>{{ insight.total_sessions }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div v-for="g in insight.by_tier" :key="g.value" class="card" style="margin-bottom:8px">
|
||||
<span class="badge" :class="g.value">Tier {{ g.value }}</span> — {{ g.losses }} losses
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:20px">Top loss personas</h3>
|
||||
<div v-if="!insight.top_loss_personas || !insight.top_loss_personas.length" class="card muted">No losses yet — 🎉</div>
|
||||
<div class="card" v-for="(p, i) in insight.top_loss_personas" :key="i" style="margin-bottom:8px">
|
||||
<strong>{{ p.persona_name }}</strong> — score {{ p.score }}<br />
|
||||
<span class="muted">{{ p.why }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const insight = ref({ wins: 0, losses: 0, total_sessions: 0, by_tier: [], top_loss_personas: [] })
|
||||
onMounted(async () => { insight.value = (await api.weakAreas()).insight })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat { text-align: center; min-width: 100px; }
|
||||
.stat div { color: var(--muted); font-size: 12px; }
|
||||
.stat strong { font-size: 22px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user