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:
Macky
2026-08-07 15:31:06 +07:00
commit c3d31c06e2
70 changed files with 6135 additions and 0 deletions

53
frontend/src/App.vue Normal file
View 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
View 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
View 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
View 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')

View 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

View 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
View 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); }

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>