PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Agent / workflows / canvas / components / DemoForm.jsx

DemoForm.jsx in Extendify 3.2.1, at src/Agent/workflows/canvas/components/DemoForm.jsx

353 lines 9.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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