| 1 |
import { getThemeVariations } from '@launch/api/WPApi'; |
| 2 |
import { LoadingIndicator } from '@launch/components/LoadingIndicator'; |
| 3 |
import { SmallPreview } from '@launch/components/SmallPreview'; |
| 4 |
import { Title } from '@launch/components/Title'; |
| 5 |
import { useFetch } from '@launch/hooks/useFetch'; |
| 6 |
import { useHomeLayouts } from '@launch/hooks/useHomeLayouts'; |
| 7 |
import { useIsMountedLayout } from '@launch/hooks/useIsMounted'; |
| 8 |
import { PageLayout } from '@launch/layouts/PageLayout'; |
| 9 |
import { pageState } from '@launch/state/factory'; |
| 10 |
import { usePagesSelectionStore } from '@launch/state/pages-selections'; |
| 11 |
import { useUserSelectionStore } from '@launch/state/user-selections'; |
| 12 |
import { Checkmark } from '@launch/svg'; |
| 13 |
import { deepMerge } from '@shared/lib/utils'; |
| 14 |
import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; |
| 15 |
import { __ } from '@wordpress/i18n'; |
| 16 |
import classNames from 'classnames'; |
| 17 |
import { AnimatePresence, motion } from 'framer-motion'; |
| 18 |
|
| 19 |
export const state = pageState('Layout', () => ({ |
| 20 |
ready: false, |
| 21 |
canSkip: false, |
| 22 |
useNav: true, |
| 23 |
onRemove: () => {}, |
| 24 |
})); |
| 25 |
|
| 26 |
export const HomeSelect = () => { |
| 27 |
const { loading, homeLayouts } = useHomeLayouts(); |
| 28 |
|
| 29 |
return ( |
| 30 |
<PageLayout> |
| 31 |
<div className="grow overflow-y-auto px-6 py-8 md:p-12 3xl:p-16"> |
| 32 |
<Title |
| 33 |
title={__('Pick a design for your website', 'extendify-local')} |
| 34 |
description={__('You can personalize this later.', 'extendify-local')} |
| 35 |
/> |
| 36 |
<div className="relative mx-auto w-full max-w-6xl"> |
| 37 |
{loading ? ( |
| 38 |
<LoadingIndicator /> |
| 39 |
) : ( |
| 40 |
<DesignSelector homeLayouts={homeLayouts} /> |
| 41 |
)} |
| 42 |
</div> |
| 43 |
</div> |
| 44 |
</PageLayout> |
| 45 |
); |
| 46 |
}; |
| 47 |
|
| 48 |
const DesignSelector = ({ homeLayouts }) => { |
| 49 |
const { data: variations } = useFetch('variations', getThemeVariations); |
| 50 |
const isMounted = useIsMountedLayout(); |
| 51 |
const [styles, setStyles] = useState([]); |
| 52 |
const { setStyle, style: currentStyle } = usePagesSelectionStore(); |
| 53 |
|
| 54 |
const { siteInformation, siteObjective, setVariation, variation } = |
| 55 |
useUserSelectionStore(); |
| 56 |
|
| 57 |
const onSelect = useCallback( |
| 58 |
(style) => { |
| 59 |
setStyle(style); |
| 60 |
setVariation(style?.variation); |
| 61 |
}, |
| 62 |
[setStyle, setVariation], |
| 63 |
); |
| 64 |
const wrapperRef = useRef(); |
| 65 |
const once = useRef(false); |
| 66 |
|
| 67 |
useEffect(() => { |
| 68 |
state.setState({ ready: !!variation?.title }); |
| 69 |
}, [variation]); |
| 70 |
|
| 71 |
useEffect(() => { |
| 72 |
if (!homeLayouts || !variations) return; |
| 73 |
if (styles.length) return; |
| 74 |
setStyle(null); |
| 75 |
setVariation(null); |
| 76 |
(async () => { |
| 77 |
const slicedEntries = Array.from(homeLayouts.entries()); |
| 78 |
for (const [index, style] of slicedEntries) { |
| 79 |
if (!isMounted.current) return; |
| 80 |
|
| 81 |
const { fonts, colorPalette } = style.siteStyle; |
| 82 |
|
| 83 |
// Select the variation matching the color palette suggested |
| 84 |
// by the AI. If none matches, return a random variation. |
| 85 |
let variation = |
| 86 |
variations.find(({ slug }) => slug === colorPalette) ?? |
| 87 |
variations[index % variations.length]; |
| 88 |
|
| 89 |
// If a variation matched color palette, replace the fonts with |
| 90 |
// those suggested by the AI. |
| 91 |
if (variation.slug === colorPalette && fonts) { |
| 92 |
variation = deepMerge(variation, { |
| 93 |
styles: { |
| 94 |
elements: { |
| 95 |
heading: { |
| 96 |
typography: { |
| 97 |
fontFamily: `var(--wp--preset--font-family--${fonts.heading.slug})`, |
| 98 |
}, |
| 99 |
}, |
| 100 |
}, |
| 101 |
typography: { |
| 102 |
fontFamily: `var(--wp--preset--font-family--${fonts.body.slug})`, |
| 103 |
}, |
| 104 |
}, |
| 105 |
settings: { |
| 106 |
typography: { |
| 107 |
fontFamilies: { |
| 108 |
// If font doesn't have a host, it means it's already installed |
| 109 |
// with the theme. We only need to add the ones that are not. |
| 110 |
custom: [fonts.heading, fonts.body].filter( |
| 111 |
(font) => !!font.host, |
| 112 |
), |
| 113 |
}, |
| 114 |
}, |
| 115 |
}, |
| 116 |
}); |
| 117 |
} |
| 118 |
|
| 119 |
setStyles((styles) => [ |
| 120 |
...styles, |
| 121 |
{ |
| 122 |
...style, |
| 123 |
variation, |
| 124 |
}, |
| 125 |
]); |
| 126 |
|
| 127 |
// Delay between 350ms and 1s to make it less rigid |
| 128 |
const random = Math.floor(Math.random() * (1000 - 150 + 1)) + 150; |
| 129 |
await new Promise((resolve) => setTimeout(resolve, random)); |
| 130 |
} |
| 131 |
})(); |
| 132 |
}, [ |
| 133 |
homeLayouts, |
| 134 |
isMounted, |
| 135 |
variations, |
| 136 |
styles.length, |
| 137 |
setStyle, |
| 138 |
setVariation, |
| 139 |
]); |
| 140 |
|
| 141 |
useEffect(() => { |
| 142 |
if (!currentStyle || !styles || once.current) return; |
| 143 |
const currentButton = wrapperRef.current?.querySelector( |
| 144 |
`#layout-style-${currentStyle.slug} [role="button"]`, |
| 145 |
); |
| 146 |
if (!currentButton) return; |
| 147 |
once.current = true; |
| 148 |
currentButton.focus(); |
| 149 |
}, [currentStyle, styles]); |
| 150 |
|
| 151 |
return ( |
| 152 |
<div |
| 153 |
className="grid gap-8 md:grid-cols-2 lg:grid-cols-3" |
| 154 |
data-test="layout-preview-wrapper" |
| 155 |
ref={wrapperRef} |
| 156 |
> |
| 157 |
{styles?.map((style) => ( |
| 158 |
<div className="relative" key={style.id}> |
| 159 |
<AnimatePresence> |
| 160 |
<motion.div |
| 161 |
initial={{ opacity: 0 }} |
| 162 |
animate={{ opacity: 1 }} |
| 163 |
duration={0.7} |
| 164 |
className={classNames( |
| 165 |
'relative cursor-pointer overflow-hidden rounded-sm border border-gray-200 ring-offset-2 ring-offset-white focus-within:outline-hidden focus-within:ring-4 focus-within:ring-design-main focus-within:ring-offset-2 focus-within:ring-offset-white hover:outline-hidden hover:ring-4', |
| 166 |
{ |
| 167 |
'ring-4 ring-design-main ring-offset-2 ring-offset-white hover:ring-design-main': |
| 168 |
currentStyle?.id === style.id, |
| 169 |
'hover:ring-gray-300': currentStyle?.id !== style.id, |
| 170 |
}, |
| 171 |
)} |
| 172 |
style={{ aspectRatio: '1.55' }} |
| 173 |
> |
| 174 |
<SmallPreview |
| 175 |
style={style} |
| 176 |
showNav={siteObjective !== 'landing-page'} |
| 177 |
siteTitle={siteInformation.title} |
| 178 |
onSelect={onSelect} |
| 179 |
selected={currentStyle?.id === style.id} |
| 180 |
/> |
| 181 |
</motion.div> |
| 182 |
</AnimatePresence> |
| 183 |
<span aria-hidden="true"> |
| 184 |
{currentStyle?.id === style.id ? ( |
| 185 |
<Checkmark className="absolute right-0 top-0 z-50 m-2 h-6 w-6 -translate-y-5 translate-x-5 rounded-full bg-design-main text-design-text" /> |
| 186 |
) : null} |
| 187 |
</span> |
| 188 |
</div> |
| 189 |
))} |
| 190 |
{homeLayouts?.slice(styles?.length).map((_, i) => ( |
| 191 |
<AnimatePresence key={i}> |
| 192 |
<motion.div |
| 193 |
initial={{ opacity: 1 }} |
| 194 |
animate={{ opacity: 1 }} |
| 195 |
exit={{ opacity: 0 }} |
| 196 |
duration={0.7} |
| 197 |
className="relative bg-gray-50" |
| 198 |
style={{ |
| 199 |
aspectRatio: '1.55', |
| 200 |
backgroundImage: |
| 201 |
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 100%)', |
| 202 |
backgroundSize: '600% 600%', |
| 203 |
animation: 'extendify-loading-skeleton 10s ease-in-out infinite', |
| 204 |
}} |
| 205 |
/> |
| 206 |
</AnimatePresence> |
| 207 |
))} |
| 208 |
</div> |
| 209 |
); |
| 210 |
}; |
| 211 |
|