0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Wired up Add Offers modal portal rendering in settings (#18923)

refs
https://www.notion.so/ghost/e0fd19a18fc449a68eddc0d692a20314?v=13f7ea775a5549d1b767bfdbe5dfa002&p=920d06c82eb94dba9b7eaabfa02c4e26&pm=s

- started adding input functionality

---

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

Improved the functionality and design of the offer modal in the
membership settings. Added dynamic data and state management for the
modal inputs and preview URL. Extracted reusable components, hooks, and
utility functions from the `Sidebar` and `AddOfferModal` components.
This commit is contained in:
Ronald Langeveld 2023-11-13 17:35:07 +07:00 committed by GitHub
parent e7ade50546
commit 660f5fef6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 347 additions and 83 deletions

View file

@ -2,20 +2,34 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PortalFrame from '../portal/PortalFrame';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useRouting from '../../../../hooks/useRouting';
import {Form, Icon, PreviewModalContent, Select, TextArea, TextField} from '@tryghost/admin-x-design-system';
import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField} from '@tryghost/admin-x-design-system';
import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl';
import {useEffect} from 'react';
import {getPaidActiveTiers, useBrowseTiers} from '../../../../api/tiers';
import {getTiersCadences} from '../../../../utils/getTiersCadences';
import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
// we should replace this with a library
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w\-]+/g, '')
.replace(/\-\-+/g, '-');
}
interface OfferType {
title: string;
description: string;
}
const ButtonSelect: React.FC<{type: OfferType, checked: boolean}> = ({type, checked}) => {
const ButtonSelect: React.FC<{type: OfferType, checked: boolean, onClick: () => void}> = ({type, checked, onClick}) => {
const checkboxClass = checked ? 'bg-black text-white' : 'border border-grey-300';
return (
<button className='text-left' type='button'>
<button className='text-left' type='button' onClick={onClick}>
<div className='flex gap-3'>
<div className={`mt-0.5 flex h-4 w-4 items-center justify-center rounded-full ${checkboxClass}`}>
{checked ? <Icon className='w-2 stroke-[4]' name='check' size='custom' /> : null}
@ -29,32 +43,35 @@ const ButtonSelect: React.FC<{type: OfferType, checked: boolean}> = ({type, chec
);
};
const Sidebar: React.FC = () => {
const typeOptions = [
{title: 'Discount', description: 'Offer a special reduced price'},
{title: 'Free trial', description: 'Give free access for a limited time'}
];
const tierCadenceOptions = [
{value: '1', label: 'Bronze — Monthly'},
{value: '2', label: 'Bronze — Yearly'},
{value: '3', label: 'Silver — Monthly'},
{value: '4', label: 'Silver — Yearly'},
{value: '5', label: 'Gold — Monthly'},
{value: '6', label: 'Gold — Yearly'}
];
const amountOptions = [
{value: '1', label: '%'},
{value: '2', label: 'USD'}
];
const durationOptions = [
{value: '1', label: 'First-payment'},
{value: '2', label: 'Multiple-months'},
{value: '3', label: 'Forever'}
];
type SidebarProps = {
tierOptions: SelectOption[];
handleTierChange: (tier: SelectOption) => void;
selectedTier: SelectOption;
overrides: offerPortalPreviewUrlTypes
handleTextInput: (e: React.ChangeEvent<HTMLInputElement>, key: keyof offerPortalPreviewUrlTypes) => void;
amountOptions: SelectOption[];
typeOptions: OfferType[];
durationOptions: SelectOption[];
handleTypeChange: (type: string) => void;
handleDurationChange: (duration: string) => void;
handleAmountTypeChange: (amountType: string) => void;
handleNameInput: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleTextAreaInput: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
};
const Sidebar: React.FC<SidebarProps> = ({tierOptions,
handleTierChange,
selectedTier,
handleTextInput,
typeOptions,
durationOptions,
handleTypeChange,
handleDurationChange,
overrides,
handleAmountTypeChange,
handleNameInput,
handleTextAreaInput,
amountOptions}) => {
return (
<div className='pt-7'>
<Form>
@ -62,38 +79,69 @@ const Sidebar: React.FC = () => {
hint='Visible to members on Stripe Checkout page.'
placeholder='Black Friday'
title='Name'
onChange={(e) => {
handleNameInput(e);
}}
/>
<section className='mt-4'>
<h2 className='mb-4 text-lg'>Offer details</h2>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-4 rounded-md border border-grey-200 p-4'>
<ButtonSelect checked={true} type={typeOptions[0]} />
<ButtonSelect checked={false} type={typeOptions[1]} />
<ButtonSelect checked={overrides.type === 'percent' ? true : false} type={typeOptions[0]} onClick={() => {
handleTypeChange('percent');
}} />
<ButtonSelect checked={overrides.type === 'trial' ? true : false} type={typeOptions[1]} onClick={() => {
handleTypeChange('trial');
}} />
</div>
<Select
options={tierCadenceOptions}
selectedOption={tierCadenceOptions[0]}
options={tierOptions}
selectedOption={selectedTier}
title='Tier — Cadence'
onSelect={() => {}}
onSelect={(e) => {
if (e) {
handleTierChange(e);
}
}}
/>
<div className='relative'>
<TextField title='Amount off' type='number' />
<div className='absolute bottom-0 right-1.5 z-10 w-10'>
<Select
clearBg={true}
controlClasses={{menu: 'w-20 right-0'}}
options={amountOptions}
selectedOption={amountOptions[0]}
onSelect={() => {}}
/>
{
overrides.type !== 'trial' && <> <div className='relative'>
<TextField title='Amount off' type='number' onChange={(e) => {
handleTextInput(e, 'discountAmount');
}} />
<div className='absolute bottom-0 right-1.5 z-10 w-10'>
<Select
clearBg={true}
controlClasses={{menu: 'w-20 right-0'}}
options={amountOptions}
selectedOption={overrides.amountType === 'percentageOff' ? amountOptions[0] : amountOptions[1]}
onSelect={(e) => {
handleAmountTypeChange(e?.value || '');
}}
/>
</div>
</div>
</div>
<Select
options={durationOptions}
selectedOption={durationOptions[0]}
title='Duration'
onSelect={() => {}}
/>
<Select
options={durationOptions}
selectedOption={overrides.duration === 'once' ? durationOptions[0] : overrides.duration === 'repeating' ? durationOptions[1] : durationOptions[2]}
title='Duration'
onSelect={e => handleDurationChange(e?.value || '')}
/>
{
overrides.duration === 'repeating' && <TextField title='Duration in months' type='number' onChange={(e) => {
handleTextInput(e, 'durationInMonths');
}} />
}
</>
}
{
overrides.type === 'trial' && <TextField title='Trial duration' type='number' value={overrides.trialAmount?.toString()} onChange={(e) => {
handleTextInput(e, 'trialAmount');
}} />
}
</div>
</section>
<section className='mt-4'>
@ -102,14 +150,26 @@ const Sidebar: React.FC = () => {
<TextField
placeholder='Black Friday Special'
title='Display title'
value={overrides.displayTitle.value}
onChange={(e) => {
handleTextInput(e, 'displayTitle');
}}
/>
<TextField
placeholder='black-friday'
title='Offer code'
value={overrides.code.value}
onChange={(e) => {
handleTextInput(e, 'code');
}}
/>
<TextArea
placeholder='Take advantage of this limited-time offer.'
title='Display description'
value={overrides.displayDescription}
onChange={(e) => {
handleTextAreaInput(e);
}}
/>
</div>
</section>
@ -118,12 +178,167 @@ const Sidebar: React.FC = () => {
);
};
const parseData = (input: string): { id: string; period: string; currency: string } => {
const [id, period, currency] = input.split('-');
if (!id || !period || !currency) {
throw new Error('Invalid input format. Expected format is: id-period-currency');
}
return {id, period, currency};
};
const AddOfferModal = () => {
const {siteData} = useGlobalData();
const typeOptions = [
{title: 'Discount', description: 'Offer a special reduced price', value: 'percent'},
{title: 'Free trial', description: 'Give free access for a limited time', value: 'trial'}
];
const durationOptions = [
{value: 'once', label: 'First-payment'},
{value: 'repeating', label: 'Multiple-months'},
{value: 'forever', label: 'Forever'}
];
const [href, setHref] = useState<string>('');
const modal = useModal();
const {updateRoute} = useRouting();
const hasOffers = useFeatureFlag('adminXOffers');
// const {data: {tiers, meta, isEnd} = {}} = useBrowseTiers();
// const activeTiers = getActiveTiers(tiers || []);
const {data: {tiers} = {}} = useBrowseTiers();
const activeTiers = getPaidActiveTiers(tiers || []);
const tierCadenceOptions = getTiersCadences(activeTiers);
const [selectedTier, setSelectedTier] = useState({
tier: tierCadenceOptions[0] || {},
dataset: {
id: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).id : '',
period: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).period : '',
currency: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).currency : ''
}
});
const [overrides, setOverrides] = useState<offerPortalPreviewUrlTypes>({
disableBackground: true,
name: '',
code: {
isDirty: false,
value: ''
},
displayTitle: {
isDirty: false,
value: ''
},
displayDescription: '',
type: 'percent',
cadence: selectedTier?.dataset?.period || '',
trialAmount: 7,
discountAmount: 0,
duration: 'once',
durationInMonths: 0,
currency: selectedTier?.dataset?.currency || '',
status: 'active',
tierId: selectedTier?.dataset?.id || '',
amountType: 'percentageOff'
});
const amountOptions = [
{value: 'percentageOff', label: '%'},
{value: 'currencyOff', label: overrides.currency}
];
const handleTierChange = (tier: SelectOption) => {
setSelectedTier({
tier,
dataset: parseData(tier.value)
});
setOverrides({
...overrides,
tierId: parseData(tier.value).id,
cadence: parseData(tier.value).period,
currency: parseData(tier.value).currency
});
};
const handleTypeChange = (type: string) => {
setOverrides({
...overrides,
type: type
});
};
const handleAmountTypeChange = (amountType: string) => {
setOverrides({
...overrides,
amountType: amountType
});
};
const handleTextInput = (
e: React.ChangeEvent<HTMLInputElement>,
key: keyof offerPortalPreviewUrlTypes
) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement;
setOverrides((prevOverrides: offerPortalPreviewUrlTypes) => {
// Extract the current value for the key
const currentValue = prevOverrides[key];
// Check if the current value is an object and has 'isDirty' and 'value' properties
if (currentValue && typeof currentValue === 'object' && 'isDirty' in currentValue && 'value' in currentValue) {
// Determine if the field has been modified
return {
...prevOverrides,
[key]: {
...currentValue,
isDirty: true,
value: target.value
}
};
} else {
// For simple properties, update the value directly
return {
...prevOverrides,
[key]: target.value
};
}
});
};
const handleNameInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setOverrides((prevOverrides) => {
let newOverrides = {...prevOverrides};
newOverrides.name = newValue;
if (!prevOverrides.code.isDirty) {
newOverrides.code = {
...prevOverrides.code,
value: slugify(newValue)
};
}
if (!prevOverrides.displayTitle.isDirty) {
newOverrides.displayTitle = {
...prevOverrides.displayTitle,
value: newValue
};
}
return newOverrides;
});
};
const handleTextAreaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
setOverrides({
...overrides,
displayDescription: target.value
});
};
const handleDurationChange = (duration: string) => {
setOverrides({
...overrides,
duration: duration
});
};
useEffect(() => {
if (!hasOffers) {
@ -137,29 +352,29 @@ const AddOfferModal = () => {
updateRoute('offers/edit');
};
const sidebar = <Sidebar />;
// TODO: wire up the data from the sidebar inputs
let overrides : offerPortalPreviewUrlTypes = {
disableBackground: true,
name: 'Black Friday',
code: 'black-friday',
displayTitle: 'Black Friday Special',
displayDescription: 'Take advantage of this limited-time offer.',
type: 'discount',
cadence: 'monthly',
amount: 1200,
duration: '',
durationInMonths: 12,
currency: 'USD',
status: 'active',
tierId: ''
};
useEffect(() => {
const newHref = getOfferPortalPreviewUrl(overrides, siteData.url);
setHref(newHref);
}, [overrides, siteData.url]);
const href = getOfferPortalPreviewUrl(overrides, 'http://localhost:2368');
const sidebar = <Sidebar
amountOptions={amountOptions as SelectOption[]}
durationOptions={durationOptions}
handleAmountTypeChange={handleAmountTypeChange}
handleDurationChange={handleDurationChange}
handleNameInput={handleNameInput}
handleTextAreaInput={handleTextAreaInput}
handleTextInput={handleTextInput}
handleTierChange={handleTierChange}
handleTypeChange={handleTypeChange}
overrides={overrides}
selectedTier={selectedTier.tier}
tierOptions={tierCadenceOptions}
typeOptions={typeOptions}
/>;
const iframe = <PortalFrame
href={href}
/>;
return <PreviewModalContent cancelLabel='Cancel' deviceSelector={false} okLabel='Publish' preview={iframe} sidebar={sidebar} size='full' title='Offer' onCancel={cancelAddOffer} />;

View file

@ -1,29 +1,46 @@
export type offerPortalPreviewUrlTypes = {
disableBackground?: boolean;
name: string;
code: string;
displayTitle?: string;
code: {
isDirty?: boolean;
value: string;
}
displayTitle: {
isDirty?: boolean;
value: string;
}
displayDescription?: string;
type: string;
cadence: string;
amount?: number;
trialAmount?: number;
discountAmount?: number;
percentageOff?: number;
duration: string;
durationInMonths: number;
currency?: string;
status: string;
tierId: string;
amountType?: string;
};
export const getOfferPortalPreviewUrl = (overrides:offerPortalPreviewUrlTypes, baseUrl: string) : string => {
const {
disableBackground = false,
name,
code,
displayTitle = '',
code = {
isDirty: false,
value: ''
},
displayTitle = {
isDirty: false,
value: ''
},
displayDescription = '',
type,
cadence,
amount = 0,
trialAmount = 7,
discountAmount = 0,
amountType,
duration,
durationInMonths,
currency = 'usd',
@ -34,18 +51,30 @@ export const getOfferPortalPreviewUrl = (overrides:offerPortalPreviewUrlTypes, b
const portalBase = '/#/portal/preview/offer';
const settingsParam = new URLSearchParams();
settingsParam.append('name', encodeURIComponent(name));
settingsParam.append('code', encodeURIComponent(code));
settingsParam.append('display_title', encodeURIComponent(displayTitle));
settingsParam.append('display_description', encodeURIComponent(displayDescription));
settingsParam.append('type', encodeURIComponent(type));
const getDiscountAmount = (discount: number, dctype: string) => {
if (dctype === 'percentageOff') {
return discount.toString();
}
if (dctype === 'currencyOff') {
settingsParam.append('type', encodeURIComponent('fixed'));
let calcDiscount = discount * 100;
return calcDiscount.toString();
}
};
settingsParam.append('name', encodeURIComponent(name));
settingsParam.append('code', encodeURIComponent(code.value));
settingsParam.append('display_title', encodeURIComponent(displayTitle.value));
settingsParam.append('display_description', encodeURIComponent(displayDescription));
settingsParam.append('cadence', encodeURIComponent(cadence));
settingsParam.append('amount', encodeURIComponent(amount));
settingsParam.append('duration', encodeURIComponent(duration));
settingsParam.append('duration_in_months', encodeURIComponent(durationInMonths));
settingsParam.append('currency', encodeURIComponent(currency));
settingsParam.append('status', encodeURIComponent(status));
settingsParam.append('tier_id', encodeURIComponent(tierId));
settingsParam.append('amount', encodeURIComponent(type === 'trial' ? trialAmount.toString() : getDiscountAmount(discountAmount, amountType ? amountType : 'currencyOff') || '0'));
if (disableBackground) {
settingsParam.append('disableBackground', 'true');

View file

@ -0,0 +1,20 @@
import {SelectOption} from '@tryghost/admin-x-design-system';
import {Tier} from '../api/tiers';
export const getTiersCadences = (tiers: Tier[]): SelectOption[] => {
const cadences: SelectOption[] = [];
tiers.forEach((tier: Tier) => {
cadences.push({
label: `${tier.name} - Monthly`,
value: `${tier.id}-month-${tier.currency}`
});
cadences.push({
label: `${tier.name} - Yearly`,
value: `${tier.id}-year-${tier.currency}`
});
});
return cadences;
};