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

Wired up DB to new Offers Modal (#18980)

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

---

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

This pull request adds the functionality to create new offers for
members in the admin-x-settings app. It uses custom hooks to handle the
form logic and the API mutation, and updates the type definitions and
the UI components accordingly. It also modifies the `offers.ts` file in
the admin-x-framework to support the new feature.

---------

Co-authored-by: Chris Raible <chris@ghost.org>
Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
This commit is contained in:
Ronald Langeveld 2023-11-15 11:52:58 +07:00 committed by GitHub
parent 3bec13774b
commit 452e702530
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 136 additions and 58 deletions

View file

@ -18,19 +18,27 @@ export type Offer = {
redemption_count: number; redemption_count: number;
tier: { tier: {
id: string; id: string;
name: string; name?: string;
} }
} }
export type PartialNewOffer = Omit<Offer, 'redemption_count'>;
export type NewOffer = Partial<Pick<PartialNewOffer, 'id'>> & Omit<PartialNewOffer, 'id'>;
export interface OffersResponseType { export interface OffersResponseType {
meta?: Meta meta?: Meta
offers: Offer[] offers?: Offer[]
} }
export interface OfferEditResponseType extends OffersResponseType { export interface OfferEditResponseType extends OffersResponseType {
meta?: Meta meta?: Meta
} }
export interface OfferAddResponseType {
meta?: Meta,
offers: NewOffer[]
}
const dataType = 'OffersResponseType'; const dataType = 'OffersResponseType';
export const useBrowseOffers = createQuery<OffersResponseType>({ export const useBrowseOffers = createQuery<OffersResponseType>({
@ -53,3 +61,14 @@ export const useEditOffer = createMutation<OfferEditResponseType, Offer>({
update: updateQueryCache('offers') update: updateQueryCache('offers')
} }
}); });
export const useAddOffer = createMutation<OfferAddResponseType, NewOffer>({
method: 'POST',
path: () => '/offers/',
body: offer => ({offers: [offer]}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('offers')
}
});

View file

@ -1,10 +1,12 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PortalFrame from '../../membership/portal/PortalFrame'; import PortalFrame from '../../membership/portal/PortalFrame';
import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField} from '@tryghost/admin-x-design-system'; import useForm from '../../../../hooks/useForm';
import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system';
import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl'; import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl';
import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
import {getTiersCadences} from '../../../../utils/getTiersCadences'; import {getTiersCadences} from '../../../../utils/getTiersCadences';
import {useAddOffer} from '@tryghost/admin-x-framework/api/offers';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useRouting} from '@tryghost/admin-x-framework/routing'; import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -206,6 +208,7 @@ const AddOfferModal = () => {
const {data: {tiers} = {}} = useBrowseTiers(); const {data: {tiers} = {}} = useBrowseTiers();
const activeTiers = getPaidActiveTiers(tiers || []); const activeTiers = getPaidActiveTiers(tiers || []);
const tierCadenceOptions = getTiersCadences(activeTiers); const tierCadenceOptions = getTiersCadences(activeTiers);
const {mutateAsync: addOffer} = useAddOffer();
const [selectedTier, setSelectedTier] = useState({ const [selectedTier, setSelectedTier] = useState({
tier: tierCadenceOptions[0] || {}, tier: tierCadenceOptions[0] || {},
dataset: { dataset: {
@ -214,34 +217,76 @@ const AddOfferModal = () => {
currency: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).currency : '' currency: tierCadenceOptions[0]?.value ? parseData(tierCadenceOptions[0]?.value).currency : ''
} }
}); });
const getDiscountAmount = (discount: number, dctype: string) => {
if (dctype === 'percentageOff') {
return discount.toString();
}
if (dctype === 'currencyOff') {
let calcDiscount = discount * 100;
return calcDiscount.toString();
}
};
const [overrides, setOverrides] = useState<offerPortalPreviewUrlTypes>({ const {formState, updateForm, handleSave, saveState, okProps} = useForm({
disableBackground: true, initialState: {
name: '', disableBackground: true,
code: { name: '',
isDirty: false, code: {
value: '' 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'
}, },
displayTitle: { onSave: async () => {
isDirty: false, const dataset = {
value: '' name: formState.name,
code: formState.code.value,
display_title: formState.displayTitle.value,
display_description: formState.displayDescription,
cadence: formState.cadence,
amount: formState.type === 'trial' ? Number(getDiscountAmount(formState.trialAmount, formState.amountType)) : Number(getDiscountAmount(formState.discountAmount, formState.amountType)),
duration: formState.type === 'trial' ? 'trial' : formState.duration,
duration_in_months: formState.durationInMonths,
currency: formState.currency,
status: formState.status,
tier: {
id: formState.tierId
},
type: formState.type,
currency_restriction: false
};
const response = await addOffer(dataset);
if (response && response.offers && response.offers.length > 0) {
modal.remove();
updateRoute(`offers/${response.offers[0].id}`);
}
}, },
displayDescription: '', onSaveError: () => {},
type: 'percent', onValidate: () => {
cadence: selectedTier?.dataset?.period || '', return {};
trialAmount: 7, },
discountAmount: 0, savingDelay: 500
duration: 'once',
durationInMonths: 0,
currency: selectedTier?.dataset?.currency || '',
status: 'active',
tierId: selectedTier?.dataset?.id || '',
amountType: 'percentageOff'
}); });
const amountOptions = [ const amountOptions = [
{value: 'percentageOff', label: '%'}, {value: 'percentageOff', label: '%'},
{value: 'currencyOff', label: overrides.currency} {value: 'currencyOff', label: formState.currency}
]; ];
const handleTierChange = (tier: SelectOption) => { const handleTierChange = (tier: SelectOption) => {
@ -249,27 +294,26 @@ const AddOfferModal = () => {
tier, tier,
dataset: parseData(tier.value) dataset: parseData(tier.value)
}); });
updateForm(state => ({
setOverrides({ ...state,
...overrides,
tierId: parseData(tier.value).id,
cadence: parseData(tier.value).period, cadence: parseData(tier.value).period,
currency: parseData(tier.value).currency currency: parseData(tier.value).currency,
}); tierId: parseData(tier.value).id
}));
}; };
const handleTypeChange = (type: string) => { const handleTypeChange = (type: string) => {
setOverrides({ updateForm(state => ({
...overrides, ...state,
type: type type: type
}); }));
}; };
const handleAmountTypeChange = (amountType: string) => { const handleAmountTypeChange = (amountType: string) => {
setOverrides({ updateForm(state => ({
...overrides, ...state,
amountType: amountType amountType: amountType
}); }));
}; };
const handleTextInput = ( const handleTextInput = (
@ -277,16 +321,15 @@ const AddOfferModal = () => {
key: keyof offerPortalPreviewUrlTypes key: keyof offerPortalPreviewUrlTypes
) => { ) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement; const target = e.target as HTMLInputElement | HTMLTextAreaElement;
setOverrides((prevOverrides: offerPortalPreviewUrlTypes) => { updateForm((state) => {
// Extract the current value for the key // Extract the current value for the key
const currentValue = prevOverrides[key]; const currentValue = (state as offerPortalPreviewUrlTypes)[key];
// Check if the current value is an object and has 'isDirty' and 'value' properties // 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) { if (currentValue && typeof currentValue === 'object' && 'isDirty' in currentValue && 'value' in currentValue) {
// Determine if the field has been modified // Determine if the field has been modified
return { return {
...prevOverrides, ...state,
[key]: { [key]: {
...currentValue, ...currentValue,
isDirty: true, isDirty: true,
@ -296,7 +339,7 @@ const AddOfferModal = () => {
} else { } else {
// For simple properties, update the value directly // For simple properties, update the value directly
return { return {
...prevOverrides, ...state,
[key]: target.value [key]: target.value
}; };
} }
@ -305,8 +348,7 @@ const AddOfferModal = () => {
const handleNameInput = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNameInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
updateForm((prevOverrides) => {
setOverrides((prevOverrides) => {
let newOverrides = {...prevOverrides}; let newOverrides = {...prevOverrides};
newOverrides.name = newValue; newOverrides.name = newValue;
if (!prevOverrides.code.isDirty) { if (!prevOverrides.code.isDirty) {
@ -327,17 +369,17 @@ const AddOfferModal = () => {
const handleTextAreaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleTextAreaInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement; const target = e.target as HTMLTextAreaElement;
setOverrides({ updateForm(state => ({
...overrides, ...state,
displayDescription: target.value displayDescription: target.value
}); }));
}; };
const handleDurationChange = (duration: string) => { const handleDurationChange = (duration: string) => {
setOverrides({ updateForm(state => ({
...overrides, ...state,
duration: duration duration: duration
}); }));
}; };
useEffect(() => { useEffect(() => {
@ -353,9 +395,9 @@ const AddOfferModal = () => {
}; };
useEffect(() => { useEffect(() => {
const newHref = getOfferPortalPreviewUrl(overrides, siteData.url); const newHref = getOfferPortalPreviewUrl(formState, siteData.url);
setHref(newHref); setHref(newHref);
}, [overrides, siteData.url]); }, [formState, siteData.url]);
const sidebar = <Sidebar const sidebar = <Sidebar
amountOptions={amountOptions as SelectOption[]} amountOptions={amountOptions as SelectOption[]}
@ -367,7 +409,7 @@ const AddOfferModal = () => {
handleTextInput={handleTextInput} handleTextInput={handleTextInput}
handleTierChange={handleTierChange} handleTierChange={handleTierChange}
handleTypeChange={handleTypeChange} handleTypeChange={handleTypeChange}
overrides={overrides} overrides={formState}
selectedTier={selectedTier.tier} selectedTier={selectedTier.tier}
tierOptions={tierCadenceOptions} tierOptions={tierCadenceOptions}
typeOptions={typeOptions} typeOptions={typeOptions}
@ -376,8 +418,25 @@ const AddOfferModal = () => {
const iframe = <PortalFrame const iframe = <PortalFrame
href={href} href={href}
/>; />;
return <PreviewModalContent
return <PreviewModalContent cancelLabel='Cancel' deviceSelector={false} okLabel='Publish' preview={iframe} sidebar={sidebar} size='full' title='Offer' onCancel={cancelAddOffer} />; cancelLabel='Cancel'
deviceSelector={false}
dirty={saveState === 'unsaved'}
okColor={okProps.color}
okLabel='Publish'
preview={iframe}
sidebar={sidebar}
size='full'
title='Offer'
onCancel={cancelAddOffer}
onOk={async () => {
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields.'
});
}
}} />;
}; };
export default NiceModal.create(AddOfferModal); export default NiceModal.create(AddOfferModal);

View file

@ -130,11 +130,11 @@ const OffersModal = () => {
currency={offer?.currency || 'USD'} currency={offer?.currency || 'USD'}
duration={offer?.duration} duration={offer?.duration}
name={offer?.name} name={offer?.name}
offerId={offer?.id} offerId={offer?.id ? offer.id : ''}
offerTier={offerTier} offerTier={offerTier}
redemptionCount={offer?.redemption_count} redemptionCount={offer?.redemption_count ? offer.redemption_count : 0}
type={offer?.type as OfferType} type={offer?.type as OfferType}
onClick={() => handleOfferEdit(offer?.id)} onClick={() => handleOfferEdit(offer?.id ? offer.id : '')}
/> />
); );
})} })}
@ -156,7 +156,7 @@ const OffersModal = () => {
<td>{offerTier.name} {getOfferCadence(offer.cadence)}</td> <td>{offerTier.name} {getOfferCadence(offer.cadence)}</td>
<td><span className={`text-xs font-semibold uppercase ${discountColor}`}>{discountOffer}</span></td> <td><span className={`text-xs font-semibold uppercase ${discountColor}`}>{discountOffer}</span></td>
<td>{updatedPriceWithCurrency}{originalPriceWithCurrency}</td> <td>{updatedPriceWithCurrency}{originalPriceWithCurrency}</td>
<td><a className='hover:underline' href={createRedemptionFilterUrl(offer.id)}>{offer.redemption_count}</a></td> <td><a className='hover:underline' href={createRedemptionFilterUrl(offer.id ? offer.id : '')}>{offer.redemption_count}</a></td>
</tr> </tr>
); );
})} })}