PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.1.5, at src/Agent/workflows/canvas/components/DemoForm.jsx

348 lines 9.8 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 } = useCanvasStore();
209 const reduceMotion = useReducedMotion();
210 const card = useRef(null);
211 const active = index === activeStep;
212 const done = index < activeStep;
213
214 useEffect(() => {
215 if (!active) return;
216 let cancelScroll = () => {};
217 const timer = setTimeout(() => {
218 cancelScroll = scrollToTop(card.current, reduceMotion);
219 }, DISABLE * 1000);
220 return () => {
221 clearTimeout(timer);
222 cancelScroll();
223 };
224 }, [active, reduceMotion]);
225
226 return (
227 <div
228 ref={card}
229 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'}`}
230 >
231 {/* Fading the card instead shows the dotted ground through the input fields. */}
232 <form
233 className={`transition-opacity duration-300 ${active ? 'opacity-100' : 'opacity-40'}`}
234 onSubmit={(event) => {
235 event.preventDefault();
236 if (isLast) return onFinish();
237 goToStep(index + 1);
238 }}
239 >
240 <fieldset
241 disabled={!active}
242 className="m-0 flex min-w-0 flex-col border-0 p-0"
243 >
244 <div className="rounded-lg border-0 border-b border-solid border-gray-300 bg-white">
245 <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">
246 <StepBadge index={index} active={active} done={done} />
247 {step.title}
248 </div>
249 <div className="flex min-w-0 flex-col gap-4 p-4">
250 {Object.entries(step.inputSchema.properties).map(
251 ([name, property]) => (
252 <Field
253 key={name}
254 name={name}
255 property={property}
256 value={values[name] ?? ''}
257 onChange={(value) => setValue(name, value)}
258 />
259 ),
260 )}
261 </div>
262 </div>
263 <div className="flex justify-end gap-2 p-3">
264 {index > 0 ? (
265 <button
266 type="button"
267 className="rounded-sm border border-gray-500 bg-white px-4 py-2 text-sm text-gray-900"
268 onClick={() => goToStep(index - 1)}
269 >
270 {__('Back', 'extendify-local')}
271 </button>
272 ) : null}
273 <button
274 type="submit"
275 className="rounded-sm border border-design-main bg-design-main px-4 py-2 text-sm text-white"
276 >
277 {isLast
278 ? __('Submit', 'extendify-local')
279 : __('Next', 'extendify-local')}
280 </button>
281 </div>
282 </fieldset>
283 </form>
284 </div>
285 );
286 };
287
288 const Submitted = () => (
289 <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">
290 <span aria-hidden className="text-2xl leading-none">
291 🎉
292 </span>
293 <span className="font-medium">
294 {__('Form submitted', 'extendify-local')}
295 </span>
296 <span className="text-gray-700">
297 {__('This was a demo. Nothing saved.', 'extendify-local')}
298 </span>
299 <span className="text-gray-700">
300 {__('Closing this in a moment…', 'extendify-local')}
301 </span>
302 </div>
303 );
304
305 export const DemoForm = ({ onConfirm }) => {
306 // Local state would leave the agent unable to read or write these.
307 const { activeStep, startSession } = useCanvasStore();
308 const [submitted, setSubmitted] = useState(false);
309
310 // A layout swap remounts the form, and re-seeding wipes what was typed.
311 useEffect(() => {
312 if (!useCanvasStore.getState().steps.length) startSession(steps);
313 }, [startSession]);
314
315 useEffect(() => {
316 if (!submitted) return;
317 window.dispatchEvent(new CustomEvent(CANVAS_CONFETTI, { detail: THROW }));
318 const timer = setTimeout(
319 () => onConfirm({ data: useCanvasStore.getState().values }),
320 CLOSE_DELAY,
321 );
322 return () => clearTimeout(timer);
323 }, [submitted, onConfirm]);
324
325 if (submitted) {
326 return (
327 <div className="flex min-h-full items-center justify-center">
328 <Submitted />
329 </div>
330 );
331 }
332
333 return (
334 <div className="flex flex-col gap-3 pb-[70vh]">
335 {steps.map((step, index) => (
336 <StepCard
337 key={step.id}
338 step={step}
339 index={index}
340 activeStep={activeStep}
341 isLast={index === steps.length - 1}
342 onFinish={() => setSubmitted(true)}
343 />
344 ))}
345 </div>
346 );
347 };
348