diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..87cb608 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,66 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": "wordpress", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 2021, + "sourceType": "module" + }, + "ignorePatterns": [ "node_modules", "assets" ], + "rules": { + "indent": [ + "error", + "tab" + ], + "linebreak-style": [ + "error", + "unix" + ], + "quotes": [ + "error", + "single" + ], + "semi": [ + "error", + "always" + ], + "prefer-destructuring": [ + "warn", + { + "array": false, + "object": true + }, + { + "enforceForRenamedProperties": false + } + ], + "array-bracket-spacing": [ + "warn", + "always", + { + "arraysInArrays": false, + "objectsInArrays": false + } + ], + "key-spacing": [ + "warn", + { + "beforeColon": false, + "afterColon": true + } + ], + "object-curly-spacing": [ + "warn", + "always", + { + "arraysInObjects": true, + "objectsInObjects": false + } + ], + } +} diff --git a/package-lock.json b/package-lock.json index 3e75f59..d394996 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "quickwp", "version": "1.0.0", "license": "GPL-3.0-or-later", + "dependencies": { + "classnames": "^2.3.2" + }, "devDependencies": { "@wordpress/scripts": "^26.19.0", + "eslint-config-wordpress": "^2.0.0", "simple-git-hooks": "^2.9.0", "tailwindcss": "^3.4.0" } @@ -6253,6 +6257,11 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, "node_modules/clean-webpack-plugin": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/clean-webpack-plugin/-/clean-webpack-plugin-3.0.0.tgz", @@ -8151,6 +8160,16 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-config-wordpress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-wordpress/-/eslint-config-wordpress-2.0.0.tgz", + "integrity": "sha512-9ydUZ1zORI3av5EOx4zQml8WpdDx1bAOZC4dLPcYGqVcdBol3dQ2L40e2ill52k/+I+rqUJppGzWK+zP7+lI1w==", + "deprecated": "This package has been deprecated, please use @wordpress/eslint-plugin or @wordpress/scripts", + "dev": true, + "engines": { + "node": ">=4.2.1" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", diff --git a/package.json b/package.json index 2f7af84..cc937ef 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,14 @@ "homepage": "https://github.com/Codeinwp/quickwp#readme", "devDependencies": { "@wordpress/scripts": "^26.19.0", + "eslint-config-wordpress": "^2.0.0", "simple-git-hooks": "^2.9.0", "tailwindcss": "^3.4.0" }, "simple-git-hooks": { "pre-commit": "npm run lint:js && composer run-script lint && composer run-script phpstan" + }, + "dependencies": { + "classnames": "^2.3.2" } } diff --git a/postcss.config.js b/postcss.config.js index 16e7961..0e0e5d7 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,3 +1,3 @@ module.exports = { - plugins: [ require( 'tailwindcss' ) ], + plugins: [ require( 'tailwindcss' ) ] }; diff --git a/src/App.js b/src/App.js index b9ba92b..3a32fa8 100644 --- a/src/App.js +++ b/src/App.js @@ -1,11 +1,43 @@ /** * WordPress dependencies. */ +import { __ } from '@wordpress/i18n'; + +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies. + */ +import { useIsSiteEditorLoading } from './hooks'; +import Loader from './components/Loader'; +import Header from './parts/Header'; + const App = () => { + const isEditorLoading = useIsSiteEditorLoading(); + + const currentStep = useSelect( ( select ) => + select( 'quickwp/data' ).getStep() + ); + const StepControls = currentStep?.view || null; + + if ( isEditorLoading ) { + return ( +
+ +
+ ); + } + return ( -
-

QuickWP

-

This is an example starter modal.

+
+
+
); }; diff --git a/src/components/Loader.js b/src/components/Loader.js new file mode 100644 index 0000000..f6d5f79 --- /dev/null +++ b/src/components/Loader.js @@ -0,0 +1,53 @@ +/** + * External dependencies. + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { memo, useEffect, useMemo, useState } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { PageControlIcon } from '../utils'; + +import STEPS from '../steps'; + +const Loader = () => { + const [ currentPage, setCurrentPage ] = useState( 0 ); + const numberOfPages = useMemo( () => STEPS.length, []); + + useEffect( () => { + const interval = setInterval( () => { + setCurrentPage( ( prevPage ) => + prevPage === numberOfPages - 1 ? 0 : prevPage + 1 + ); + }, 500 ); + + return () => clearInterval( interval ); + }, [ numberOfPages ]); + + return ( + + ); +}; + +export default memo( Loader ); diff --git a/src/components/PageControl.js b/src/components/PageControl.js new file mode 100644 index 0000000..07d307d --- /dev/null +++ b/src/components/PageControl.js @@ -0,0 +1,54 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { Button } from '@wordpress/components'; + +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies. + */ +import { PageControlIcon } from '../utils'; +import STEPS from '../steps'; + +const numberOfPages = STEPS.length; + +const PageControl = () => { + const currentStep = useSelect( ( select ) => + select( 'quickwp/data' ).getStep() + ); + const { setStep } = useDispatch( 'quickwp/data' ); + + const currentPage = STEPS.findIndex( + ( step ) => step.value === currentStep.value + ); + + return ( + + ); +}; + +export default PageControl; diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..5ac4681 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,111 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; + +const MAX_LOADING_TIME = 10000; // 10 seconds + +// This is a copy of the useEditedEntityRecord function from the Gutenberg plugin. +export const useEditedEntityRecord = ( postType, postId ) => { + const { record, title, description, isLoaded, icon } = useSelect( + ( select ) => { + const { getEditedPostType, getEditedPostId } = + select( 'core/edit-site' ); + const { getEditedEntityRecord, hasFinishedResolution } = + select( 'core' ); + const { __experimentalGetTemplateInfo: getTemplateInfo } = + select( 'core/editor' ); + const usedPostType = postType ?? getEditedPostType(); + const usedPostId = postId ?? getEditedPostId(); + const _record = getEditedEntityRecord( + 'postType', + usedPostType, + usedPostId + ); + const _isLoaded = + usedPostId && + hasFinishedResolution( 'getEditedEntityRecord', [ + 'postType', + usedPostType, + usedPostId + ]); + const templateInfo = getTemplateInfo( _record ); + + return { + record: _record, + title: templateInfo.title, + description: templateInfo.description, + isLoaded: _isLoaded, + icon: templateInfo.icon + }; + }, + [ postType, postId ] + ); + + return { + isLoaded, + icon, + record, + getTitle: () => ( title ? decodeEntities( title ) : null ), + getDescription: () => + description ? decodeEntities( description ) : null + }; +}; + +// This is a copy of the useIsSiteEditorLoading function from the Gutenberg plugin. +export const useIsSiteEditorLoading = () => { + const { isLoaded: hasLoadedPost } = useEditedEntityRecord(); + const [ loaded, setLoaded ] = useState( false ); + const inLoadingPause = useSelect( + ( select ) => { + const hasResolvingSelectors = + select( 'core' ).hasResolvingSelectors(); + return ! loaded && ! hasResolvingSelectors; + }, + [ loaded ] + ); + + /* + * If the maximum expected loading time has passed, we're marking the + * editor as loaded, in order to prevent any failed requests from blocking + * the editor canvas from appearing. + */ + useEffect( () => { + let timeout; + + if ( ! loaded ) { + timeout = setTimeout( () => { + setLoaded( true ); + }, MAX_LOADING_TIME ); + } + + return () => { + clearTimeout( timeout ); + }; + }, [ loaded ]); + + useEffect( () => { + if ( inLoadingPause ) { + + /* + * We're using an arbitrary 1s timeout here to catch brief moments + * without any resolving selectors that would result in displaying + * brief flickers of loading state and loaded state. + * + * It's worth experimenting with different values, since this also + * adds 1s of artificial delay after loading has finished. + */ + const timeout = setTimeout( () => { + setLoaded( true ); + }, 1000 ); + + return () => { + clearTimeout( timeout ); + }; + } + }, [ inLoadingPause ]); + + return ! loaded || ! hasLoadedPost; +}; diff --git a/src/index.js b/src/index.js index 0accca0..ded242e 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { registerPlugin } from '@wordpress/plugins'; * Internal dependencies. */ import './style.scss'; +import './store'; import App from './App'; const Render = () => { @@ -22,6 +23,6 @@ const hasFlag = urlParams.get( 'quickwp' ); // If the quickwp query string is present, render the quickwp modal. if ( 'true' === hasFlag ) { registerPlugin( 'quickwp', { - render: Render, - } ); + render: Render + }); } diff --git a/src/parts/ColorPalette.js b/src/parts/ColorPalette.js new file mode 100644 index 0000000..f532415 --- /dev/null +++ b/src/parts/ColorPalette.js @@ -0,0 +1,107 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { BlockPreview } from '@wordpress/block-editor'; + +import { Button, ColorIndicator, TextControl } from '@wordpress/components'; + +import { useDispatch, useSelect } from '@wordpress/data'; + +const palette = [ + { + slug: 'ti-bg', + color: '#FFFFFF', + name: 'Background' + }, + { + slug: 'ti-fg', + color: '#202020', + name: 'Text' + }, + { + slug: 'ti-accent', + color: '#325ce8', + name: 'Accent' + }, + { + slug: 'ti-accent-secondary', + color: '#1B47DA', + name: 'Accent Secondary' + }, + { + slug: 'ti-bg-inv', + color: '#1A1919', + name: 'Dark Background' + }, + { + slug: 'ti-bg-alt', + color: '#f7f7f3', + name: 'Background Alt' + }, + { + slug: 'ti-fg-alt', + color: '#FBFBFB', + name: 'Inverted Text' + } +]; + +const ColorPalette = () => { + const { onContinue } = useDispatch( 'quickwp/data' ); + + const { template } = useSelect( ( select ) => { + const { getBlocks } = select( 'core/block-editor' ); + + return { + template: getBlocks() + }; + }); + + return ( +
+
+

+ { __( + 'Let\'s give your site a color that fits to your brand. What is your primary brand color?', + 'quickwp' + ) } +

+ +
+ + + {} } + /> +
+ +
+ { palette.map( ( color ) => ( + + ) ) } +
+ + +
+ +
+ +
+
+ ); +}; + +export default ColorPalette; diff --git a/src/parts/Header.js b/src/parts/Header.js new file mode 100644 index 0000000..24f6765 --- /dev/null +++ b/src/parts/Header.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies. + */ +import { Logo } from '../utils'; +import PageControl from '../components/PageControl'; + +const Header = () => { + return ( +
+ + +
+ ); +}; + +export default Header; diff --git a/src/parts/SiteDescription.js b/src/parts/SiteDescription.js new file mode 100644 index 0000000..0924054 --- /dev/null +++ b/src/parts/SiteDescription.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { Button, TextareaControl } from '@wordpress/components'; + +import { useDispatch } from '@wordpress/data'; + +import { useState } from '@wordpress/element'; + +import { ENTER } from '@wordpress/keycodes'; + +const SiteDescription = () => { + const [ value, setValue ] = useState( '' ); + + const { onContinue } = useDispatch( 'quickwp/data' ); + + const onEnter = ( e ) => { + if ( ENTER === e.keyCode && !! value ) { + onContinue(); + } + }; + + return ( +
+

+ { __( + 'Great! We\'d love to learn more about your brand to create a tailor-made website just for you.', + 'quickwp' + ) } +

+ + + + +
+ ); +}; + +export default SiteDescription; diff --git a/src/parts/SiteTopic.js b/src/parts/SiteTopic.js new file mode 100644 index 0000000..5ae5259 --- /dev/null +++ b/src/parts/SiteTopic.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +import { Button, TextControl } from '@wordpress/components'; + +import { useDispatch } from '@wordpress/data'; + +import { useState } from '@wordpress/element'; + +import { ENTER } from '@wordpress/keycodes'; + +const SiteTopic = () => { + const [ value, setValue ] = useState( '' ); + + const { onContinue } = useDispatch( 'quickwp/data' ); + + const onEnter = ( e ) => { + if ( ENTER === e.keyCode && !! value ) { + onContinue(); + } + }; + + return ( +
+

+ { __( + 'Welcome. What kind of site are you building?', + 'quickwp' + ) } +

+ + + + +
+ ); +}; + +export default SiteTopic; diff --git a/src/steps.js b/src/steps.js new file mode 100644 index 0000000..77d158d --- /dev/null +++ b/src/steps.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies. + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import SiteTopic from './parts/SiteTopic'; +import SiteDescription from './parts/SiteDescription'; +import ColorPalette from './parts/ColorPalette'; + +const STEPS = [ + { + value: 'site_topic', + label: __( 'Site Topic', 'quickwp' ), + view: SiteTopic + }, + { + value: 'site_description', + label: __( 'Site Description', 'quickwp' ), + view: SiteDescription + }, + { + value: 'color_palette', + label: __( 'Color Palette', 'quickwp' ), + view: ColorPalette + }, + { + value: 'image_suggestions', + label: __( 'Image Suggestions', 'quickwp' ) + }, + { + value: 'frontpage_template', + label: __( 'Front Page Template', 'quickwp' ) + }, + { + value: 'view_site', + label: __( 'View Site', 'quickwp' ) + } +]; + +export default STEPS; diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..f50094a --- /dev/null +++ b/src/store.js @@ -0,0 +1,84 @@ +/** + * WordPress dependencies. + */ +import { createReduxStore, dispatch, register, select } from '@wordpress/data'; + +/** + * Internal dependencies. + */ +import STEPS from './steps'; + +const DEFAULT_STATE = { + step: 2 +}; + +const actions = { + setStep( step ) { + return { + type: 'SET_STEP', + step + }; + }, + nextStep() { + return ({ dispatch, select }) => { + const current = select.getStep(); + const stepIndex = STEPS.findIndex( + ( step ) => current.value === step.value + ); + const isLast = STEPS.length === stepIndex + 1; + const newStep = + STEPS[ isLast ? STEPS.length : stepIndex + 1 ]?.value; + + if ( isLast ) { + return; + } + + dispatch( actions.setStep( newStep ) ); + }; + }, + previousStep() { + return ({ dispatch, select }) => { + const current = select.getStep(); + const stepIndex = STEPS.findIndex( + ( step ) => current.value === step.value + ); + const isFirst = 0 === stepIndex; + const newStep = STEPS[ isFirst ? 0 : stepIndex - 1 ]?.value; + + dispatch( actions.setStep( newStep ) ); + }; + }, + onContinue() { + return ({ dispatch }) => { + dispatch( actions.nextStep() ); + }; + } +}; + +const store = createReduxStore( 'quickwp/data', { + reducer( state = DEFAULT_STATE, action ) { + switch ( action.type ) { + case 'SET_STEP': + const step = STEPS.findIndex( + ( step ) => step.value === action.step + ); + + return { + ...state, + step + }; + } + + return state; + }, + + actions, + + selectors: { + getStep( state ) { + return STEPS[ state.step ]; + } + } +}); + +register( store ); diff --git a/src/style.scss b/src/style.scss index da1a97c..2ddaa7d 100644 --- a/src/style.scss +++ b/src/style.scss @@ -1,2 +1,38 @@ +@import 'tailwindcss/base'; @import 'tailwindcss/components'; @import 'tailwindcss/utilities'; + +#quickwp { + --wp-admin-theme-color: #ffffff; + + .components-text-control__input { + @apply bg-transparent max-w-xl border-0 h-12 text-2xl not-italic font-normal text-fg shadow-none; + } + + .components-textarea-control__input { + @apply bg-transparent max-w-5xl h-24 border-0 text-2xl not-italic font-normal text-fg shadow-none resize-none; + } + + .components-text-control__input::placeholder, + .components-textarea-control__input::placeholder { + @apply text-fg/50 shadow-none; + } + + .components-button { + &.is-primary { + @apply bg-transparent w-fit h-auto border border-solid border-fg text-fg rounded-md px-6 py-4 transition-all text-lg not-italic font-medium; + + &:disabled { + @apply opacity-50; + } + } + } + + .block-editor-block-preview__content { + max-height: none !important; + + iframe { + max-height: none !important; + } + } +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..b5a7059 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,52 @@ +/** + * WordPress dependencies + */ +import { SVG, Circle, G, Path } from '@wordpress/primitives'; + +export const PageControlIcon = ({ isFilled = false }) => { + return ( + + { isFilled ? ( + + ) : ( + + ) } + + ); +}; + +export const Logo = () => { + return ( + + + + ); +}; diff --git a/tailwind.config.js b/tailwind.config.js index 7eddf58..a10a29e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,8 +1,16 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: [ './src/*.js' ], + content: [ './src/style.scss', './src/*.js', './src/**/*.js' ], theme: { - extend: {}, + extend: { + colors: { + bg: '#000000', + fg: '#FFFFFF' + }, + maxHeight: { + '80vh': '80vh' + } + } }, - plugins: [], + plugins: [] };