| 1 |
import { CANVAS_CONFETTI, THROW } from '@agent/lib/confetti'; |
| 2 |
import { useCanvasStore } from '@agent/state/canvas'; |
| 3 |
import { useEffect, useRef, useState } from '@wordpress/element'; |
| 4 |
import { __ } from '@wordpress/i18n'; |
| 5 |
import { check, chevronDown, Icon } from '@wordpress/icons'; |
| 6 |
import { useReducedMotion } from 'framer-motion'; |
| 7 |
|
| 8 |
// The step settles before the scroll moves the stack, or the two read as one blur. |
| 9 |
const DISABLE = 0.3; |
| 10 |
const CLOSE_DELAY = 3000; |
| 11 |
const SCROLL_TIME = 400; |
| 12 |
const STEP_INSET = 48; |
| 13 |
|
| 14 |
const scrollBox = (el) => { |
| 15 |
for (let node = el?.parentElement; node; node = node.parentElement) { |
| 16 |
const scrollable = /(auto|scroll)/.test(getComputedStyle(node).overflowY); |
| 17 |
if (scrollable && node.scrollHeight > node.clientHeight) return node; |
| 18 |
} |
| 19 |
return null; |
| 20 |
}; |
| 21 |
|
| 22 |
// Native smooth scroll times by distance, so long and short advances differ in speed. |
| 23 |
const scrollToTop = (card, instant) => { |
| 24 |
const box = scrollBox(card); |
| 25 |
if (!box) return () => {}; |
| 26 |
const from = box.scrollTop; |
| 27 |
const to = |
| 28 |
from + |
| 29 |
card.getBoundingClientRect().top - |
| 30 |
box.getBoundingClientRect().top - |
| 31 |
STEP_INSET; |
| 32 |
if (instant) { |
| 33 |
box.scrollTop = to; |
| 34 |
return () => {}; |
| 35 |
} |
| 36 |
const start = performance.now(); |
| 37 |
let frame = requestAnimationFrame(function step(now) { |
| 38 |
const progress = Math.min((now - start) / SCROLL_TIME, 1); |
| 39 |
box.scrollTop = from + (to - from) * (1 - (1 - progress) ** 3); |
| 40 |
if (progress < 1) frame = requestAnimationFrame(step); |
| 41 |
}); |
| 42 |
return () => cancelAnimationFrame(frame); |
| 43 |
}; |
| 44 |
|
| 45 |
export const steps = [ |
| 46 |
{ |
| 47 |
id: 'business', |
| 48 |
title: __('Business details', 'extendify-local'), |
| 49 |
inputSchema: { |
| 50 |
type: 'object', |
| 51 |
properties: { |
| 52 |
businessName: { |
| 53 |
type: 'string', |
| 54 |
description: __('Business name', 'extendify-local'), |
| 55 |
}, |
| 56 |
tagline: { |
| 57 |
type: 'string', |
| 58 |
// translators: Form field label. A tagline is the short line describing what a business does. |
| 59 |
description: __('Tagline', 'extendify-local'), |
| 60 |
}, |
| 61 |
contactEmail: { |
| 62 |
type: 'string', |
| 63 |
format: 'email', |
| 64 |
description: __('Contact email', 'extendify-local'), |
| 65 |
}, |
| 66 |
phone: { |
| 67 |
type: 'string', |
| 68 |
description: __('Phone number', 'extendify-local'), |
| 69 |
}, |
| 70 |
}, |
| 71 |
}, |
| 72 |
}, |
| 73 |
{ |
| 74 |
id: 'content', |
| 75 |
title: __('Content and audience', 'extendify-local'), |
| 76 |
inputSchema: { |
| 77 |
type: 'object', |
| 78 |
properties: { |
| 79 |
postCount: { |
| 80 |
type: 'string', |
| 81 |
description: __('Published posts', 'extendify-local'), |
| 82 |
}, |
| 83 |
topics: { |
| 84 |
type: 'string', |
| 85 |
// translators: Form field label. The subjects this website writes about. |
| 86 |
description: __('Main topics', 'extendify-local'), |
| 87 |
'x-widget': 'textarea', |
| 88 |
}, |
| 89 |
latestPost: { |
| 90 |
type: 'string', |
| 91 |
description: __('Most recent post', 'extendify-local'), |
| 92 |
}, |
| 93 |
cadence: { |
| 94 |
type: 'string', |
| 95 |
// translators: Form field label. How often the website publishes something new. |
| 96 |
description: __('Publishing cadence', 'extendify-local'), |
| 97 |
enum: [ |
| 98 |
__('Weekly', 'extendify-local'), |
| 99 |
__('Monthly', 'extendify-local'), |
| 100 |
__('A few times a year', 'extendify-local'), |
| 101 |
__('Not publishing right now', 'extendify-local'), |
| 102 |
], |
| 103 |
}, |
| 104 |
}, |
| 105 |
}, |
| 106 |
}, |
| 107 |
{ |
| 108 |
id: 'technical', |
| 109 |
title: __('Technical', 'extendify-local'), |
| 110 |
inputSchema: { |
| 111 |
type: 'object', |
| 112 |
properties: { |
| 113 |
plugins: { |
| 114 |
type: 'string', |
| 115 |
description: __('Plugins in use', 'extendify-local'), |
| 116 |
'x-widget': 'textarea', |
| 117 |
}, |
| 118 |
theme: { |
| 119 |
type: 'string', |
| 120 |
description: __('Active theme', 'extendify-local'), |
| 121 |
}, |
| 122 |
sellsOnline: { |
| 123 |
type: 'string', |
| 124 |
// translators: Form field label. Whether this website sells anything online. |
| 125 |
description: __('Sells online', 'extendify-local'), |
| 126 |
enum: [__('Yes', 'extendify-local'), __('No', 'extendify-local')], |
| 127 |
}, |
| 128 |
contactForm: { |
| 129 |
type: 'string', |
| 130 |
description: __('Contact form plugin', 'extendify-local'), |
| 131 |
}, |
| 132 |
}, |
| 133 |
}, |
| 134 |
}, |
| 135 |
]; |
| 136 |
|
| 137 |
// The disabled colors land the moment the attribute flips, so without this the |
| 138 |
// fill jumps a frame before the step's fade even starts. |
| 139 |
const inputClass = |
| 140 |
'w-full rounded-sm border border-gray-300 bg-white p-2 text-sm text-gray-900 transition-colors duration-300 disabled:border-gray-200 disabled:bg-gray-100 disabled:text-gray-700'; |
| 141 |
|
| 142 |
// JSON Schema has no way to say a string wants a textarea. |
| 143 |
const Field = ({ name, property, value, onChange }) => { |
| 144 |
const id = `extendify-demo-form-${name}`; |
| 145 |
const props = { |
| 146 |
id, |
| 147 |
className: inputClass, |
| 148 |
value, |
| 149 |
onChange: (event) => onChange(event.target.value), |
| 150 |
}; |
| 151 |
|
| 152 |
return ( |
| 153 |
<div className="flex flex-col gap-1 text-sm text-gray-900"> |
| 154 |
<label htmlFor={id}>{property.description}</label> |
| 155 |
{property['x-widget'] === 'textarea' ? ( |
| 156 |
<textarea {...props} rows={3} /> |
| 157 |
) : property.enum ? ( |
| 158 |
<div className="relative"> |
| 159 |
{/* wp-admin gives selects their own caret and a 25rem width cap. */} |
| 160 |
<select |
| 161 |
{...props} |
| 162 |
className={`${inputClass} max-w-none appearance-none bg-none pe-8`} |
| 163 |
> |
| 164 |
<option value="" /> |
| 165 |
{property.enum.map((option) => ( |
| 166 |
<option key={option} value={option}> |
| 167 |
{option} |
| 168 |
</option> |
| 169 |
))} |
| 170 |
</select> |
| 171 |
<Icon |
| 172 |
className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 fill-current text-gray-700" |
| 173 |
icon={chevronDown} |
| 174 |
size={20} |
| 175 |
/> |
| 176 |
</div> |
| 177 |
) : ( |
| 178 |
<input |
| 179 |
{...props} |
| 180 |
type={property.format === 'email' ? 'email' : 'text'} |
| 181 |
/> |
| 182 |
)} |
| 183 |
</div> |
| 184 |
); |
| 185 |
}; |
| 186 |
|
| 187 |
const StepBadge = ({ index, active, done }) => { |
| 188 |
const tone = done |
| 189 |
? 'bg-design-main text-white' |
| 190 |
: active |
| 191 |
? 'bg-gray-900 text-white' |
| 192 |
: 'bg-gray-200 text-gray-700'; |
| 193 |
|
| 194 |
return ( |
| 195 |
<span |
| 196 |
className={`flex h-5 w-5 items-center justify-center rounded-full text-xss ${tone}`} |
| 197 |
> |
| 198 |
{done ? ( |
| 199 |
<Icon className="fill-current" icon={check} size={14} /> |
| 200 |
) : ( |
| 201 |
index + 1 |
| 202 |
)} |
| 203 |
</span> |
| 204 |
); |
| 205 |
}; |
| 206 |
|
| 207 |
const StepCard = ({ step, index, activeStep, isLast, onFinish }) => { |
| 208 |
const { values, setValue, goToStep, reached } = useCanvasStore(); |
| 209 |
const reduceMotion = useReducedMotion(); |
| 210 |
const card = useRef(null); |
| 211 |
const active = index === activeStep; |
| 212 |
const done = index < activeStep; |
| 213 |
const opened = index <= reached; |
| 214 |
|
| 215 |
useEffect(() => { |
| 216 |
if (!active) return; |
| 217 |
let cancelScroll = () => {}; |
| 218 |
const timer = setTimeout(() => { |
| 219 |
cancelScroll = scrollToTop(card.current, reduceMotion); |
| 220 |
}, DISABLE * 1000); |
| 221 |
return () => { |
| 222 |
clearTimeout(timer); |
| 223 |
cancelScroll(); |
| 224 |
}; |
| 225 |
}, [active, reduceMotion]); |
| 226 |
|
| 227 |
return ( |
| 228 |
<div |
| 229 |
ref={card} |
| 230 |
className={`overflow-hidden rounded-lg border border-solid bg-gray-50 transition-shadow duration-300 ${active ? 'border-gray-300 shadow-md' : 'border-gray-200 shadow-none'}`} |
| 231 |
> |
| 232 |
{/* Fading the card instead shows the dotted ground through the input fields. */} |
| 233 |
<form |
| 234 |
className={`transition-opacity duration-300 ${active ? 'opacity-100' : 'opacity-40'}`} |
| 235 |
onSubmit={(event) => { |
| 236 |
event.preventDefault(); |
| 237 |
if (isLast) return onFinish(); |
| 238 |
goToStep(index + 1); |
| 239 |
}} |
| 240 |
> |
| 241 |
<fieldset |
| 242 |
disabled={!active} |
| 243 |
className="m-0 flex min-w-0 flex-col border-0 p-0" |
| 244 |
> |
| 245 |
<div className="rounded-lg border-0 border-b border-solid border-gray-300 bg-white"> |
| 246 |
<div className="flex items-center gap-2 border-0 border-b border-solid border-gray-200 px-4 py-3 text-sm font-medium text-gray-900"> |
| 247 |
<StepBadge index={index} active={active} done={done} /> |
| 248 |
{step.title} |
| 249 |
</div> |
| 250 |
{opened ? ( |
| 251 |
<div className="flex min-w-0 flex-col gap-4 p-4"> |
| 252 |
{Object.entries(step.inputSchema.properties).map( |
| 253 |
([name, property]) => ( |
| 254 |
<Field |
| 255 |
key={name} |
| 256 |
name={name} |
| 257 |
property={property} |
| 258 |
value={values[name] ?? ''} |
| 259 |
onChange={(value) => setValue(name, value)} |
| 260 |
/> |
| 261 |
), |
| 262 |
)} |
| 263 |
</div> |
| 264 |
) : null} |
| 265 |
</div> |
| 266 |
{opened ? ( |
| 267 |
<div className="flex justify-end gap-2 p-3"> |
| 268 |
{index > 0 ? ( |
| 269 |
<button |
| 270 |
type="button" |
| 271 |
className="rounded-sm border border-gray-500 bg-white px-4 py-2 text-sm text-gray-900" |
| 272 |
onClick={() => goToStep(index - 1)} |
| 273 |
> |
| 274 |
{__('Back', 'extendify-local')} |
| 275 |
</button> |
| 276 |
) : null} |
| 277 |
<button |
| 278 |
type="submit" |
| 279 |
className="rounded-sm border border-design-main bg-design-main px-4 py-2 text-sm text-white" |
| 280 |
> |
| 281 |
{isLast |
| 282 |
? __('Submit', 'extendify-local') |
| 283 |
: __('Next', 'extendify-local')} |
| 284 |
</button> |
| 285 |
</div> |
| 286 |
) : null} |
| 287 |
</fieldset> |
| 288 |
</form> |
| 289 |
</div> |
| 290 |
); |
| 291 |
}; |
| 292 |
|
| 293 |
const Submitted = () => ( |
| 294 |
<div className="flex flex-col items-center gap-2 rounded-lg border border-solid border-gray-300 bg-white p-8 text-sm text-gray-900"> |
| 295 |
<span aria-hidden className="text-2xl leading-none"> |
| 296 |
🎉 |
| 297 |
</span> |
| 298 |
<span className="font-medium"> |
| 299 |
{__('Form submitted', 'extendify-local')} |
| 300 |
</span> |
| 301 |
<span className="text-gray-700"> |
| 302 |
{__('This was a demo. Nothing saved.', 'extendify-local')} |
| 303 |
</span> |
| 304 |
<span className="text-gray-700"> |
| 305 |
{__('Closing this in a moment…', 'extendify-local')} |
| 306 |
</span> |
| 307 |
</div> |
| 308 |
); |
| 309 |
|
| 310 |
export const DemoForm = ({ onConfirm }) => { |
| 311 |
// Local state would leave the agent unable to read or write these. |
| 312 |
const { activeStep, startSession } = useCanvasStore(); |
| 313 |
const [submitted, setSubmitted] = useState(false); |
| 314 |
|
| 315 |
// A layout swap remounts the form, and re-seeding wipes what was typed. |
| 316 |
useEffect(() => { |
| 317 |
if (!useCanvasStore.getState().steps.length) startSession(steps); |
| 318 |
}, [startSession]); |
| 319 |
|
| 320 |
useEffect(() => { |
| 321 |
if (!submitted) return; |
| 322 |
window.dispatchEvent(new CustomEvent(CANVAS_CONFETTI, { detail: THROW })); |
| 323 |
const timer = setTimeout( |
| 324 |
() => onConfirm({ data: useCanvasStore.getState().values }), |
| 325 |
CLOSE_DELAY, |
| 326 |
); |
| 327 |
return () => clearTimeout(timer); |
| 328 |
}, [submitted, onConfirm]); |
| 329 |
|
| 330 |
if (submitted) { |
| 331 |
return ( |
| 332 |
<div className="flex min-h-full items-center justify-center"> |
| 333 |
<Submitted /> |
| 334 |
</div> |
| 335 |
); |
| 336 |
} |
| 337 |
|
| 338 |
return ( |
| 339 |
<div className="flex flex-col gap-3 pb-[200px]"> |
| 340 |
{steps.map((step, index) => ( |
| 341 |
<StepCard |
| 342 |
key={step.id} |
| 343 |
step={step} |
| 344 |
index={index} |
| 345 |
activeStep={activeStep} |
| 346 |
isLast={index === steps.length - 1} |
| 347 |
onFinish={() => setSubmitted(true)} |
| 348 |
/> |
| 349 |
))} |
| 350 |
</div> |
| 351 |
); |
| 352 |
}; |
| 353 |
|