ux(ui): readable persona form instead of raw JSON + step-by-step guides for non-IT users

- GroupEdit: replace raw JSON persona dump with a labeled, sectioned PersonaForm
  (ข้อมูลพื้นฐาน / ข้อมูลลูกค้า / การขาย / ช่องทาง) shown in a modal; admin edits by field.
- New reusable components/PersonaForm.vue.
- GroupBuilder + Personas: add friendly step-by-step guidance cards ('วิธีสร้าง',
  'วิธีฝึก') so admins/trainees know what to do at each step.
- Rebuild dist.
This commit is contained in:
Macky
2026-08-08 10:24:53 +07:00
parent 55759a3a50
commit 39d1aa04e4
25 changed files with 273 additions and 83 deletions

View File

@@ -0,0 +1,129 @@
<template>
<div class="persona-form">
<h3>{{ persona.name || 'New persona' }}</h3>
<p class="muted">{{ persona.profession }} · {{ persona.personality }}</p>
<!-- Section: อมลพนฐาน -->
<div class="sec">
<h4>👤 อมลพนฐาน</h4>
<div class="row">
<div class="f"><label></label><input v-model="d.name" /></div>
<div class="f"><label>ระดบความยาก (1-5)</label><input v-model.number="d.difficulty" type="number" min="1" max="5" /></div>
<div class="f"><label>ระดบลกค</label>
<select v-model="d.tier"><option value="A">A — พร้อมตัดสินใจซื้อ</option><option value="B">B — ยังไม่แน่ใจ</option><option value="C">C ไม่สนใจแต่มีปัญหา</option></select>
</div>
</div>
<div class="row">
<div class="f"><label>อาช</label><input v-model="d.profession" /></div>
<div class="f"><label>วงอาย</label><input v-model="d.age_group" /></div>
<div class="f"><label>นท</label><input v-model="d.location" /></div>
</div>
</div>
<!-- Section: อมลลกค (การขาย) -->
<div class="sec">
<h4>💼 อมลลกค</h4>
<div class="row">
<div class="f"><label>รายได/สถานะการเง</label><input v-model="d.income" /></div>
<div class="f"><label>งบประมาณ</label><input v-model="d.budget" /></div>
<div class="f"><label>ไลฟสไตล</label><input v-model="d.lifestyle" /></div>
</div>
<label>หล</label>
<textarea v-model="d.background"></textarea>
<label>คล / ดค</label>
<textarea v-model="d.personality"></textarea>
<label>สไตลการสอสาร</label>
<textarea v-model="d.communication_style"></textarea>
</div>
<!-- Section: การขาย -->
<div class="sec">
<h4>🎯 การขาย</h4>
<label>เปาหมาย</label><textarea v-model="d.goal"></textarea>
<label>กรอบเวลาในการตดสนใจ</label><input v-model="d.decision_timeline" />
<label>Pain (ญหา) แบบ 1 อบรรท</label>
<textarea v-model="painsText" placeholder="เช่น ต้นทุนสูงเกินไป&#10;ระบบล้าสมัย"></textarea>
<label>อโตแย (Objections) 1 อบรรท</label>
<textarea v-model="objectionsText"></textarea>
<label>งทใชอรอง (เช วนลด, ฟรดต)</label>
<textarea v-model="negLeversText"></textarea>
<label>องทางสำหรบลกคาเปดบทสนทนา (opener)</label>
<textarea v-model="d.opener"></textarea>
</div>
<!-- Section: ช่องทาง/โหมด -->
<div class="sec">
<h4>📡 องทาง & โหมด</h4>
<div class="row">
<div class="f"><label>องทาง</label>
<select v-model="d.channel"><option value="line">LINE</option><option value="facebook">Facebook</option></select>
</div>
<div class="f"><label>ใครเรมคยกอน</label>
<select v-model="d.initiation_mode"><option value="customer">ลูกค้าทักก่อน</option><option value="seller">เราต้องเริ่มขาย (เชิงรุก)</option></select>
</div>
</div>
<label class="muted" v-if="d.special">พิเศษ: {{ d.special }}</label>
</div>
<div class="row actions">
<button class="primary" @click="save" :disabled="busy">{{ busy ? 'กำลังบันทึก…' : '💾 บันทึกบุคคลต้นแบบ' }}</button>
<button @click="$emit('cancel')">ปิด</button>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
const props = defineProps({ persona: { type: Object, required: true } })
const emit = defineEmits(['save', 'cancel'])
const d = reactive({
name: '', difficulty: 1, tier: 'B', profession: '', age_group: '', location: '',
income: '', budget: '', lifestyle: '', background: '', personality: '',
communication_style: '', goal: '', decision_timeline: '', opener: '', channel: 'line',
initiation_mode: 'customer', special: '',
})
const painsText = ref('')
const objectionsText = ref('')
const negLeversText = ref('')
const busy = ref(false)
function toLines(a) { return Array.isArray(a) ? a.join('\n') : String(a || '') }
function fromLines(s) { return s.split('\n').map(x => x.trim()).filter(Boolean) }
watch(() => props.persona, (p) => {
Object.assign(d, {
name: p?.name || '', difficulty: p?.difficulty ?? 1, tier: p?.tier || 'B',
profession: p?.profession || '', age_group: p?.age_group || '', location: p?.location || '',
income: p?.income || '', budget: p?.budget || '', lifestyle: p?.lifestyle || '',
background: p?.background || '', personality: p?.personality || '',
communication_style: p?.communication_style || '', goal: p?.goal || '',
decision_timeline: p?.decision_timeline || '', opener: p?.opener || '',
channel: p?.channel || 'line', initiation_mode: p?.initiation_mode || 'customer',
special: p?.special || '',
})
painsText.value = toLines(p?.pains)
objectionsText.value = toLines(p?.objections)
negLeversText.value = toLines(p?.negotiation_levers)
}, { immediate: true })
function save() {
emit('save', {
...d,
pains: fromLines(painsText.value),
objections: fromLines(objectionsText.value),
negotiation_levers: fromLines(negLeversText.value),
})
}
</script>
<style scoped>
.sec { margin-top: 20px; }
.sec h4 { margin: 0 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.f { flex: 1; min-width: 140px; }
label { font-size: 13px; color: var(--muted); display: block; margin: 10px 0 4px; }
input, select, textarea { width: 100%; }
.actions { margin-top: 20px; }
</style>

View File

@@ -1,39 +1,53 @@
<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>
<div>
<router-link to="/training" class="btn-back"> {{ i18n.t('training') }}</router-link>
<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>
<!-- Step guidance -->
<div class="card guide">
<strong>📋 สรางกลมบคคลตนแบบ</strong>
<ol style="margin:8px 0 0;padding-left:20px;line-height:1.8">
<li>กรอก <strong>/รายละเอยดสนคาหรอบรการ</strong> อยากใหมฝกขาย (องดานลาง)</li>
<li>(ไมงค) ระบกลมลกคาเปาหมาย หรออปโหลดไฟลเอกสารสนค .pdf/.md/.txt</li>
<li>กด <strong>สราง</strong> ระบบจะพาไปหนาสรางบคคลตนแบบ (กคาจำลอง)</li>
</ol>
</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="card">
<h2 style="margin-top:0">📦 {{ i18n.t('addProduct') }}</h2>
<label>{{ i18n.t('product') }} <span class="req">*</span></label>
<textarea v-model="form.product" rows="3" placeholder="เช่น ระบบ POS สำหรับร้านอาหาร Cloud POS for small restaurants"></textarea>
<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>
<label>{{ i18n.t('segment') }}</label>
<input v-model="form.segment" placeholder="เช่น SMEs ร้านอาหารในกรุงเทพ" />
<label>{{ i18n.t('description') }}</label>
<textarea v-model="form.description" rows="3" placeholder="รายละเอียดเพิ่มเติม เช่น จุดเด่นสินค้า ราคา ฯลฯ (ไม่บังคับ)"></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>📎 ไฟลเอกสารสนค (.pdf/.md/.txt) ใชเปนขอมลใหระบบได</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 ? 'กำลังสร้าง' : ' สร้างกลุ่มบุคคลต้นแบบ' }}
</button>
</div>
</div>
</template>
@@ -73,3 +87,8 @@ async function create() {
}
}
</script>
<style scoped>
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 16px; }
.req { color: var(--red); }
</style>

View File

@@ -1,37 +1,58 @@
<template>
<div>
<router-link to="/" class="btn-back"> {{ i18n.t('dashboard') }}</router-link>
<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">
<span v-if="busy" class="spinner" style="margin-right:4px"></span>{{ i18n.t('analyze') }}
</button>
<router-link to="/training" class="btn-back"> {{ i18n.t('training') }}</router-link>
<div class="row" style="align-items:center;margin-bottom:8px">
<h2 style="margin:0">🛠 {{ i18n.t('managePersonas') }}</h2>
<div class="row" style="margin-left:auto;gap:10px">
<button @click="analyze" :disabled="busy" class="primary">
<span v-if="busy" class="spinner"></span>{{ busy ? 'กำลังสร้าง' : ' สร้าง/วิเคราะห์บุคคลต้นแบบ' }}
</button>
</div>
</div>
<p class="muted" v-if="group">สินค้า: <strong>{{ group.title }}</strong></p>
<div class="error" v-if="error" style="margin:12px 0">{{ error }}</div>
<!-- Step guidance -->
<div class="card guide">
<strong>ใชหนาน</strong>
<ol style="margin:8px 0 0;padding-left:20px;line-height:1.8">
<li>กดป <strong> สราง/เคราะหคคลตนแบบ</strong> เพอใหระบบสรางลกคาจำลอง 15 คน (ระด A/B/C)</li>
<li>แตละการดคอลกคาจำลอง 1 คน กด <strong> แกไข</strong> เพอปรบรายละเอยดใหตรงใจ</li>
<li>เมอพรอมใหใช กด <strong>เปดใชงาน</strong> (งทำในขนถดไป)</li>
</ol>
</div>
<div class="error" v-if="error">{{ error }}</div>
<div v-if="personas.length === 0 && !busy" class="card empty-state">
<strong>No personas yet</strong>
<span>Click {{ i18n.t('analyze') }} to generate the 15 personas (5 per tier).</span>
<strong>งไมคคลตนแบบ</strong>
<span>กด "✨ สร้าง/วิเคราะห์บุคคลต้นแบบ" เพอใหระบบสรางลกคาจำลองให</span>
</div>
<!-- Persona list -->
<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">
<div class="row" style="justify-content:space-between;align-items:center">
<strong>{{ p.name }}</strong>
<span class="badge" v-if="p.special === 'wrong_text'"> wrong_text</span>
<span class="badge" :class="tierClass(p.tier)">{{ p.tier }}</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 class="diff">
<span v-for="n in 5" :key="n" class="star" :class="{ on: n <= (p.difficulty || 1) }"></span>
</div>
<div class="muted">{{ p.profession }} · {{ p.age_group }} · {{ p.location }}</div>
<div class="muted">{{ p.channel }} · {{ p.initiation_mode === 'seller' ? 'เริ่มขายเอง' : 'ลูกค้าทักก่อน' }}</div>
<button class="edit-btn" @click="openEdit(p)"> แก้ไขรายละเอียด</button>
</div>
</div>
</div>
<!-- Edit modal -->
<div v-if="editing" class="modal-backdrop" @click.self="editing=null">
<div class="modal">
<PersonaForm :persona="editing" @save="savePersona" @cancel="editing=null" />
</div>
</div>
</div>
</template>
@@ -40,6 +61,7 @@ import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api'
import { i18n } from '../i18n'
import PersonaForm from '../components/PersonaForm.vue'
const route = useRoute()
const gid = route.params.gid
@@ -47,6 +69,7 @@ const group = ref(null)
const personas = ref([])
const busy = ref(false)
const error = ref('')
const editing = ref(null)
async function load() {
const data = await api.getGroup(gid)
@@ -55,9 +78,9 @@ async function load() {
}
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 tierClass(t) { return { A: 'tier-a', B: 'tier-b', C: 'tier-c' }[t] || '' }
async function analyze() {
busy.value = true
error.value = ''
busy.value = true; error.value = ''
try {
const data = await api.analyzeGroup(gid)
personas.value = data.personas
@@ -65,23 +88,29 @@ async function analyze() {
} 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
function openEdit(p) { editing.value = JSON.parse(JSON.stringify(p)) }
async function savePersona(payload) {
busy.value = true
try {
const parsed = JSON.parse(json)
api.updatePersona(gid, p.id, parsed).then(load)
} catch (e) { error.value = 'Invalid JSON: ' + e.message }
await api.updatePersona(gid, editing.value.id, payload)
editing.value = null
await load()
} catch (e) { error.value = e.message }
finally { busy.value = false }
}
onMounted(load)
</script>
<style scoped>
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 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;
}
.edit-btn { margin-top: auto; }
.star { color: #d8dbe3; }
.star.on { color: #f59e0b; }
.badge.tier-a { background: #dcfce7; color: #166534; }
.badge.tier-b { background: #fef9c3; color: #854d0e; }
.badge.tier-c { background: #fee2e2; color: #991b1b; }
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 16px; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.5); display: flex; justify-content: center; align-items: flex-start; padding: 40px 16px; z-index: 50; overflow: auto; }
.modal { background: #fff; border-radius: 14px; padding: 24px; width: 100%; max-width: 720px; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
</style>

View File

@@ -5,6 +5,14 @@
<h2 style="margin:0">{{ i18n.t('personas') }}</h2>
<span class="muted" style="margin-left:auto">{{ i18n.t('selectPersona') }}</span>
</div>
<div class="card guide" v-if="!auth.isAdmin">
<strong>🎯 </strong>
<ol style="margin:8px 0 0;padding-left:20px;line-height:1.8">
<li>เลอกลกคาจำลอง (คคลตนแบบ) คนหนงทอยากฝกดวย</li>
<li>ระด A ายส ระด C ยากส (จากดาว ความยาก)</li>
<li>กดปมสำหรบฝกแชทกบลกคาคนน (กไดคนละครงเทาน)</li>
</ol>
</div>
<div class="row" v-if="auth.isAdmin" style="margin:12px 0;gap:10px">
<router-link :to="`/admin/groups/${gid}/edit`"><button class="primary">🛠 {{ i18n.t('managePersonas') }}</button></router-link>
@@ -81,4 +89,5 @@ onMounted(load)
.badge.won { background: #dcfce7; color: #166534; }
.badge.lost { background: #fee2e2; color: #991b1b; }
.badge.not_tried { background: #eef2ff; color: #4338ca; }
.guide { background: #eef2ff; border-color: #c7d2fe; margin: 12px 0 16px; }
</style>