Wired up embeddable signup form to Admin X (#18010)

refs https://github.com/TryGhost/Product/issues/3819

- Wired up embeddable signup form to admin x.
- minus the colour picker, to add in the next commit.
---

<!-- Leave the line below if you'd like GitHub Copilot to generate a
summary from your commit -->
<!--
copilot:summary
-->
### <samp>🤖 Generated by Copilot at 9a3f1b9</samp>

This pull request introduces a new feature that allows users to embed a
signup form for their blog site on other websites. It adds a new
component `EmbedSignupFormModal` that renders a modal with form
customization and code copying options. It also updates the `Config`
type and the `config.ts` file to store and access the necessary data for
the embed code generation.
This commit is contained in:
Ronald Langeveld 2023-09-08 16:21:05 +07:00 committed by GitHub
parent e9bff23aa9
commit b0662d2cf9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 455 additions and 127 deletions

View File

@ -11,6 +11,11 @@ export type Config = {
url: string
version: string
};
signupForm: {
url: string,
version: string
}
blogUrl: string;
labs: Record<string, boolean>;
stripeDirect: boolean;
hostSettings?: {

View File

@ -57,7 +57,7 @@ const UnsplashModal = () => import('../settings/advanced/integrations/UnsplashMo
const UserDetailModal = () => import('../settings/general/UserDetailModal');
const ZapierModal = () => import('../settings/advanced/integrations/ZapierModal');
const AnnouncementBarModal = () => import('../settings/site/AnnouncementBarModal');
const EmbedSignupFormModal = () => import('../settings/membership/EmbedSignupFormModal');
const EmbedSignupFormModal = () => import('../settings/membership/embedSignup/EmbedSignupFormModal');
const modalPaths: {[key: string]: () => Promise<{default: React.FC<NiceModalHocProps & RoutingModalProps>}>} = {
'design/edit/themes': ChangeThemeModal,

View File

@ -1,122 +0,0 @@
import Button from '../../../admin-x-ds/global/Button';
import ColorIndicator from '../../../admin-x-ds/global/form/ColorIndicator';
import Form from '../../../admin-x-ds/global/form/Form';
import Heading from '../../../admin-x-ds/global/Heading';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import MultiSelect from '../../../admin-x-ds/global/form/MultiSelect';
import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio';
import TextArea from '../../../admin-x-ds/global/form/TextArea';
import useRouting from '../../../hooks/useRouting';
const Preview: React.FC = () => {
return (
<div className='hidden rounded-md bg-grey-100 text-grey-600 tablet:!visible tablet:!block'>
preview
</div>
);
};
const Sidebar: React.FC = () => {
return (
<div className='flex h-full flex-col justify-between'>
<div>
<Heading className='mb-4' level={4}>Embed signup form</Heading>
<Form>
<Radio
id='embed-layout'
options={[
{
label: 'Branded',
value: 'branded'
},
{
label: 'Minimal',
value: 'minimal'
}
]}
selectedOption='branded'
title='Layout'
onSelect={() => {}}
/>
<ColorIndicator
isExpanded={false}
swatches={[
{
hex: '#08090c',
title: 'Dark'
},
{
hex: '#ffffff',
title: 'Light'
},
{
hex: '#ffdd00',
title: 'Accent'
}
]}
swatchSize='lg'
title='Background color'
onSwatchChange={() => {}}
onTogglePicker={() => {}}
/>
<MultiSelect
hint='Will be applied to all members signing up via this form'
options={[
{
label: 'Steph',
value: 'steph'
},
{
label: 'Klay',
value: 'klay'
},
{
label: 'Loons',
value: 'loons'
}
]}
placeholder='Pick one or more labels (optional)'
title='Labels at signup'
values={[]}
onChange={() => {}}
/>
<TextArea
className='text-grey-800'
clearBg={false}
fontStyle='mono'
hint={`Paste this code onto any website where you'd like your signup to appear.`}
title='Embed code'
value={`<div style="height: 40vmin;min-height: 360px"><script src="https://cdn.jsdelivr.net/ghost/signup-form@~0.1/umd/signup-form.min.js" data-background-color="#F1F3F4" data-text-color="#000000" data-button-color="#d74780" data-button-text-color="#FFFFFF" data-title="Zimo&#039;s Secret Volcano Lair" data-description="You Know, I Have One Simple Request, And That Is To Have Sharks With Frickin&#039; Laser Beams Attached To Their Heads!" data-site="http://localhost:2368" async></script></div>`}
/>
</Form>
</div>
<Button className='self-end' color='black' label='Copy code' />
</div>
);
};
const EmbedSignupFormModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
return (
<Modal
afterClose={() => {
updateRoute('embed-signup-form');
}}
cancelLabel=''
footer={false}
size={1120}
testId='embed-signup-form'
title=''
topRightContent='close'
>
<div className='grid grid-cols-1 gap-6 pb-8 md:grid-cols-[5.5fr_2.5fr]'>
<Preview />
<Sidebar />
</div>
</Modal>
);
});
export default EmbedSignupFormModal;

View File

@ -1,6 +1,6 @@
import Access from './Access';
import Analytics from './Analytics';
import EmbedSignupForm from './EmbedSignupForm';
import EmbedSignupForm from './embedSignup/EmbedSignupForm';
import Portal from './Portal';
import React from 'react';
import Recommendations from '../site/Recommendations';

View File

@ -1,7 +1,7 @@
import Button from '../../../admin-x-ds/global/Button';
import Button from '../../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
import SettingGroup from '../../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../../hooks/useRouting';
const EmbedSignupForm: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();

View File

@ -0,0 +1,122 @@
import EmbedSignupPreview from './EmbedSignupPreview';
import EmbedSignupSidebar, {SelectedLabelTypes} from './EmbedSignupSidebar';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect';
import {MultiValue} from 'react-select';
import {generateCode} from '../../../../utils/generateEmbedCode';
import {getSettingValues} from '../../../../api/settings';
import {useBrowseLabels} from '../../../../api/labels';
import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
const EmbedSignupFormModal = NiceModal.create(() => {
let i18nEnabled = false;
const [selectedColor, setSelectedColor] = useState<string>('#08090c');
const [selectedLabels, setSelectedLabels] = useState<SelectedLabelTypes[]>([]);
const [selectedLayout, setSelectedLayout] = useState<string>('all-in-one');
const [embedScript, setEmbedScript] = useState<string>('');
const [isCopied, setIsCopied] = useState(false);
const {updateRoute} = useRouting();
const {config} = useGlobalData();
const {localSettings, siteData} = useSettingGroup();
const [accentColor, title, description, locale, labs, icon] = getSettingValues<string>(localSettings, ['accent_color', 'title', 'description', 'locale', 'labs', 'icon']);
const {data: labels} = useBrowseLabels();
if (labs) {
i18nEnabled = JSON.parse(labs).i18n;
}
useEffect(() => {
if (!siteData) {
return;
}
const code = generateCode({
preview: true,
config: {
blogUrl: siteData.url,
signupForm: {
url: config?.signupForm?.url,
version: config?.signupForm?.version
}
},
settings: {
accentColor: accentColor || '#d74780',
title: title || '',
locale: locale || 'en',
icon: icon || '',
description: description || ''
},
labels: selectedLabels.map(({label}) => ({name: label})),
backgroundColor: selectedColor || '#08090c',
layout: selectedLayout,
i18nEnabled
});
setEmbedScript(code);
}, [siteData, accentColor, selectedLabels, config, title, selectedColor, selectedLayout, locale, i18nEnabled, icon, description]);
const handleCopyClick = async () => {
try {
await navigator.clipboard.writeText(embedScript);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000); // reset after 2 seconds
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to copy text: ', err);
}
};
const handleColorToggle = (e:string) => {
setSelectedColor(e);
};
const addSelectedLabel = (selected: MultiValue<MultiSelectOption>) => {
if (selected?.length) {
const chosenLabels = selected?.map(({value}) => ({label: value, value: value}));
setSelectedLabels(chosenLabels);
} else {
setSelectedLabels([]);
}
};
return (
<Modal
afterClose={() => {
updateRoute('embed-signup-form');
}}
cancelLabel=''
footer={false}
size={1120}
testId='embed-signup-form'
title=''
topRightContent='close'
>
<div className='grid grid-cols-[5.5fr_2.5fr] gap-6 pb-8'>
<EmbedSignupPreview
html={embedScript}
style={selectedLayout}
/>
<EmbedSignupSidebar
accentColor={accentColor}
embedScript={embedScript}
handleColorToggle={handleColorToggle}
handleCopyClick={handleCopyClick}
handleLabelClick={addSelectedLabel}
handleLayoutSelect={setSelectedLayout}
isCopied={isCopied}
labels={labels?.labels || []}
selectedColor={selectedColor}
selectedLabels={selectedLabels}
selectedLayout={selectedLayout}
/>
</div>
</Modal>
);
});
export default EmbedSignupFormModal;

View File

@ -0,0 +1,74 @@
import React, {useEffect, useRef, useState} from 'react';
type EmbedSignupPreviewProps = {
html: string;
style: string;
};
const EmbedSignupPreview: React.FC<EmbedSignupPreviewProps> = ({html, style}) => {
const [visibleIframeIndex, setVisibleIframeIndex] = useState(0);
const iframes = [useRef<HTMLIFrameElement>(null), useRef<HTMLIFrameElement>(null)];
const updateIframeContent = (index: number) => {
const iframe = iframes[index].current;
if (!iframe) {
return;
}
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
if (!iframeDoc) {
return;
}
const docString = `
<html>
<head>
<style>body, html {padding: 0; margin: 0; overflow: hidden;}</style>
<style>${style}</style>
</head>
<body>${html}</body>
</html>
`;
iframeDoc.open();
iframeDoc.write(docString);
iframeDoc.close();
};
useEffect(() => {
const invisibleIframeIndex = visibleIframeIndex === 0 ? 1 : 0;
updateIframeContent(invisibleIframeIndex);
const timer = setTimeout(() => {
setVisibleIframeIndex(invisibleIframeIndex);
}, 100);
return () => {
clearTimeout(timer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, style]);
return (
<div className="relative">
<iframe
ref={iframes[0]}
// allowTransparency={true}
className={`absolute h-full w-full transition-opacity duration-500 ${visibleIframeIndex !== 0 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
frameBorder="0"
title="Signup Form Preview 1"
></iframe>
<iframe
ref={iframes[1]}
// allowTransparency={true}
className={`absolute h-full w-full transition-opacity duration-500 ${visibleIframeIndex !== 1 ? 'z-10 opacity-0' : 'z-20 opacity-100'}`}
frameBorder="0"
title="Signup Form Preview 2"
></iframe>
</div>
);
};
export default EmbedSignupPreview;

View File

@ -0,0 +1,124 @@
import Button from '../../../../admin-x-ds/global/Button';
import ColorIndicator from '../../../../admin-x-ds/global/form/ColorIndicator';
import Form from '../../../../admin-x-ds/global/form/Form';
import Heading from '../../../../admin-x-ds/global/Heading';
import MultiSelect, {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect';
import Radio from '../../../../admin-x-ds/global/form/Radio';
import React from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import {Label} from '../../../../api/labels';
import {MultiValue} from 'react-select';
export type SelectedLabelTypes = {
label: string;
value: string;
};
type SidebarProps = {
selectedColor?: string;
accentColor?: string;
handleColorToggle: (e: string) => void;
labels?: Label[];
handleLabelClick: (selected: MultiValue<MultiSelectOption>) => void;
selectedLabels?: SelectedLabelTypes[];
embedScript: string;
handleLayoutSelect: React.Dispatch<React.SetStateAction<string>>;
selectedLayout : string;
handleCopyClick: () => void;
isCopied: boolean;
};
const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
accentColor,
handleColorToggle,
selectedColor,
labels,
selectedLabels,
handleLabelClick,
embedScript,
handleLayoutSelect,
handleCopyClick,
isCopied}) => {
const labelOptions = labels ? labels.map((l) => {
return {
label: l?.name,
value: l?.name
};
}).filter(Boolean) : [];
return (
<div className='flex h-full flex-col justify-between'>
<div>
<Heading className='mb-4' level={4}>Embed signup form</Heading>
<Form>
<Radio
id='embed-layout'
options={[
{
label: 'Branded',
value: 'all-in-one'
},
{
label: 'Minimal',
value: 'minimal'
}
]}
selectedOption={selectedLayout}
title='Layout'
onSelect={(value) => {
handleLayoutSelect(value);
}}
/>
{
selectedLayout === 'all-in-one' &&
<ColorIndicator
isExpanded={false}
swatches={[
{
hex: '#08090c',
title: 'Dark'
},
{
hex: '#ffffff',
title: 'Light'
},
{
hex: (accentColor || '#d74780'),
title: 'Accent'
}
]}
swatchSize='lg'
title='Background color'
value={selectedColor}
onSwatchChange={(e) => {
if (e) {
handleColorToggle(e);
}
}}
onTogglePicker={() => {}}
/>
}
<MultiSelect
hint='Will be applied to all members signing up via this form'
options={labelOptions}
placeholder='Pick one or more labels (optional)'
title='Labels at signup'
values={selectedLabels || []}
onChange={handleLabelClick}
/>
<TextArea
className='text-grey-800'
clearBg={false}
fontStyle='mono'
hint={`Paste this code onto any website where you'd like your signup to appear.`}
title='Embed code'
value={`${embedScript}`}
onChange={() => {}}
/>
</Form>
</div>
<Button className='self-end' color={isCopied ? 'green' : 'black'} label={isCopied ? 'Copied!' : 'Copy code'} onClick={handleCopyClick} />
</div>
);
};
export default EmbedSignupSidebar;

View File

@ -0,0 +1,11 @@
export function escapeHtml(unsafe:string) {
if (!unsafe) {
return '';
}
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@ -0,0 +1,108 @@
import {escapeHtml} from './escapeHtml';
import {textColorForBackgroundColor} from '@tryghost/color-utils';
export type GenerateCodeOptions = {
preview: boolean;
config: {
blogUrl: string;
signupForm: {
url: string;
version: string;
};
};
settings: {
accentColor: string;
icon?: string;
title?: string;
description?: string;
locale?: string;
};
labels: Array<{ name: string }>;
backgroundColor: string;
layout: string;
i18nEnabled: boolean;
};
type OptionsType = {
site: string;
'button-color': string;
'button-text-color': string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any; // This allows for computed properties like 'label-1', 'label-2', etc.
};
export const generateCode = ({
preview,
config,
settings,
labels,
backgroundColor,
layout,
i18nEnabled
}: GenerateCodeOptions) => {
const siteUrl = config.blogUrl;
const scriptUrl = config.signupForm.url.replace('{version}', config.signupForm.version);
let options: OptionsType = {
site: siteUrl,
'button-color': settings.accentColor,
'button-text-color': textColorForBackgroundColor(settings.accentColor).hex()
};
if (i18nEnabled && settings.locale) {
options.locale = settings.locale;
}
for (const [i, label] of labels.entries()) {
options[`label-${i + 1}`] = label.name;
}
let style = 'min-height: 58px;max-width: 440px;margin: 0 auto;width: 100%';
if (layout === 'all-in-one') {
if (settings.icon && settings.icon !== '') {
options.icon = settings.icon.replace(/\/content\/images\//, '/content/images/size/w192h192/');
}
options.title = settings.title;
options.description = settings.description;
options['background-color'] = backgroundColor;
options['text-color'] = textColorForBackgroundColor(backgroundColor).hex();
style = 'height: 40vmin;min-height: 360px';
}
if (preview) {
if (layout === 'minimal') {
style = 'min-height: 58px; max-width: 440px;width: 100%;position: absolute; left: 50%; top:50%; transform: translate(-50%, -50%);';
} else {
style = 'height: 100vh';
}
}
let dataOptionsString = '';
const preferredOrder = [
'background-color',
'text-color',
'button-color',
'button-text-color',
'title',
'description',
'icon',
'site',
'locale'
];
const sortedKeys = Object.keys(options).sort((a, b) => {
return preferredOrder.indexOf(a) - preferredOrder.indexOf(b);
});
for (const key of sortedKeys) {
const value = options[key];
dataOptionsString += ` data-${key}="${escapeHtml(value)}"`;
}
const code = `<div style="${escapeHtml(style)}"><script src="${encodeURI(scriptUrl)}"${dataOptionsString} async></script></div>`;
if (preview && style === 'minimal') {
return `<div style="position: absolute; z-index: -1; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%), linear-gradient(45deg, #eee 25%, transparent 25%, transparent 75%, #eee 75%);background-size: 16px 16px;background-position: 0 0, 8px 8px;;"></div>${code}`;
}
return code;
};

View File

@ -0,0 +1,6 @@
import {test} from '@playwright/test';
// import {globalDataRequests, mockApi, responseFixtures} from '../../utils/e2e';
test.describe('Signup Embed', async () => {
// TODO - currently having difficulty rendering the iframe in the test
});