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:
parent
e7ade50546
commit
660f5fef6f
3 changed files with 347 additions and 83 deletions
|
@ -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} />;
|
||||
|
|
|
@ -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');
|
||||
|
|
20
apps/admin-x-settings/src/utils/getTiersCadences.ts
Normal file
20
apps/admin-x-settings/src/utils/getTiersCadences.ts
Normal 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;
|
||||
};
|
Loading…
Add table
Reference in a new issue