| @@ -1,22 +1,37 @@ | ||
| 1 | -import { ExtendifyCodeConnector } from '@auto-launch/components/ExtendifyCodeConnector'; | |
| 2 | -import { Launch } from '@auto-launch/components/Launch'; | |
| 1 | +import { CreatingSite } from '@auto-launch/components/CreatingSite'; | |
| 2 | +import { DescriptionGathering } from '@auto-launch/components/DescriptionGathering'; | |
| 3 | +import { LaunchUpdate } from '@auto-launch/components/LaunchUpdate'; | |
| 3 | 4 | import { Logo } from '@auto-launch/components/Logo'; |
| 4 | -import { MigrateChoice } from '@auto-launch/components/MigrateChoice'; | |
| 5 | -import { MovingGradient } from '@auto-launch/components/MovingGradients'; | |
| 6 | -import { NeedsTheme } from '@auto-launch/components/NeedsTheme'; | |
| 7 | -import { RestartLaunchModal } from '@auto-launch/components/RestartLaunchModal'; | |
| 8 | -import { ViewportPulse } from '@auto-launch/components/ViewportPulse'; | |
| 5 | +import { | |
| 6 | + ModalHeading, | |
| 7 | + ModalLink, | |
| 8 | + ModalText, | |
| 9 | +} from '@auto-launch/components/Modal'; | |
| 10 | +import { ShaderBackground } from '@auto-launch/components/ShaderBackground'; | |
| 11 | +import { activeLogo } from '@auto-launch/functions/design'; | |
| 9 | 12 | import { getAbTest } from '@auto-launch/functions/getAbTest'; |
| 10 | 13 | import { preLaunchFunctions } from '@auto-launch/functions/setup'; |
| 11 | 14 | import { updateOption } from '@auto-launch/functions/wp'; |
| 15 | +import { useExtendifyCodeConnector } from '@auto-launch/hooks/useExtendifyCodeConnector'; | |
| 16 | +import { useInstallRequiredPlugins } from '@auto-launch/hooks/useInstallRequiredPlugins'; | |
| 17 | +import { useMigrateChoice } from '@auto-launch/hooks/useMigrateChoice'; | |
| 18 | +import { useNeedsTheme } from '@auto-launch/hooks/useNeedsTheme'; | |
| 19 | +import { useRestartLaunch } from '@auto-launch/hooks/useRestartLaunch'; | |
| 12 | 20 | import { useLaunchDataStore } from '@auto-launch/state/launch-data'; |
| 21 | +import { launchStrings } from '@auto-launch/strings'; | |
| 22 | +import { | |
| 23 | + activeChoiceTemplate, | |
| 24 | + activeDescriptionTemplate, | |
| 25 | + activeModalTemplate, | |
| 26 | +} from '@auto-launch/templates'; | |
| 27 | +import { ActionButton, SecondaryButton } from '@auto-launch/templates/button'; | |
| 28 | +import { Heading, PageLink } from '@auto-launch/templates/heading'; | |
| 29 | +import { digest } from '@shared/api/digest'; | |
| 13 | 30 | import { registerCoreBlocks } from '@wordpress/block-library'; |
| 14 | 31 | import { getBlockTypes } from '@wordpress/blocks'; |
| 15 | 32 | import { useSelect } from '@wordpress/data'; |
| 16 | -import { useEffect, useRef, useState } from '@wordpress/element'; | |
| 17 | -import { __ } from '@wordpress/i18n'; | |
| 18 | -import { chevronLeft, Icon } from '@wordpress/icons'; | |
| 33 | +import { useEffect, useState } from '@wordpress/element'; | |
| 19 | 34 | import classNames from 'classnames'; |
| 20 | 35 | import { AnimatePresence, motion } from 'framer-motion'; |
| 21 | 36 | import { checkIn, reportRestApiStatus } from './functions/insights'; |
| 22 | 37 | |
| @@ -27,8 +42,18 @@ | ||
| 27 | 42 | |
| 28 | 43 | const oldPages = window.extLaunchData.resetSiteInformation.pagesIds ?? []; |
| 29 | 44 | const needsToReset = oldPages.length > 0; |
| 30 | 45 | |
| 46 | + const pluginUpdateNeeded = Boolean(window.extLaunchData?.pluginUpdateNeeded); | |
| 47 | + const themeUpdateNeeded = Boolean(window.extLaunchData?.themeUpdateNeeded); | |
| 48 | + const pluginUpdateViaRest = Boolean( | |
| 49 | + window.extLaunchData?.pluginUpdateViaRest, | |
| 50 | + ); | |
| 51 | + const launchUpdateNeeded = pluginUpdateNeeded || themeUpdateNeeded; | |
| 52 | + const launchUpdateAttempt = window.extLaunchData?.launchUpdateAttempt ?? 0; | |
| 53 | + const launchUpdateStale = window.extLaunchData?.launchUpdateStale ?? {}; | |
| 54 | + const launchUpdateGaveUp = Object.keys(launchUpdateStale).length > 0; | |
| 55 | + | |
| 31 | 56 | const { |
| 32 | 57 | title, |
| 33 | 58 | descriptionRaw, |
| 34 | 59 | go, |
| @@ -48,13 +73,37 @@ | ||
| 48 | 73 | getAbTest('AutoLaunch.MigrateScreen').variant === 'B'; |
| 49 | 74 | const [choosingMigration, setChoosingMigration] = useState(inMigrateVariant); |
| 50 | 75 | const showMigrateChoice = !skipDescription && choosingMigration; |
| 51 | 76 | |
| 52 | - const containerRef = useRef(null); | |
| 77 | + // Deleting the old pages first leaves the user on a theme Launch cannot build on. | |
| 78 | + const blocker = launchUpdateNeeded | |
| 79 | + ? 'update' | |
| 80 | + : needsTheme | |
| 81 | + ? 'theme' | |
| 82 | + : needsToReset | |
| 83 | + ? 'reset' | |
| 84 | + : null; | |
| 53 | 85 | |
| 86 | + // Only the describe screen commits to a build; every other screen can still leave. | |
| 87 | + useInstallRequiredPlugins({ | |
| 88 | + enabled: | |
| 89 | + !blocker && !skipDescription && !showConnector && !showMigrateChoice, | |
| 90 | + }); | |
| 91 | + | |
| 54 | 92 | useEffect(() => { |
| 93 | + if (launchUpdateNeeded) return; | |
| 94 | + if (launchUpdateGaveUp) { | |
| 95 | + digest({ | |
| 96 | + error: new Error('Launch went ahead without the pending update'), | |
| 97 | + details: { | |
| 98 | + source: 'auto-launch', | |
| 99 | + caller: 'launch-update', | |
| 100 | + stale: launchUpdateStale, | |
| 101 | + }, | |
| 102 | + }); | |
| 103 | + } | |
| 55 | 104 | // translators: Launch is a noun. |
| 56 | - document.title = __('Launch - AI-Powered Web Creation', 'extendify-local'); | |
| 105 | + document.title = launchStrings().documentTitle; | |
| 57 | 106 | updateOption('extendify_launch_loaded', new Date().toISOString()); |
| 58 | 107 | // We load core blocks so we can parse them |
| 59 | 108 | if (getBlockTypes().length === 0) registerCoreBlocks(); |
| 60 | 109 | |
| @@ -60,169 +109,212 @@ | ||
| 60 | 109 | |
| 61 | 110 | preLaunchFunctions(); |
| 62 | 111 | checkIn({ stage: 'launch_page' }); |
| 63 | 112 | reportRestApiStatus(); |
| 64 | - }, []); | |
| 113 | + }, [launchUpdateNeeded, launchUpdateGaveUp]); | |
| 65 | 114 | |
| 66 | - if (needsTheme) { | |
| 67 | - return ( | |
| 68 | - <Wrapper> | |
| 69 | - <div className="bg-white w-full max-w-3xl rounded-lg border border-design-main/60 relative z-10"> | |
| 70 | - <NeedsTheme /> | |
| 71 | - </div> | |
| 72 | - </Wrapper> | |
| 73 | - ); | |
| 74 | - } | |
| 115 | + const modal = | |
| 116 | + blocker === 'update' ? ( | |
| 117 | + <UpdateScreen | |
| 118 | + pluginUpdateNeeded={pluginUpdateNeeded} | |
| 119 | + themeUpdateNeeded={themeUpdateNeeded} | |
| 120 | + pluginUpdateViaRest={pluginUpdateViaRest} | |
| 121 | + pluginSlug={window.extLaunchData?.pluginSlug} | |
| 122 | + attempt={launchUpdateAttempt} | |
| 123 | + /> | |
| 124 | + ) : blocker === 'theme' ? ( | |
| 125 | + <NeedsThemeScreen /> | |
| 126 | + ) : blocker === 'reset' ? ( | |
| 127 | + <RestartScreen pages={oldPages} /> | |
| 128 | + ) : null; | |
| 129 | + // Site creation must not start behind a modal the user has not cleared. | |
| 130 | + const screen = modal | |
| 131 | + ? 'description' | |
| 132 | + : showConnector | |
| 133 | + ? 'connector' | |
| 134 | + : showMigrateChoice | |
| 135 | + ? 'migrate' | |
| 136 | + : skipDescription | |
| 137 | + ? 'creating' | |
| 138 | + : 'description'; | |
| 75 | 139 | |
| 76 | - if (needsToReset) { | |
| 77 | - return ( | |
| 78 | - <Wrapper> | |
| 79 | - <div className="w-full max-w-2xl rounded-3xl border bg-gray-100/80 backdrop-blur-2xl shadow-md relative z-10 border-gray-300"> | |
| 80 | - <RestartLaunchModal pages={oldPages} /> | |
| 81 | - </div> | |
| 82 | - </Wrapper> | |
| 83 | - ); | |
| 84 | - } | |
| 140 | + return ( | |
| 141 | + <Viewport modal={modal} screen={screen}> | |
| 142 | + {screen === 'creating' ? ( | |
| 143 | + <CreatingSite /> | |
| 144 | + ) : screen === 'connector' ? ( | |
| 145 | + <ConnectorScreen | |
| 146 | + onProceed={() => setData('go', true)} | |
| 147 | + footer={ | |
| 148 | + <BackLink | |
| 149 | + onClick={() => setData('showExtendifyCodeScreen', false)} | |
| 150 | + /> | |
| 151 | + } | |
| 152 | + /> | |
| 153 | + ) : screen === 'migrate' ? ( | |
| 154 | + <MigrateScreen | |
| 155 | + onBuildNew={() => setChoosingMigration(false)} | |
| 156 | + footer={showExitLink ? <ExitLink /> : null} | |
| 157 | + /> | |
| 158 | + ) : ( | |
| 159 | + <DescriptionPage | |
| 160 | + heading={<TheTitle />} | |
| 161 | + footer={showExitLink ? <ExitLink /> : null} | |
| 162 | + > | |
| 163 | + {/* Matches the migrate screen's height, so choosing Build does not jump. */} | |
| 164 | + <div className={classNames({ 'md:h-72.75': inMigrateVariant })}> | |
| 165 | + <div className="mx-auto w-full"> | |
| 166 | + <DescriptionGathering autoFocus={!modal} /> | |
| 167 | + </div> | |
| 168 | + </div> | |
| 169 | + </DescriptionPage> | |
| 170 | + )} | |
| 171 | + </Viewport> | |
| 172 | + ); | |
| 173 | +}; | |
| 85 | 174 | |
| 175 | +const NeedsThemeScreen = () => { | |
| 176 | + const { title, body, action } = useNeedsTheme(); | |
| 86 | 177 | return ( |
| 87 | - <Wrapper | |
| 88 | - footer={ | |
| 89 | - showConnector ? ( | |
| 90 | - <BackLink onClick={() => setData('showExtendifyCodeScreen', false)} /> | |
| 91 | - ) : showExitLink ? ( | |
| 92 | - <ExitLink /> | |
| 93 | - ) : null | |
| 94 | - } | |
| 95 | - > | |
| 96 | - <AnimatePresence mode="wait" initial={false}> | |
| 97 | - <TheTitle | |
| 98 | - key={showMigrateChoice ? 'migrate' : 'description'} | |
| 99 | - // The connector renders its own heading, so hide the shared one. | |
| 100 | - hide={skipDescription || showConnector} | |
| 101 | - migrate={showMigrateChoice} | |
| 102 | - /> | |
| 103 | - </AnimatePresence> | |
| 104 | - <div | |
| 105 | - ref={containerRef} | |
| 106 | - className={classNames('w-full relative z-10', { | |
| 107 | - 'max-w-3xl': inMigrateVariant || showConnector, | |
| 108 | - 'max-w-2xl': !inMigrateVariant && !showConnector, | |
| 109 | - 'md:h-72.75': inMigrateVariant && !skipDescription, | |
| 110 | - })} | |
| 111 | - > | |
| 112 | - <AnimatePresence mode="wait"> | |
| 113 | - {showConnector && ( | |
| 114 | - <ExtendifyCodeConnector | |
| 115 | - key="extendify-code" | |
| 116 | - onProceed={() => setData('go', true)} | |
| 117 | - /> | |
| 118 | - )} | |
| 119 | - {!showConnector && showMigrateChoice && ( | |
| 120 | - <MigrateChoice | |
| 121 | - key="migrate-choice" | |
| 122 | - onBuildNew={() => setChoosingMigration(false)} | |
| 123 | - /> | |
| 124 | - )} | |
| 125 | - {!showConnector && !showMigrateChoice && ( | |
| 126 | - <Launch | |
| 127 | - key={skipDescription ? 'description-launch' : 'creating-launch'} | |
| 128 | - skipDescription={skipDescription} | |
| 129 | - lastHeight={containerRef.current?.offsetHeight} | |
| 130 | - /> | |
| 131 | - )} | |
| 132 | - </AnimatePresence> | |
| 133 | - </div> | |
| 134 | - </Wrapper> | |
| 178 | + <ModalPage | |
| 179 | + heading={<ModalHeading title={title} />} | |
| 180 | + body={<ModalText>{body}</ModalText>} | |
| 181 | + actions={<ModalLink href={action.href} label={action.label} />} | |
| 182 | + /> | |
| 135 | 183 | ); |
| 136 | 184 | }; |
| 137 | 185 | |
| 138 | -const Wrapper = ({ children, footer }) => { | |
| 139 | - const { pulse } = useLaunchDataStore(); | |
| 186 | +const UpdateScreen = (props) => <LaunchUpdate {...props} />; | |
| 140 | 187 | |
| 188 | +const RestartScreen = ({ pages }) => { | |
| 189 | + const { title, body, note, exit, confirm } = useRestartLaunch({ pages }); | |
| 141 | 190 | return ( |
| 142 | - <div style={{ zIndex: 99999 + 1 }} className="fixed inset-0 bg-white"> | |
| 143 | - <div className="relative h-dvh bg-banner-main text-banner-text text-base overflow-y-auto"> | |
| 144 | - <div className="relative z-10 min-h-dvh w-full flex flex-col items-center justify-between p-6"> | |
| 145 | - <div className="w-full flex flex-col items-center gap-5 md:gap-8 m-auto"> | |
| 146 | - <div className="mb-4"> | |
| 147 | - <Logo /> | |
| 148 | - </div> | |
| 149 | - {children} | |
| 150 | - </div> | |
| 151 | - {footer && <div className="w-full pt-8 shrink-0">{footer}</div>} | |
| 152 | - </div> | |
| 153 | - </div> | |
| 154 | - <MovingGradient /> | |
| 155 | - {pulse ? <ViewportPulse /> : null} | |
| 156 | - </div> | |
| 191 | + <ModalPage | |
| 192 | + left | |
| 193 | + heading={<ModalHeading title={title} />} | |
| 194 | + body={ | |
| 195 | + <> | |
| 196 | + <ModalText>{body}</ModalText> | |
| 197 | + <ModalText strong>{note}</ModalText> | |
| 198 | + </> | |
| 199 | + } | |
| 200 | + actions={ | |
| 201 | + <> | |
| 202 | + <SecondaryButton | |
| 203 | + disabled={confirm.processing} | |
| 204 | + label={exit.label} | |
| 205 | + onClick={exit.onClick} | |
| 206 | + /> | |
| 207 | + <ActionButton {...confirm} /> | |
| 208 | + </> | |
| 209 | + } | |
| 210 | + /> | |
| 157 | 211 | ); |
| 158 | 212 | }; |
| 159 | 213 | |
| 160 | -const ExitLink = () => { | |
| 214 | +const ConnectorScreen = ({ onProceed, footer }) => { | |
| 215 | + const { title, subtitle, options } = useExtendifyCodeConnector({ onProceed }); | |
| 161 | 216 | return ( |
| 162 | - <a | |
| 163 | - className="inline-flex items-center gap-0.5 text-sm text-banner-text opacity-70 hover:opacity-100 transition-opacity" | |
| 164 | - href={window.extSharedData.adminUrl} | |
| 165 | - onClick={() => checkIn({ stage: 'exit_to_wp_admin' })} | |
| 166 | - > | |
| 167 | - <Icon fill="currentColor" icon={chevronLeft} size={20} /> | |
| 168 | - {__('WP Admin Dashboard', 'extendify-local')} | |
| 169 | - </a> | |
| 217 | + <ChoicePage | |
| 218 | + heading={title && <Heading subtitle={subtitle} title={title} />} | |
| 219 | + options={options} | |
| 220 | + footer={footer} | |
| 221 | + /> | |
| 170 | 222 | ); |
| 171 | 223 | }; |
| 172 | 224 | |
| 173 | -const BackLink = ({ onClick }) => { | |
| 225 | +const MigrateScreen = ({ onBuildNew, footer }) => { | |
| 226 | + const { title, options } = useMigrateChoice({ onBuildNew }); | |
| 174 | 227 | return ( |
| 175 | - <button | |
| 176 | - type="button" | |
| 177 | - onClick={onClick} | |
| 178 | - className="inline-flex items-center gap-0.5 border-0 bg-transparent cursor-pointer text-sm text-banner-text opacity-70 hover:opacity-100 transition-opacity" | |
| 179 | - > | |
| 180 | - <Icon fill="currentColor" icon={chevronLeft} size={20} /> | |
| 181 | - {__('Back', 'extendify-local')} | |
| 182 | - </button> | |
| 228 | + <ChoicePage | |
| 229 | + heading={<Heading title={title} />} | |
| 230 | + options={options} | |
| 231 | + footer={footer} | |
| 232 | + /> | |
| 183 | 233 | ); |
| 184 | 234 | }; |
| 185 | 235 | |
| 186 | -const TheTitle = ({ hide, migrate }) => { | |
| 187 | - const useOldHeader = | |
| 188 | - getAbTest('AutoLaunch.HeaderParagraphOld').variant === 'B'; | |
| 189 | - if (hide) return null; | |
| 236 | +const Viewport = ({ children, screen, modal }) => ( | |
| 237 | + // Inline inset: utilities are !important here and would win over it. | |
| 238 | + <div | |
| 239 | + style={{ zIndex: 99999 + 1, top: 0, right: 0, bottom: 0, left: 0 }} | |
| 240 | + className="group fixed bg-ui-page" | |
| 241 | + > | |
| 242 | + <AnimatePresence mode="wait" initial={false}> | |
| 243 | + <motion.div | |
| 244 | + key={screen} | |
| 245 | + className="absolute inset-0" | |
| 246 | + // React 18 drops a boolean inert; only a string reaches the DOM. | |
| 247 | + inert={modal ? '' : undefined} | |
| 248 | + initial={{ opacity: 0 }} | |
| 249 | + animate={{ opacity: 1 }} | |
| 250 | + exit={{ opacity: 0 }} | |
| 251 | + transition={{ duration: 0.25 }} | |
| 252 | + > | |
| 253 | + {children} | |
| 254 | + </motion.div> | |
| 255 | + </AnimatePresence> | |
| 256 | + {modal} | |
| 257 | + <ShaderBackground /> | |
| 258 | + </div> | |
| 259 | +); | |
| 190 | 260 | |
| 191 | - const headingClass = | |
| 192 | - 'text-xl md:text-2xl text-pretty text-banner-text font-semibold p-0 m-0 text-center'; | |
| 193 | - const transition = { | |
| 194 | - animate: { opacity: 1 }, | |
| 195 | - exit: { opacity: 0 }, | |
| 196 | - transition: { duration: 0.4 }, | |
| 261 | +const DescriptionPage = ({ children, footer, heading }) => { | |
| 262 | + const Page = activeDescriptionTemplate(); | |
| 263 | + const { partnerLogo, partnerName } = window.extSharedData ?? {}; | |
| 264 | + // A template cannot tell a heading that renders null from one that draws. | |
| 265 | + const has = { | |
| 266 | + heading: Boolean(heading), | |
| 267 | + footer: Boolean(footer), | |
| 197 | 268 | }; |
| 269 | + const parts = { | |
| 270 | + logo: <Logo name={partnerName} src={activeLogo() ?? partnerLogo} />, | |
| 271 | + heading, | |
| 272 | + content: children, | |
| 273 | + footer, | |
| 274 | + }; | |
| 198 | 275 | |
| 199 | - if (migrate) { | |
| 200 | - return ( | |
| 201 | - <motion.h2 className={headingClass} {...transition}> | |
| 202 | - {__('How would you like to start your website?', 'extendify-local')} | |
| 203 | - </motion.h2> | |
| 204 | - ); | |
| 205 | - } | |
| 276 | + return <Page parts={parts} has={has} />; | |
| 277 | +}; | |
| 206 | 278 | |
| 207 | - if (useOldHeader) { | |
| 208 | - return ( | |
| 209 | - <motion.div className="flex flex-col items-center gap-2" {...transition}> | |
| 210 | - <h2 className={headingClass}> | |
| 211 | - {__('Tell Us About Your Website', 'extendify-local')} | |
| 212 | - </h2> | |
| 213 | - <p className="text-sm md:text-base text-pretty text-banner-text opacity-70 p-0 m-0 text-center max-w-xl"> | |
| 214 | - {__( | |
| 215 | - "Share your vision, and we'll craft a website that's perfectly tailored to your needs, ready to launch in no time.", | |
| 216 | - 'extendify-local', | |
| 217 | - )} | |
| 218 | - </p> | |
| 219 | - </motion.div> | |
| 220 | - ); | |
| 221 | - } | |
| 279 | +const ModalPage = ({ heading, body, actions, left }) => { | |
| 280 | + const Page = activeModalTemplate(); | |
| 281 | + const has = { | |
| 282 | + heading: Boolean(heading), | |
| 283 | + actions: Boolean(actions), | |
| 284 | + card: true, | |
| 285 | + left: Boolean(left), | |
| 286 | + }; | |
| 222 | 287 | |
| 223 | - return ( | |
| 224 | - <motion.h2 className={headingClass} {...transition}> | |
| 225 | - {__('Describe the website you want to build', 'extendify-local')} | |
| 226 | - </motion.h2> | |
| 227 | - ); | |
| 288 | + return <Page parts={{ heading, body, actions }} has={has} />; | |
| 228 | 289 | }; |
| 290 | + | |
| 291 | +const ChoicePage = ({ heading, options, footer }) => { | |
| 292 | + const Page = activeChoiceTemplate(); | |
| 293 | + const { partnerLogo, partnerName } = window.extSharedData ?? {}; | |
| 294 | + const has = { | |
| 295 | + heading: Boolean(heading), | |
| 296 | + footer: Boolean(footer), | |
| 297 | + }; | |
| 298 | + const parts = { | |
| 299 | + logo: <Logo name={partnerName} src={activeLogo() ?? partnerLogo} />, | |
| 300 | + heading, | |
| 301 | + options, | |
| 302 | + footer, | |
| 303 | + }; | |
| 304 | + | |
| 305 | + return <Page parts={parts} has={has} />; | |
| 306 | +}; | |
| 307 | + | |
| 308 | +const ExitLink = () => ( | |
| 309 | + <PageLink | |
| 310 | + href={window.extSharedData.adminUrl} | |
| 311 | + label={launchStrings().exitLink} | |
| 312 | + onClick={() => checkIn({ stage: 'exit_to_wp_admin' })} | |
| 313 | + /> | |
| 314 | +); | |
| 315 | + | |
| 316 | +const BackLink = ({ onClick }) => ( | |
| 317 | + <PageLink label={launchStrings().back} onClick={onClick} /> | |
| 318 | +); | |
| 319 | + | |
| 320 | +const TheTitle = () => <Heading title={launchStrings().heading} />; | |