@picked/svelte 0.0.1-dev.1793758300441 → 0.0.1-dev.1793769511104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/plugins/devtools/DevToolsExperience.svelte +142 -30
- package/dist/plugins/devtools/customTools.d.ts +24 -5
- package/dist/plugins/devtools/customTools.js +1 -1
- package/dist/plugins/devtools/derive/guide-sections/guide-sections.js +1 -1
- package/dist/plugins/devtools/derive/index.d.ts +4 -0
- package/dist/plugins/devtools/derive/index.js +1 -1
- package/dist/plugins/devtools/derive/persona-builder/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/persona-builder/index.js +1 -0
- package/dist/plugins/devtools/derive/persona-builder/persona-builder.d.ts +177 -0
- package/dist/plugins/devtools/derive/persona-builder/persona-builder.js +1 -0
- package/dist/plugins/devtools/derive/personas/index.d.ts +3 -0
- package/dist/plugins/devtools/derive/personas/index.js +1 -0
- package/dist/plugins/devtools/derive/personas/personas.d.ts +197 -0
- package/dist/plugins/devtools/derive/personas/personas.js +1 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.d.ts +2 -0
- package/dist/plugins/devtools/derive/plain-language/plain-language.js +1 -1
- package/dist/plugins/devtools/index.d.ts +2 -1
- package/dist/plugins/devtools/options.d.ts +33 -1
- package/dist/plugins/devtools/options.js +1 -1
- package/dist/plugins/devtools/plugin.d.ts +2 -1
- package/dist/plugins/devtools/review/index.d.ts +5 -1
- package/dist/plugins/devtools/review/index.js +1 -1
- package/dist/plugins/devtools/review/personas/ChoiceButton.svelte +50 -0
- package/dist/plugins/devtools/review/personas/PersonaBuilder.svelte +450 -0
- package/dist/plugins/devtools/review/personas/PersonaCard.svelte +86 -0
- package/dist/plugins/devtools/review/personas/PersonasPanel.svelte +546 -0
- package/dist/plugins/devtools/review/personas/load-persona.d.ts +226 -0
- package/dist/plugins/devtools/review/personas/load-persona.js +1 -0
- package/dist/plugins/devtools/session/session.js +1 -1
- package/dist/plugins/devtools/sidebar/SidebarFooter.svelte +114 -0
- package/dist/plugins/devtools/sidebar/ToolIcon.svelte +32 -0
- package/dist/plugins/devtools/sidebar/nav.d.ts +19 -9
- package/dist/plugins/devtools/sidebar/nav.js +1 -1
- package/dist/plugins/devtools/ui/forms/TextField.svelte +33 -6
- package/package.json +2 -2
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* @file The Personas & Scenarios tab: customers and situations to try the journey as. The site's
|
|
4
|
+
* own come from the plugin's `personas` option. A reviewer makes theirs in this browser, either by
|
|
5
|
+
* answering the journey's questions here, screen by screen, or from where they are on the page.
|
|
6
|
+
* A persona keeps the answers a customer gives on fields they can see, the screens moved on from,
|
|
7
|
+
* the screen it stops on and the page it starts on. Loading one reloads that page clean and walks
|
|
8
|
+
* the journey with its answers, screen by screen, up to that screen.
|
|
9
|
+
*/
|
|
10
|
+
import type { FieldValue, Picked } from '@picked/client'
|
|
11
|
+
import { getStepFields } from '@picked/client/form'
|
|
12
|
+
import { Banner, Button, Card, EmptyState, RadioGroup, Stack, Text, TextField } from '../../ui'
|
|
13
|
+
import {
|
|
14
|
+
buildPersona,
|
|
15
|
+
customerAnswers,
|
|
16
|
+
deslug,
|
|
17
|
+
entryUrlOf,
|
|
18
|
+
isBlank,
|
|
19
|
+
passedScreens,
|
|
20
|
+
readSavedPersonas,
|
|
21
|
+
writeSavedPersonas,
|
|
22
|
+
type Persona,
|
|
23
|
+
type BuilderWalk,
|
|
24
|
+
type PersonaCapture,
|
|
25
|
+
type PersonaLanding,
|
|
26
|
+
type PersonaStorageGetter,
|
|
27
|
+
type RouteEntry,
|
|
28
|
+
type SafetyPosture,
|
|
29
|
+
} from '../../derive'
|
|
30
|
+
import type { JourneyView } from '../../session'
|
|
31
|
+
import { loadUrl, startPersonaLoad } from './load-persona'
|
|
32
|
+
import PersonaBuilder from './PersonaBuilder.svelte'
|
|
33
|
+
import PersonaCard from './PersonaCard.svelte'
|
|
34
|
+
|
|
35
|
+
interface PersonasPanelProps {
|
|
36
|
+
client: Picked
|
|
37
|
+
posture: SafetyPosture
|
|
38
|
+
/** The journey in front, which a new persona lands on. */
|
|
39
|
+
journey: JourneyView | null
|
|
40
|
+
/** The site's own personas. */
|
|
41
|
+
personas: readonly Persona[]
|
|
42
|
+
/** Every field a journey's screens ask, minus the code-owned ones. */
|
|
43
|
+
asked: ReadonlySet<string>
|
|
44
|
+
/** Every screen moved on from this session, in order. */
|
|
45
|
+
route: readonly RouteEntry[]
|
|
46
|
+
/** Experience keys the devtools never follow. */
|
|
47
|
+
hiddenKeys: ReadonlySet<string>
|
|
48
|
+
/** Keys a screen carries only for its code. */
|
|
49
|
+
codeOwned: ReadonlySet<string>
|
|
50
|
+
/** Screens that move on by themselves, and actions that open another journey. */
|
|
51
|
+
personaWalk: {
|
|
52
|
+
selfAdvancingSteps: ReadonlySet<string>
|
|
53
|
+
handoffs: Readonly<Record<string, string>>
|
|
54
|
+
}
|
|
55
|
+
/** How the last load's walk ended; `null` when no load ran on this page. */
|
|
56
|
+
walkNotice: Notice | null
|
|
57
|
+
onWalkNoticeDismiss: () => void
|
|
58
|
+
/** The storage saved personas live in. */
|
|
59
|
+
getStorage?: PersonaStorageGetter
|
|
60
|
+
/** The tab's storage, which carries a load across its reload. */
|
|
61
|
+
getSessionStorage?: () => Pick<Storage, 'setItem'>
|
|
62
|
+
/**
|
|
63
|
+
* Goes to a URL; a load reloads the page through it.
|
|
64
|
+
*
|
|
65
|
+
* @param url - The URL.
|
|
66
|
+
*/
|
|
67
|
+
navigate?: (url: string) => void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A banner's tone and words. */
|
|
71
|
+
interface Notice {
|
|
72
|
+
status: 'info' | 'success' | 'warning' | 'error'
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
client,
|
|
78
|
+
posture,
|
|
79
|
+
journey,
|
|
80
|
+
personas,
|
|
81
|
+
asked,
|
|
82
|
+
route,
|
|
83
|
+
hiddenKeys,
|
|
84
|
+
codeOwned,
|
|
85
|
+
personaWalk,
|
|
86
|
+
walkNotice,
|
|
87
|
+
onWalkNoticeDismiss,
|
|
88
|
+
getStorage = () => window.localStorage,
|
|
89
|
+
getSessionStorage = () => window.sessionStorage,
|
|
90
|
+
navigate = (url: string) => {
|
|
91
|
+
window.location.assign(url)
|
|
92
|
+
},
|
|
93
|
+
}: PersonasPanelProps = $props()
|
|
94
|
+
|
|
95
|
+
// SDK stores read straight off the prop: effector stores meet Svelte's store contract, so `$`
|
|
96
|
+
// subscribes, and `$derived` keeps it on whichever client the panel is given.
|
|
97
|
+
const responsesStore = $derived(client.responses.getAllStore)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* This browser's saved personas for the client's project.
|
|
101
|
+
*
|
|
102
|
+
* @returns {Persona[]} The saved personas, oldest first.
|
|
103
|
+
* @example readSaved()
|
|
104
|
+
*/
|
|
105
|
+
function readSaved(): Persona[] {
|
|
106
|
+
return readSavedPersonas(getStorage, client.projectId)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let saved = $state<Persona[]>(readSaved())
|
|
110
|
+
let creating = $state(false)
|
|
111
|
+
/** How a new persona is made: answered here, or taken from where the reviewer is. */
|
|
112
|
+
let mode = $state<'build' | 'capture'>('build')
|
|
113
|
+
let name = $state('')
|
|
114
|
+
let description = $state('')
|
|
115
|
+
let entryText = $state('')
|
|
116
|
+
let nameError = $state<string | null>(null)
|
|
117
|
+
let entryError = $state<string | null>(null)
|
|
118
|
+
/** The journey a build starts in, its draft answers, and its picks on the site's outcomes. */
|
|
119
|
+
let buildStart = $state('')
|
|
120
|
+
let buildAnswers = $state.raw<Record<string, FieldValue>>({})
|
|
121
|
+
let buildChoices = $state.raw<Record<string, boolean>>({})
|
|
122
|
+
let buildWalk = $state.raw<BuilderWalk | null>(null)
|
|
123
|
+
let notice = $state<Notice | null>(null)
|
|
124
|
+
let loadingId = $state<string | null>(null)
|
|
125
|
+
/** The answers Save would keep; `null` while they are read. */
|
|
126
|
+
let captured = $state<Record<string, FieldValue> | null>(null)
|
|
127
|
+
|
|
128
|
+
const isEmpty = $derived(personas.length === 0 && saved.length === 0)
|
|
129
|
+
const postureReason = $derived(posture.canEditAnswers ? undefined : (posture.reason ?? undefined))
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Why a persona's Load is withheld: the session's posture, or another persona loading.
|
|
133
|
+
*
|
|
134
|
+
* @param {Persona} persona - The persona on the card.
|
|
135
|
+
* @returns {string | undefined} The reason, or `undefined` while Load is allowed.
|
|
136
|
+
* @example loadReason(persona)
|
|
137
|
+
*/
|
|
138
|
+
function loadReason(persona: Persona): string | undefined {
|
|
139
|
+
if (postureReason !== undefined) return postureReason
|
|
140
|
+
return loadingId !== null && loadingId !== persona.id
|
|
141
|
+
? 'Another persona is loading.'
|
|
142
|
+
: undefined
|
|
143
|
+
}
|
|
144
|
+
const landing = $derived<PersonaLanding | null>(
|
|
145
|
+
journey === null
|
|
146
|
+
? null
|
|
147
|
+
: {
|
|
148
|
+
experienceKey: journey.experienceKey,
|
|
149
|
+
experienceName: journey.experienceName,
|
|
150
|
+
stepKey: journey.stepKey,
|
|
151
|
+
stepName: journey.stepName,
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
const buildSummary = $derived.by(() => {
|
|
155
|
+
if (buildWalk === null) return 'Working out the route…'
|
|
156
|
+
const count = Object.keys(buildWalk.answers).length
|
|
157
|
+
const answers = count === 1 ? '1 answer' : `${count} answers`
|
|
158
|
+
const stop = buildWalk.landing
|
|
159
|
+
if (stop === null) return `Saves ${answers}.`
|
|
160
|
+
const screen = stop.stepName ?? deslug(stop.stepKey ?? '')
|
|
161
|
+
return `Saves ${answers}. Loading it walks the journey to ${screen}.`
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const captureSummary = $derived.by(() => {
|
|
165
|
+
if (captured === null) return 'Reading your answers…'
|
|
166
|
+
const count = Object.keys(captured).length
|
|
167
|
+
const answers = count === 1 ? '1 answer' : `${count} answers`
|
|
168
|
+
if (landing === null)
|
|
169
|
+
return `Saves ${answers}. No journey is open, so it loads the answers only.`
|
|
170
|
+
const journeyName = landing.experienceName ?? deslug(landing.experienceKey)
|
|
171
|
+
if (landing.stepKey === null) return `Saves ${answers}. Loading it walks ${journeyName}.`
|
|
172
|
+
const screen = landing.stepName ?? deslug(landing.stepKey)
|
|
173
|
+
return `Saves ${answers}. Loading it walks ${journeyName} to ${screen}.`
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The answers a customer gave: held values of the fields a journey's screens ask, minus any whose
|
|
178
|
+
* own condition hides it now.
|
|
179
|
+
*
|
|
180
|
+
* @param {Readonly<Record<string, FieldValue>>} responses - Every held response.
|
|
181
|
+
* @returns {Promise<Record<string, FieldValue>>} The answers a persona keeps.
|
|
182
|
+
* @example await readCustomerAnswers(client.responses.getAllStore.getState())
|
|
183
|
+
*/
|
|
184
|
+
async function readCustomerAnswers(
|
|
185
|
+
responses: Readonly<Record<string, FieldValue>>,
|
|
186
|
+
): Promise<Record<string, FieldValue>> {
|
|
187
|
+
const answered = client.fields.listStore
|
|
188
|
+
.getState()
|
|
189
|
+
.filter((field) => asked.has(field.key) && !isBlank(responses[field.key]))
|
|
190
|
+
const visible = await getStepFields(client, answered)
|
|
191
|
+
return customerAnswers(responses, new Set(visible.map((field) => field.key)))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// While the form is open, keep what Save would keep in step with the answers.
|
|
195
|
+
$effect(() => {
|
|
196
|
+
if (!creating || mode !== 'capture') return
|
|
197
|
+
let current = true
|
|
198
|
+
void readCustomerAnswers($responsesStore).then((answers) => {
|
|
199
|
+
if (current) captured = answers
|
|
200
|
+
})
|
|
201
|
+
return () => {
|
|
202
|
+
current = false
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A new persona's id, unique in this browser.
|
|
208
|
+
*
|
|
209
|
+
* @returns {string} The id.
|
|
210
|
+
* @example newId() // 'persona-lx2k9-4f1a2b3c'
|
|
211
|
+
*/
|
|
212
|
+
function newId(): string {
|
|
213
|
+
const [entropy = 0] = crypto.getRandomValues(new Uint32Array(1))
|
|
214
|
+
return `persona-${Date.now().toString(36)}-${entropy.toString(16)}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The journey a build starts in: the one in front, else the first one open.
|
|
219
|
+
*
|
|
220
|
+
* @returns {string} Its experience key, or nothing when no journey is open.
|
|
221
|
+
* @example defaultStart() // 'needs-analysis'
|
|
222
|
+
*/
|
|
223
|
+
function defaultStart(): string {
|
|
224
|
+
if (journey !== null) return journey.experienceKey
|
|
225
|
+
const open = client.experienceInstances.listStore
|
|
226
|
+
.getState()
|
|
227
|
+
.find(
|
|
228
|
+
(instance) => instance.isOpenStore.getState() && !hiddenKeys.has(instance.experienceKey),
|
|
229
|
+
)
|
|
230
|
+
return open?.experienceKey ?? ''
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Opens the form that makes a persona: answered here on a fresh page, or from where the reviewer
|
|
235
|
+
* is once they have answered something on it.
|
|
236
|
+
*
|
|
237
|
+
* @example startCreate()
|
|
238
|
+
*/
|
|
239
|
+
function startCreate(): void {
|
|
240
|
+
const responses = client.responses.getAllStore.getState()
|
|
241
|
+
const answeredHere = [...asked].some((key) => !isBlank(responses[key]))
|
|
242
|
+
creating = true
|
|
243
|
+
mode = answeredHere ? 'capture' : 'build'
|
|
244
|
+
name = ''
|
|
245
|
+
description = ''
|
|
246
|
+
entryText = `${window.location.pathname}${window.location.search}`
|
|
247
|
+
nameError = null
|
|
248
|
+
entryError = null
|
|
249
|
+
notice = null
|
|
250
|
+
captured = null
|
|
251
|
+
buildStart = defaultStart()
|
|
252
|
+
buildAnswers = {}
|
|
253
|
+
buildChoices = {}
|
|
254
|
+
buildWalk = null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What Save keeps, from the build or from where the reviewer is.
|
|
259
|
+
*
|
|
260
|
+
* @param {string | null} entryUrl - The page a load starts on.
|
|
261
|
+
* @returns {Promise<PersonaCapture | null>} The capture, or `null` while the build's route is worked out.
|
|
262
|
+
* @example await capture('/compare')
|
|
263
|
+
*/
|
|
264
|
+
async function capture(entryUrl: string | null): Promise<PersonaCapture | null> {
|
|
265
|
+
const id = newId()
|
|
266
|
+
const savedAt = new Date().toISOString()
|
|
267
|
+
if (mode === 'build') {
|
|
268
|
+
if (buildWalk === null) return null
|
|
269
|
+
return {
|
|
270
|
+
responses: buildWalk.answers,
|
|
271
|
+
journey: buildWalk.landing,
|
|
272
|
+
passed: buildWalk.passed,
|
|
273
|
+
entryUrl: entryUrl ?? '',
|
|
274
|
+
id,
|
|
275
|
+
savedAt,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
responses: await readCustomerAnswers(client.responses.getAllStore.getState()),
|
|
281
|
+
journey: landing,
|
|
282
|
+
passed: passedScreens(route, hiddenKeys),
|
|
283
|
+
entryUrl: entryUrl ?? `${window.location.pathname}${window.location.search}`,
|
|
284
|
+
id,
|
|
285
|
+
savedAt,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Saves where the reviewer is as a new persona, in this browser.
|
|
291
|
+
*
|
|
292
|
+
* @returns {Promise<void>} Settles once it is saved or refused.
|
|
293
|
+
* @example await save()
|
|
294
|
+
*/
|
|
295
|
+
async function save(): Promise<void> {
|
|
296
|
+
const entryUrl = entryUrlOf(entryText, window.location.origin)
|
|
297
|
+
if (entryText.trim().length > 0 && entryUrl === null) {
|
|
298
|
+
entryError = 'Use a page on this site, such as /compare.'
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const kept = await capture(entryUrl)
|
|
303
|
+
if (kept === null) return
|
|
304
|
+
const result = buildPersona({ name, description }, kept)
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
nameError = result.error
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
saved = [...saved, result.persona]
|
|
311
|
+
creating = false
|
|
312
|
+
const stored = writeSavedPersonas(getStorage, client.projectId, saved)
|
|
313
|
+
notice = stored
|
|
314
|
+
? { status: 'success', text: `Saved ${result.persona.name}.` }
|
|
315
|
+
: {
|
|
316
|
+
status: 'warning',
|
|
317
|
+
text: `${result.persona.name} is here until you reload. This browser would not let it be saved.`,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Deletes a saved persona.
|
|
323
|
+
*
|
|
324
|
+
* @param {Persona} persona - The persona to delete.
|
|
325
|
+
* @example remove(persona)
|
|
326
|
+
*/
|
|
327
|
+
function remove(persona: Persona): void {
|
|
328
|
+
saved = saved.filter((entry) => entry.id !== persona.id)
|
|
329
|
+
writeSavedPersonas(getStorage, client.projectId, saved)
|
|
330
|
+
notice = { status: 'success', text: `Deleted ${persona.name}.` }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Loads a persona: the site's own loader when it has one, else a clean reload that walks its
|
|
335
|
+
* journey on the persona's page, keeping this page's query.
|
|
336
|
+
*
|
|
337
|
+
* @param {Persona} persona - The persona to load.
|
|
338
|
+
* @returns {Promise<void>} Settles once it has loaded, failed, or the reload is under way.
|
|
339
|
+
* @example await load(persona)
|
|
340
|
+
*/
|
|
341
|
+
async function load(persona: Persona): Promise<void> {
|
|
342
|
+
loadingId = persona.id
|
|
343
|
+
notice = null
|
|
344
|
+
onWalkNoticeDismiss()
|
|
345
|
+
try {
|
|
346
|
+
if (persona.load === null) {
|
|
347
|
+
const started = startPersonaLoad(client, persona, {
|
|
348
|
+
storage: getSessionStorage(),
|
|
349
|
+
url: loadUrl(persona.entryUrl, window.location),
|
|
350
|
+
navigate,
|
|
351
|
+
})
|
|
352
|
+
if (!started) {
|
|
353
|
+
notice = { status: 'error', text: 'This browser would not let the load start.' }
|
|
354
|
+
loadingId = null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
await persona.load(client)
|
|
361
|
+
notice = { status: 'success', text: `Loaded ${persona.name}.` }
|
|
362
|
+
loadingId = null
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const detail = error instanceof Error ? ` ${error.message}` : ''
|
|
365
|
+
notice = { status: 'error', text: `${persona.name} did not load.${detail}` }
|
|
366
|
+
loadingId = null
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
</script>
|
|
370
|
+
|
|
371
|
+
<div class="personas" data-testid="devtools-personas">
|
|
372
|
+
{#if walkNotice}
|
|
373
|
+
<Banner
|
|
374
|
+
status={walkNotice.status}
|
|
375
|
+
testId="devtools-persona-walk"
|
|
376
|
+
onDismiss={onWalkNoticeDismiss}
|
|
377
|
+
>
|
|
378
|
+
{walkNotice.text}
|
|
379
|
+
</Banner>
|
|
380
|
+
{/if}
|
|
381
|
+
|
|
382
|
+
{#if notice}
|
|
383
|
+
<Banner status={notice.status} onDismiss={() => (notice = null)}>{notice.text}</Banner>
|
|
384
|
+
{/if}
|
|
385
|
+
|
|
386
|
+
{#if creating}
|
|
387
|
+
<Card>
|
|
388
|
+
<div class="form">
|
|
389
|
+
<Stack direction="vertical" gap="medium">
|
|
390
|
+
<Text weight="semibold">New persona</Text>
|
|
391
|
+
<RadioGroup
|
|
392
|
+
label="Make it by"
|
|
393
|
+
name="persona-mode"
|
|
394
|
+
orientation="horizontal"
|
|
395
|
+
value={mode}
|
|
396
|
+
options={[
|
|
397
|
+
{ value: 'build', label: 'Answering the questions here' },
|
|
398
|
+
{ value: 'capture', label: 'Using where I am now' },
|
|
399
|
+
]}
|
|
400
|
+
onchange={(value) => (mode = value === 'capture' ? 'capture' : 'build')}
|
|
401
|
+
/>
|
|
402
|
+
<TextField
|
|
403
|
+
label="Name"
|
|
404
|
+
name="persona-name"
|
|
405
|
+
placeholder="Young family"
|
|
406
|
+
required
|
|
407
|
+
value={name}
|
|
408
|
+
error={nameError ?? undefined}
|
|
409
|
+
oninput={(value) => {
|
|
410
|
+
name = value
|
|
411
|
+
nameError = null
|
|
412
|
+
}}
|
|
413
|
+
/>
|
|
414
|
+
<TextField
|
|
415
|
+
label="Description"
|
|
416
|
+
name="persona-description"
|
|
417
|
+
helpText="Who they are, or what they test, in a sentence."
|
|
418
|
+
value={description}
|
|
419
|
+
oninput={(value) => (description = value)}
|
|
420
|
+
/>
|
|
421
|
+
<TextField
|
|
422
|
+
label="Starts on"
|
|
423
|
+
name="persona-entry-url"
|
|
424
|
+
helpText="The page a load opens. Keep a campaign page's address to load it as that campaign."
|
|
425
|
+
value={entryText}
|
|
426
|
+
error={entryError ?? undefined}
|
|
427
|
+
oninput={(value) => {
|
|
428
|
+
entryText = value
|
|
429
|
+
entryError = null
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
{#if mode === 'build'}
|
|
433
|
+
<PersonaBuilder
|
|
434
|
+
{client}
|
|
435
|
+
start={buildStart}
|
|
436
|
+
answers={buildAnswers}
|
|
437
|
+
choices={buildChoices}
|
|
438
|
+
entryUrl={entryUrlOf(entryText, window.location.origin) ?? window.location.pathname}
|
|
439
|
+
{asked}
|
|
440
|
+
{codeOwned}
|
|
441
|
+
selfAdvancing={personaWalk.selfAdvancingSteps}
|
|
442
|
+
handoffs={personaWalk.handoffs}
|
|
443
|
+
{hiddenKeys}
|
|
444
|
+
onStartChange={(key) => (buildStart = key)}
|
|
445
|
+
onAnswersChange={(next) => (buildAnswers = next)}
|
|
446
|
+
onChoicesChange={(next) => (buildChoices = next)}
|
|
447
|
+
onWalk={(next) => (buildWalk = next)}
|
|
448
|
+
/>
|
|
449
|
+
{/if}
|
|
450
|
+
<Text size="small" color="subdued">
|
|
451
|
+
{mode === 'build' ? buildSummary : captureSummary}
|
|
452
|
+
</Text>
|
|
453
|
+
<div class="form-actions">
|
|
454
|
+
<Button variant="primary" size="small" onclick={() => void save()}>Save</Button>
|
|
455
|
+
<Button variant="ghost" size="small" onclick={() => (creating = false)}>Cancel</Button>
|
|
456
|
+
</div>
|
|
457
|
+
</Stack>
|
|
458
|
+
</div>
|
|
459
|
+
</Card>
|
|
460
|
+
{:else if isEmpty}
|
|
461
|
+
<div class="empty">
|
|
462
|
+
<EmptyState
|
|
463
|
+
message="No personas or scenarios yet"
|
|
464
|
+
hint="Walk the journey the way a customer would, then save it here to load it again in one click."
|
|
465
|
+
/>
|
|
466
|
+
<Button variant="primary" onclick={startCreate}>Create</Button>
|
|
467
|
+
</div>
|
|
468
|
+
{:else}
|
|
469
|
+
<div class="toolbar">
|
|
470
|
+
<Button variant="secondary" size="small" onclick={startCreate}>Create</Button>
|
|
471
|
+
</div>
|
|
472
|
+
{/if}
|
|
473
|
+
|
|
474
|
+
{#if personas.length > 0}
|
|
475
|
+
<section class="group" aria-label="For this site">
|
|
476
|
+
<h3 class="group-title">For this site</h3>
|
|
477
|
+
{#each personas as persona (persona.id)}
|
|
478
|
+
<PersonaCard
|
|
479
|
+
{persona}
|
|
480
|
+
loadReason={loadReason(persona)}
|
|
481
|
+
loading={loadingId === persona.id}
|
|
482
|
+
onLoad={() => void load(persona)}
|
|
483
|
+
/>
|
|
484
|
+
{/each}
|
|
485
|
+
</section>
|
|
486
|
+
{/if}
|
|
487
|
+
|
|
488
|
+
{#if saved.length > 0}
|
|
489
|
+
<section class="group" aria-label="Saved in this browser">
|
|
490
|
+
<h3 class="group-title">Saved in this browser</h3>
|
|
491
|
+
{#each saved as persona (persona.id)}
|
|
492
|
+
<PersonaCard
|
|
493
|
+
{persona}
|
|
494
|
+
loadReason={loadReason(persona)}
|
|
495
|
+
loading={loadingId === persona.id}
|
|
496
|
+
onLoad={() => void load(persona)}
|
|
497
|
+
onDelete={() => remove(persona)}
|
|
498
|
+
/>
|
|
499
|
+
{/each}
|
|
500
|
+
</section>
|
|
501
|
+
{/if}
|
|
502
|
+
</div>
|
|
503
|
+
|
|
504
|
+
<style>
|
|
505
|
+
.personas {
|
|
506
|
+
display: flex;
|
|
507
|
+
flex-direction: column;
|
|
508
|
+
gap: var(--space-4);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.form {
|
|
512
|
+
max-width: 36rem;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.empty {
|
|
516
|
+
display: flex;
|
|
517
|
+
flex-direction: column;
|
|
518
|
+
align-items: center;
|
|
519
|
+
gap: var(--space-3);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.toolbar,
|
|
523
|
+
.form-actions {
|
|
524
|
+
display: flex;
|
|
525
|
+
gap: var(--space-2);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.toolbar {
|
|
529
|
+
justify-content: flex-end;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.group {
|
|
533
|
+
display: flex;
|
|
534
|
+
flex-direction: column;
|
|
535
|
+
gap: var(--space-2);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.group-title {
|
|
539
|
+
margin: 0;
|
|
540
|
+
font-size: var(--text-xs);
|
|
541
|
+
font-weight: var(--font-semibold);
|
|
542
|
+
letter-spacing: 0.04em;
|
|
543
|
+
text-transform: uppercase;
|
|
544
|
+
color: var(--color-gray-500);
|
|
545
|
+
}
|
|
546
|
+
</style>
|