PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
extendify / src / Agent / workflows / theme / components / change-site-design / SelectSiteDesign.jsx

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

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