PluginProbe
Extendify / trunk
Extendify vtrunk
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 / theme / components / change-site-design / SelectSiteDesign.jsx

SelectSiteDesign.jsx in Extendify trunk, at src/Agent/workflows/theme/components/change-site-design/SelectSiteDesign.jsx

497 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { usePaletteOverride } from '@agent/hooks/usePaletteOverride';
2 import { usePalettes } from '@agent/hooks/usePalettes';
3 import { useSiteVibesOverride } from '@agent/hooks/useSiteVibesOverride';
4 import { useSiteVibesVariations } from '@agent/hooks/useSiteVibesVariations';
5 import { useVariationOverride } from '@agent/hooks/useVariationOverride';
6 import { refreshBlockHighlight } from '@agent/lib/block-highlight';
7 import { DesignOption } from '@agent/workflows/theme/components/change-site-design/DesignOption';
8 import { fontsOnlyVariation } from '@agent/workflows/theme/components/change-site-design/utils/fontsOnlyVariation';
9 import { removeAnimationClasses } from '@agent/workflows/theme/components/change-site-design/utils/removeAnimationClasses';
10 import { handleSiteImages } from '@auto-launch/fetchers/get-images';
11 import { useUserSelectionStore } from '@launch/state/user-selections';
12 import { paletteDuotone } from '@shared/lib/palette-preview';
13 import { samplePalettes } from '@shared/lib/palettes';
14 import { safeParseJson } from '@shared/lib/parsing';
15 import { normalizeSiteImages } from '@shared/lib/site-images';
16 import apiFetch from '@wordpress/api-fetch';
17 import { registerCoreBlocks } from '@wordpress/block-library';
18 import { getBlockTypes, parse, serialize } from '@wordpress/blocks';
19 import { Spinner } from '@wordpress/components';
20 import { useDispatch } from '@wordpress/data';
21 import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
22 import { __ } from '@wordpress/i18n';
23 import classnames from 'classnames';
24
25 let originalHeroElement = null;
26
27 const undoHeroSectionChange = () => {
28 const preview = document.querySelector('.ext-hero-section-preview');
29 if (preview && originalHeroElement) {
30 preview.replaceWith(originalHeroElement);
31 } else {
32 preview?.remove();
33 }
34 originalHeroElement = null;
35 };
36
37 const updateHeroSection = (content) => {
38 const tempDiv = document.createElement('div');
39 tempDiv.innerHTML = content;
40 const newNode = tempDiv.firstElementChild;
41 newNode.classList.add('ext-hero-section-preview');
42
43 const existingPreview = document.querySelector('.ext-hero-section-preview');
44 if (existingPreview) {
45 existingPreview.replaceWith(newNode);
46 return;
47 }
48
49 const heroSectionElement = document.querySelector('.ext-hero-section');
50 if (heroSectionElement) {
51 originalHeroElement = heroSectionElement;
52 heroSectionElement.replaceWith(newNode);
53 return;
54 }
55
56 const contentArea =
57 document.querySelector('.entry-content') ?? document.querySelector('main');
58 if (!contentArea) return;
59 contentArea.insertAdjacentElement('afterbegin', newNode);
60 };
61
62 const { context } = window.extAgentData;
63 const isAdmin = context?.adminPage;
64
65 const PAGE_SIZE = 5;
66
67 const hasImages = ({ hero, general }) => hero.length > 0 || general.length > 0;
68
69 const resolveSiteImages = async ({ storedSiteImages, siteProfile }) => {
70 const stored = normalizeSiteImages(storedSiteImages);
71 if (hasImages(stored)) return stored;
72
73 // A browser that didn't run Launch otherwise reuses one image on every option.
74 const localized = normalizeSiteImages(window.extSharedData.siteImages);
75 if (hasImages(localized)) return localized;
76
77 if (siteProfile) {
78 const { siteImages } = await handleSiteImages({ siteProfile });
79 const fetched = normalizeSiteImages(siteImages);
80 if (hasImages(fetched)) return fetched;
81 }
82
83 const saved = await apiFetch({
84 path: '/extendify/v1/shared/site-images',
85 }).catch(() => null);
86
87 return normalizeSiteImages(saved?.siteImages);
88 };
89
90 export const SelectSiteDesign = ({ onConfirm, onCancel }) => {
91 const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
92 const [isLoading, setIsLoading] = useState(false);
93 const [isSaving, setIsSaving] = useState(false);
94
95 const [heroPatterns, setHeroPatterns] = useState();
96 const [colorAndFontsVariations, setColorAndFontsVariations] = useState();
97 const [blockEditorStyles, setBlockEditorStyles] = useState();
98 const [currentHeroHtml, setCurrentHeroHtml] = useState();
99 const [currentCssVibe, setCurrentCssVibe] = useState();
100
101 const [selectedColorAndFonts, setSelectedColorAndFonts] = useState();
102 const [selectedHeroPattern, setSelectedHeroPattern] = useState();
103 const [selectedVibe, setSelectedVibe] = useState();
104 const [selectedPalette, setSelectedPalette] = useState();
105
106 const { updateSettings } = useDispatch('core/block-editor');
107
108 const injectedLinksRef = useRef([]);
109
110 const injectLinkStyles = (linkStyles) => {
111 injectedLinksRef.current.forEach((link) => {
112 link.remove();
113 });
114 injectedLinksRef.current = [];
115
116 (linkStyles ?? []).forEach((href) => {
117 if (document.querySelector(`link[href="${href}"]`)) return;
118 const link = document.createElement('link');
119 link.rel = 'stylesheet';
120 link.href = href;
121 document.head.appendChild(link);
122 injectedLinksRef.current.push(link);
123 });
124 };
125
126 const removeInjectedLinks = () => {
127 injectedLinksRef.current.forEach((link) => {
128 link.remove();
129 });
130 injectedLinksRef.current = [];
131 };
132
133 const {
134 palettes,
135 preferred,
136 css: paletteCss,
137 isLoading: isLoadingPalettes,
138 } = usePalettes();
139 const onPalettes = Boolean(palettes?.length);
140 const shownPalettes = useMemo(
141 () => samplePalettes(palettes, preferred),
142 [palettes, preferred],
143 );
144 const paletteFor = (index) =>
145 onPalettes ? shownPalettes[index % shownPalettes.length] : undefined;
146
147 const { undoChange: undoColorAndFontsChange } = useVariationOverride({
148 css: !isAdmin && selectedColorAndFonts?.css,
149 duotoneTheme:
150 !isAdmin &&
151 !onPalettes &&
152 selectedColorAndFonts?.settings?.color?.duotone?.theme,
153 });
154
155 const { undoChange: undoPaletteChange } = usePaletteOverride({
156 css: (!isAdmin && paletteCss?.[selectedPalette?.slug]) || '',
157 duotoneTheme: (!isAdmin && paletteDuotone(selectedPalette)) || null,
158 });
159
160 const { undoChange: undoVibesChange } = useSiteVibesOverride({
161 css: !isAdmin && selectedVibe?.css,
162 slug: !isAdmin && selectedVibe?.slug,
163 });
164
165 const { data: vibesData, isLoading: isLoadingVibes } =
166 useSiteVibesVariations();
167
168 const vibes = useMemo(() => {
169 // Null for a theme with no block variations, which .css would throw on.
170 if (isLoadingVibes || !vibesData?.css) return [];
171
172 const vibes = Object.entries(vibesData.css)
173 .filter(([slug]) => slug !== vibesData.currentVibe)
174 .map(([slug, css]) => ({
175 slug,
176 css: css?.replaceAll(slug, 'natural-1'),
177 // Css only adds, so a cloned page keeps the applied vibe's slots.
178 previewCss: `${vibesData.resets?.[slug] ?? ''}${css ?? ''}`.replaceAll(
179 slug,
180 'natural-1',
181 ),
182 }))
183 .sort(() => Math.random() - 0.5);
184
185 return [...vibes, ...vibes.slice(0, 3)];
186 }, [vibesData, isLoadingVibes]);
187
188 useEffect(() => {
189 window.scrollTo({ top: 0, behavior: 'smooth' });
190 const heroEl = document.querySelector('.ext-hero-section');
191 const cssVibeEl = document.getElementById(
192 'block-style-variation-styles-inline-css',
193 );
194
195 if (heroEl) setCurrentHeroHtml(removeAnimationClasses(heroEl).outerHTML);
196 if (cssVibeEl) setCurrentCssVibe(cssVibeEl.textContent);
197 }, []);
198
199 useEffect(() => {
200 setIsLoading(true);
201
202 const heroSectionElement = document.querySelector('.ext-hero-section');
203
204 const title = heroSectionElement?.querySelector('h1')?.textContent ?? null;
205 const description =
206 heroSectionElement?.querySelector('p')?.textContent ?? null;
207 const cta =
208 heroSectionElement?.querySelector('.wp-block-button__link') ?? null;
209 const heroPatternName =
210 heroSectionElement?.className?.match(
211 /ext-hero-section ext-hero-section--(\S+)/,
212 )?.[1] ?? null;
213 const domImages = [...(heroSectionElement?.querySelectorAll('img') ?? [])]
214 .filter(
215 (img) =>
216 !img.src.includes('.svg') && !img.src.includes('data:image/svg+xml'),
217 )
218 .map((img) => {
219 try {
220 const url = new URL(img.src);
221 return url.origin + url.pathname;
222 } catch {
223 return img.src;
224 }
225 });
226
227 const { siteId } = window.extSharedData;
228 const stored = safeParseJson(
229 localStorage.getItem(`extendify-launch-data-${siteId}`),
230 );
231 const launchState = useUserSelectionStore.getState();
232 const storedSiteImages =
233 stored?.state?.siteImages ?? launchState?.siteImages?.siteImages ?? [];
234 const siteProfile = stored?.state?.siteProfile ?? launchState?.siteProfile;
235
236 (async () => {
237 const siteImages = await resolveSiteImages({
238 storedSiteImages,
239 siteProfile,
240 });
241
242 apiFetch({
243 path: '/extendify/v1/agent/site-design-variations',
244 method: 'POST',
245 data: {
246 title,
247 images: domImages,
248 siteImages,
249 postId: context?.postId,
250 description,
251 currentHeroPattern: heroPatternName,
252 source: 'change-site-design-workflow',
253 cta: {
254 label: cta?.textContent,
255 link: cta?.href,
256 },
257 },
258 })
259 .then((data) => {
260 setHeroPatterns(data?.patterns?.flat() ?? []);
261 setColorAndFontsVariations(
262 [...(data?.colorAndFontsVariations ?? [])].sort(
263 () => Math.random() - 0.5,
264 ),
265 );
266
267 setBlockEditorStyles(data?.blockEditorSettings);
268 })
269 .finally(() => {
270 setIsLoading(false);
271 });
272 })();
273 }, []);
274
275 useEffect(() => {
276 if (blockEditorStyles) {
277 updateSettings(blockEditorStyles);
278 }
279 }, [blockEditorStyles, updateSettings]);
280
281 const currentDesignOption = useMemo(
282 () => ({ id: 'current', isCurrent: true, renderedHtml: currentHeroHtml }),
283 [currentHeroHtml],
284 );
285
286 useEffect(() => {
287 if (currentDesignOption) setSelectedHeroPattern(currentDesignOption);
288 }, [currentDesignOption]);
289
290 const visibleHeroPatterns = heroPatterns?.slice(0, visibleCount);
291 const hasMore = visibleCount < heroPatterns?.length;
292
293 const undoChanges = () => {
294 undoHeroSectionChange();
295 undoColorAndFontsChange();
296 undoPaletteChange();
297 undoVibesChange();
298 removeInjectedLinks();
299 refreshBlockHighlight();
300 };
301
302 const confirmed = useRef(false);
303 useEffect(() => {
304 return () => {
305 if (!confirmed.current) undoChanges();
306 };
307 }, []);
308
309 const handleCancel = () => {
310 undoChanges();
311 onCancel();
312 };
313
314 const handleConfirm = async () => {
315 if (!selectedHeroPattern) return;
316
317 if (selectedHeroPattern.isCurrent) {
318 confirmed.current = true;
319 onConfirm({
320 data: { postId: context?.postId },
321 shouldRefreshPage: false,
322 });
323 return;
324 }
325
326 if (!selectedVibe || !selectedColorAndFonts) return;
327
328 const postId = context?.postId;
329
330 try {
331 const page = await apiFetch({
332 path: `/wp/v2/pages/${postId}?context=edit`,
333 });
334
335 // parse() depends on block types being registered
336 if (getBlockTypes().length === 0) registerCoreBlocks();
337
338 const pageBlocks = parse(page.content.raw);
339
340 let heroPatternUpdated = false;
341 const updatedPageBlocks = serialize(
342 pageBlocks.map((block) => {
343 if (
344 heroPatternUpdated ||
345 !block.attributes.className.split(' ').includes('ext-hero-section')
346 )
347 return block;
348
349 heroPatternUpdated = true;
350
351 return parse(selectedHeroPattern.code)?.[0] || block;
352 }),
353 );
354
355 confirmed.current = true;
356 onConfirm({
357 data: {
358 updatedPageBlocks,
359 postId,
360 vibeSlug: selectedVibe.slug,
361 colorAndFontsVariation: onPalettes
362 ? fontsOnlyVariation(selectedColorAndFonts)
363 : selectedColorAndFonts,
364 colorPalette: selectedPalette?.slug,
365 },
366 shouldRefreshPage: true,
367 });
368 } catch (error) {
369 console.log(error);
370 } finally {
371 setIsSaving(false);
372 }
373 };
374
375 if (isLoading || isLoadingVibes || isLoadingPalettes) {
376 return (
377 <div className="min-h-24 p-2 text-center text-sm">
378 {__('Loading design options...', 'extendify-local')}
379 </div>
380 );
381 }
382
383 return (
384 <div className="mb-4 ms-2 me-2 flex flex-col rounded-lg border border-gray-300 bg-gray-50">
385 <div className="rounded-lg border-b border-gray-300 bg-white">
386 <div className="flex flex-col gap-4 p-3">
387 {currentHeroHtml && (
388 <DesignOption
389 renderedHtml={currentHeroHtml}
390 isSelected={selectedHeroPattern?.id === 'current'}
391 styles={{ vibes: currentCssVibe }}
392 onClick={() => {
393 setSelectedHeroPattern(currentDesignOption);
394 setSelectedColorAndFonts(null);
395 setSelectedVibe(null);
396 setSelectedPalette(null);
397
398 undoChanges();
399 }}
400 />
401 )}
402 {visibleHeroPatterns?.map((heroPattern, i) => (
403 <DesignOption
404 key={heroPattern.id}
405 renderedHtml={heroPattern.renderedHtml}
406 isSelected={selectedHeroPattern?.id === heroPattern.id}
407 styles={{
408 linkStyles: heroPattern.linkStyles,
409 colorAndFontsVariations: colorAndFontsVariations[i].css,
410 paletteCss: paletteCss?.[paletteFor(i)?.slug],
411 duotoneTheme: onPalettes
412 ? paletteDuotone(paletteFor(i))
413 : colorAndFontsVariations[i]?.settings?.color?.duotone?.theme,
414 vibes: vibes[i]?.previewCss,
415 blockSupportsCss: heroPattern.blockSupportsCss,
416 }}
417 onClick={() => {
418 setSelectedHeroPattern(heroPattern);
419 setSelectedColorAndFonts(colorAndFontsVariations[i]);
420 setSelectedVibe(vibes[i]);
421 setSelectedPalette(paletteFor(i));
422
423 if (!isAdmin) {
424 const blockSupportsCss = heroPattern.blockSupportsCss;
425 if (blockSupportsCss) {
426 let el = document.getElementById('ext-block-supports-css');
427 if (!el) {
428 el = document.createElement('style');
429 el.id = 'ext-block-supports-css';
430 document.head.appendChild(el);
431 }
432 el.textContent = blockSupportsCss;
433 }
434
435 injectLinkStyles(heroPattern.linkStyles);
436
437 updateHeroSection(heroPattern.renderedHtml);
438 refreshBlockHighlight();
439 }
440 }}
441 />
442 ))}
443
444 {hasMore && (
445 <button
446 type="button"
447 className={classnames(
448 'w-full rounded-sm border border-gray-300 bg-white p-2 text-sm text-gray-800',
449 {
450 'hover:bg-gray-50': !isSaving,
451 },
452 )}
453 onClick={() => setVisibleCount((value) => value + PAGE_SIZE)}
454 disabled={isSaving}
455 >
456 {__('Load more', 'extendify-local')}
457 </button>
458 )}
459 </div>
460 </div>
461 <div className="flex justify-start gap-2 p-3">
462 <button
463 type="button"
464 className={classnames(
465 'w-full rounded-sm border border-gray-300 bg-white p-2 text-sm text-gray-800',
466 {
467 'hover:bg-gray-50': !isSaving,
468 },
469 )}
470 onClick={handleCancel}
471 disabled={isSaving}
472 >
473 {__('Cancel', 'extendify-local')}
474 </button>
475 <button
476 type="button"
477 className={classnames(
478 'w-full rounded-sm border border-design-main bg-design-main p-2 text-sm text-white',
479 {
480 'cursor-not-allowed': !selectedHeroPattern || isSaving,
481 'hover:bg-gray-800': !isSaving,
482 },
483 )}
484 disabled={!selectedHeroPattern || isSaving}
485 onClick={handleConfirm}
486 >
487 {isSaving ? (
488 <Spinner className="m-0" />
489 ) : (
490 __('Save', 'extendify-local')
491 )}
492 </button>
493 </div>
494 </div>
495 );
496 };
497