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

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

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