Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated portal popup to support selecting and updating tiers #17192

Merged
merged 2 commits into from
Jul 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ type Story = StoryObj<typeof Checkbox>;

export const Default: Story = {
args: {
label: 'Checkbox 1',
id: 'my-radio-button'
label: 'Checkbox 1'
}
};

Expand Down
12 changes: 7 additions & 5 deletions apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import Heading from '../Heading';
import Hint from '../Hint';
import React, {useEffect, useState} from 'react';
import React, {useEffect, useId, useState} from 'react';
import Separator from '../Separator';

interface CheckboxProps {
id: string;
title?: string;
label: string;
value: string;
onChange: (checked: boolean) => void;
error?:boolean;
disabled?: boolean;
error?: boolean;
hint?: React.ReactNode;
checked?: boolean;
separator?: boolean;
}

const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, error, hint, checked, separator}) => {
const Checkbox: React.FC<CheckboxProps> = ({title, label, value, onChange, disabled, error, hint, checked, separator}) => {
const id = useId();
const [isChecked, setIsChecked] = useState(checked);

useEffect(() => {
Expand All @@ -36,6 +37,7 @@ const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, e
<input
checked={isChecked}
className="relative float-left mt-[3px] h-4 w-4 appearance-none border-2 border-solid border-grey-300 outline-none checked:border-green checked:bg-green checked:after:absolute checked:after:-mt-px checked:after:ml-[3px] checked:after:block checked:after:h-[11px] checked:after:w-[6px] checked:after:rotate-45 checked:after:border-[2px] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer focus:shadow-none focus:transition-[border-color_0.2s] dark:border-grey-600 dark:checked:border-green dark:checked:bg-green"
disabled={disabled}
id={id}
type='checkbox'
value={value}
Expand All @@ -52,4 +54,4 @@ const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, e
);
};

export default Checkbox;
export default Checkbox;
12 changes: 10 additions & 2 deletions apps/admin-x-settings/src/components/providers/ServiceProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, {createContext, useContext, useMemo} from 'react';
import setupGhostApi from '../../utils/api';
import useDataService, {DataService, bulkEdit} from '../../utils/dataService';
import useSearchService, {SearchService} from '../../utils/search';
import {OfficialTheme} from '../../models/themes';
import {Tier} from '../../types/api';

export interface FileService {
uploadImage: (file: File) => Promise<string>;
Expand All @@ -11,6 +13,7 @@ interface ServicesContextProps {
fileService: FileService|null;
officialThemes: OfficialTheme[];
search: SearchService
tiers: DataService<Tier>
}

interface ServicesProviderProps {
Expand All @@ -23,7 +26,8 @@ const ServicesContext = createContext<ServicesContextProps>({
api: setupGhostApi({ghostVersion: ''}),
fileService: null,
officialThemes: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true}
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
tiers: {data: [], update: async () => {}}
});

const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, officialThemes}) => {
Expand All @@ -35,13 +39,15 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
}
}), [apiService]);
const search = useSearchService();
const tiers = useDataService({key: 'tiers', browse: apiService.tiers.browse, edit: bulkEdit('tiers', apiService.tiers.edit)});

return (
<ServicesContext.Provider value={{
api: apiService,
fileService,
officialThemes,
search
search,
tiers
}}>
{children}
</ServicesContext.Provider>
Expand All @@ -57,3 +63,5 @@ export const useApi = () => useServices().api;
export const useOfficialThemes = () => useServices().officialThemes;

export const useSearch = () => useServices().search;

export const useTiers = () => useServices().tiers;
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {Config, Setting, SiteData} from '../../types/api';
import {ServicesContext} from './ServiceProvider';
import {Setting, SiteData} from '../../types/api';

// Define the Settings Context
interface SettingsContextProps {
settings: Setting[] | null;
saveSettings: (updatedSettings: Setting[]) => Promise<Setting[]>;
siteData: SiteData | null;
config: Config | null;
}

interface SettingsProviderProps {
Expand All @@ -16,6 +17,7 @@ interface SettingsProviderProps {
const SettingsContext = createContext<SettingsContextProps>({
settings: null,
siteData: null,
config: null,
saveSettings: async () => []
});

Expand Down Expand Up @@ -79,18 +81,23 @@ function deserializeSettings(settings: Setting[]): Setting[] {
// Create a Settings Provider component
const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext);
const [settings, setSettings] = useState <Setting[] | null> (null);
const [siteData, setSiteData] = useState <SiteData | null> (null);
const [settings, setSettings] = useState<Setting[] | null> (null);
const [siteData, setSiteData] = useState<SiteData | null> (null);
const [config, setConfig] = useState<Config | null> (null);

useEffect(() => {
const fetchSettings = async (): Promise<void> => {
try {
// Make an API call to fetch the settings
const data = await api.settings.browse();
const siteDataRes = await api.site.browse();

setSettings(serialiseSettingsData(data.settings));
setSiteData(siteDataRes.site);
const [settingsData, siteDataResponse, configData] = await Promise.all([
api.settings.browse(),
api.site.browse(),
api.config.browse()
]);

setSettings(serialiseSettingsData(settingsData.settings));
setSiteData(siteDataResponse.site);
setConfig(configData.config);
} catch (error) {
// Log error in settings API
}
Expand Down Expand Up @@ -120,7 +127,7 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
// Provide the settings and the saveSettings function to the children components
return (
<SettingsContext.Provider value={{
settings, saveSettings, siteData
settings, saveSettings, siteData, config
}}>
{children}
</SettingsContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@ import AccountPage from './portal/AccountPage';
import LookAndFeel from './portal/LookAndFeel';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PortalPreview from './portal/PortalPreview';
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import SignupOptions from './portal/SignupOptions';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import useForm, {Dirtyable} from '../../../hooks/useForm';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
import {Setting, SettingValue} from '../../../types/api';
import {Setting, SettingValue, Tier} from '../../../types/api';
import {SettingsContext} from '../../providers/SettingsProvider';
import {useTiers} from '../../providers/ServiceProvider';

const Sidebar: React.FC<{
localSettings: Setting[]
updateSetting: (key: string, setting: SettingValue) => void
}> = ({localSettings, updateSetting}) => {
localTiers: Tier[]
updateTier: (tier: Tier) => void
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
const [selectedTab, setSelectedTab] = useState('signupOptions');

const tabs: Tab[] = [
{
id: 'signupOptions',
title: 'Signup options',
contents: <SignupOptions localSettings={localSettings} updateSetting={updateSetting} />
contents: <SignupOptions localSettings={localSettings} localTiers={localTiers} updateSetting={updateSetting} updateTier={updateTier} />
},
{
id: 'lookAndFeel',
Expand Down Expand Up @@ -48,13 +52,44 @@ const PortalModal: React.FC = () => {
const modal = useModal();

const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
const {localSettings, updateSetting, handleSave, saveState} = useSettingGroup();
const {settings, saveSettings} = useContext(SettingsContext);
const {data: tiers, update: updateTiers} = useTiers();

const {formState, saveState, handleSave, updateForm} = useForm({
initialState: {
settings: settings as Dirtyable<Setting>[],
tiers: tiers as Dirtyable<Tier>[]
},

onSave: async () => {
await updateTiers(formState.tiers.filter(tier => tier.dirty));
await saveSettings(formState.settings.filter(setting => setting.dirty));
}
});

const updateSetting = (key: string, value: SettingValue) => {
updateForm(state => ({
...state,
settings: state.settings.map(setting => (
setting.key === key ? {...setting, value, dirty: true} : setting
))
}));
};

const updateTier = (newTier: Tier) => {
updateForm(state => ({
...state,
tiers: state.tiers.map(tier => (
tier.id === newTier.id ? {...newTier, dirty: true} : tier
))
}));
};

const onSelectURL = (id: string) => {
setSelectedPreviewTab(id);
};

const sidebar = <Sidebar localSettings={localSettings} updateSetting={updateSetting} />;
const sidebar = <Sidebar localSettings={formState.settings} localTiers={formState.tiers} updateSetting={updateSetting} updateTier={updateTier} />;
const preview = <PortalPreview selectedTab={selectedPreviewTab} />;

let previewTabs: Tab[] = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,67 @@
import React from 'react';
import Checkbox from '../../../../admin-x-ds/global/form/Checkbox';
import Heading from '../../../../admin-x-ds/global/Heading';
import React, {useContext} from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {Setting, SettingValue} from '../../../../types/api';
import {getSettingValues} from '../../../../utils/helpers';
import {Setting, SettingValue, Tier} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {checkStripeEnabled, getSettingValues} from '../../../../utils/helpers';

const SignupOptions: React.FC<{
localSettings: Setting[]
updateSetting: (key: string, setting: SettingValue) => void
}> = ({localSettings, updateSetting}) => {
const [membersSignupAccess, portalName, portalSignupCheckboxRequired] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required']);
localTiers: Tier[]
updateTier: (tier: Tier) => void
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
const {config} = useContext(SettingsContext);

const [membersSignupAccess, portalName, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required', 'portal_plans']);
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];

const togglePlan = (plan: string) => {
const index = portalPlans.indexOf(plan);

if (index === -1) {
portalPlans.push(plan);
} else {
portalPlans.splice(index, 1);
}

updateSetting('portal_plans', JSON.stringify(portalPlans));
};

// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
const isDisabled = membersSignupAccess !== 'all';

const isStripeEnabled = checkStripeEnabled(localSettings, config!);

return <>
<Toggle
checked={Boolean(portalName)}
disabled={isDisabled}
label='Display name in signup form'
onChange={e => updateSetting('portal_name', e.target.checked)}
/>
<div>TODO: Tiers available at signup</div>

<Heading level={6} grey>Tiers available at signup</Heading>
<Checkbox checked={portalPlans.includes('free')} disabled={isDisabled} label='Free' value='free' onChange={() => togglePlan('free')} />

{isStripeEnabled && localTiers.map(tier => (
<Checkbox
checked={tier.visibility === 'public'}
label={tier.name}
value={tier.id}
onChange={checked => updateTier({...tier, visibility: checked ? 'public' : 'none'})}
/>
))}

{isStripeEnabled && localTiers.some(tier => tier.visibility === 'public') && (
<>
<Heading level={6} grey>Prices available at signup</Heading>
<Checkbox checked={portalPlans.includes('monthly')} disabled={isDisabled} label='Monthly' value='monthly' onChange={() => togglePlan('monthly')} />
<Checkbox checked={portalPlans.includes('yearly')} disabled={isDisabled} label='Yearly' value='yearly' onChange={() => togglePlan('yearly')} />
</>
)}

<div>TODO: Display notice at signup (Koenig)</div>
<Toggle
checked={Boolean(portalSignupCheckboxRequired)}
Expand Down
4 changes: 4 additions & 0 deletions apps/admin-x-settings/src/hooks/useForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import {useCallback, useEffect, useState} from 'react';

export type Dirtyable<Data> = Data & {
dirty?: boolean;
}

export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';

export interface FormHook<State> {
Expand Down
4 changes: 4 additions & 0 deletions apps/admin-x-settings/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export type Setting = {
value: SettingValue;
}

export type Config = {
[key: string]: any;
}

export type User = {
id: string;
name: string;
Expand Down
Loading
Loading