Replace custom data loading with react-query (#17537)

refs https://github.com/TryGhost/Product/issues/3349
This commit is contained in:
Jono M 2023-07-31 18:27:30 +01:00 committed by GitHub
parent 841e52ccfe
commit 55d243f470
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
87 changed files with 1340 additions and 1587 deletions

17
.vscode/settings.json vendored
View File

@ -22,5 +22,20 @@
},
"tailwindCSS.experimental.classRegex": [
["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
],
"workbench.colorCustomizations": {
"editorIndentGuide.background": "#00000000",
"[Material Theme Palenight High Contrast]": {
"tab.activeBorder": "#00000000",
"tab.inactiveBackground": "#1b1e2b",
"activityBar.activeBorder": "#a6accd",
"editorGroupHeader.tabsBackground": "#1b1e2b",
"tab.border": "#00000000",
"sideBarSectionHeader.border": "#00000000",
"terminal.border": "#a6accd"
},
"activityBar.background": "#0C3429",
"titleBar.activeBackground": "#114939",
"titleBar.activeForeground": "#F4FDFA"
}
}

View File

@ -46,6 +46,7 @@
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@ebay/nice-modal-react": "1.2.10",
"@tanstack/react-query": "4.29.25",
"@tryghost/timezone-data": "0.3.0",
"clsx": "2.0.0",
"react": "18.2.0",

View File

@ -5,57 +5,61 @@ import NiceModal from '@ebay/nice-modal-react';
import RoutingProvider from './components/providers/RoutingProvider';
import Settings from './components/Settings';
import Sidebar from './components/Sidebar';
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
import {OfficialTheme} from './models/themes';
import {ServicesProvider} from './components/providers/ServiceProvider';
import {Toaster} from 'react-hot-toast';
import { GlobalDirtyStateProvider } from './hooks/useGlobalDirtyState';
import { OfficialTheme } from './models/themes';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ServicesProvider } from './components/providers/ServiceProvider';
import { Toaster } from 'react-hot-toast';
interface AppProps {
ghostVersion: string;
officialThemes: OfficialTheme[];
}
const queryClient = new QueryClient();
function App({ghostVersion, officialThemes}: AppProps) {
return (
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
<DataProvider>
<RoutingProvider>
<GlobalDirtyStateProvider>
<div className="admin-x-settings h-[100vh] w-full overflow-y-auto" id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<div className='fixed left-6 top-4 z-20'>
<ExitSettingsButton />
</div>
{/* Main container */}
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]" id="admin-x-settings-content">
{/* Sidebar */}
<div className="relative z-20 min-w-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-[260px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
<Sidebar />
</div>
<QueryClientProvider client={queryClient}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
<DataProvider>
<RoutingProvider>
<GlobalDirtyStateProvider>
<div className="admin-x-settings h-[100vh] w-full overflow-y-auto" id="admin-x-root" style={{
height: '100vh',
width: '100%'
}}
>
<Toaster />
<NiceModal.Provider>
<div className='fixed left-6 top-4 z-20'>
<ExitSettingsButton />
</div>
<div className="relative flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
<Settings />
{/* Main container */}
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]" id="admin-x-settings-content">
{/* Sidebar */}
<div className="relative z-20 min-w-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
<div className='h-[84px]'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-[260px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
<Sidebar />
</div>
</div>
<div className="relative flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
<Settings />
</div>
</div>
</div>
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</RoutingProvider>
</DataProvider>
</ServicesProvider>
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>
</RoutingProvider>
</DataProvider>
</ServicesProvider>
</QueryClientProvider>
);
}

View File

@ -1,11 +1,12 @@
import TaskList from './Tasklist';
import { ReactNode } from 'react';
import * as TaskStories from './Task.stories';
const story = {
component: TaskList,
title: 'Experimental / Task List',
decorators: [(_story: any) => <div style={{padding: '3rem'}}>{_story()}</div>],
decorators: [(_story: () => ReactNode) => <div style={{padding: '3rem'}}>{_story()}</div>],
tags: ['autodocs']
};
@ -43,4 +44,4 @@ export const Empty = {
...Loading.args,
loading: false
}
};
};

View File

@ -1,4 +1,4 @@
import React, {useEffect, useState} from 'react';
import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
interface UseDynamicSVGImportOptions {
@ -27,9 +27,13 @@ function useDynamicSVGImport(
).ReactComponent;
setSvgComponent(() => SvgIcon);
onCompleted?.(name, SvgIcon);
} catch (err: any) {
onError?.(err);
setError(() => err);
} catch (err) {
if (err instanceof Error) {
onError?.(err);
setError(err);
} else {
throw err;
}
} finally {
setLoading(() => false);
}
@ -103,4 +107,4 @@ const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = 'text-black'
return null;
};
export default Icon;
export default Icon;

View File

@ -1,8 +1,9 @@
import type {Meta, StoryObj} from '@storybook/react';
import type { Meta, StoryObj } from '@storybook/react';
import * as ListItemStories from './ListItem.stories';
import List from './List';
import ListItem from './ListItem';
import { ReactNode } from 'react';
const meta = {
title: 'Global / List',
@ -29,7 +30,7 @@ export const Default: Story = {
children: listItems,
hint: 'And here is a hint for the whole list'
},
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
};
export const PageLevel: Story = {

View File

@ -1,5 +1,5 @@
import React from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import React, { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Avatar from './Avatar';
import Button from './Button';
@ -9,7 +9,7 @@ const meta = {
title: 'Global / List / List Item',
component: ListItem,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)],
argTypes: {
title: {control: 'text'},
detail: {control: 'text'}

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';
import Menu from './Menu';
@ -7,7 +8,7 @@ const meta = {
title: 'Global / Menu',
component: Menu,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '100px', margin: '0 auto', padding: '100px 0 200px'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '100px', margin: '0 auto', padding: '100px 0 200px'}}>{_story()}</div>)]
} satisfies Meta<typeof Menu>;
export default meta;
@ -59,4 +60,4 @@ export const LongLabels: Story = {
items: longItems,
position: 'right'
}
};
};

View File

@ -1,12 +1,12 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import SortableList, {SortableListProps} from './SortableList';
import SortableList, { SortableListProps } from './SortableList';
import clsx from 'clsx';
import {arrayMove} from '@dnd-kit/sortable';
import {useState} from 'react';
import { arrayMove } from '@dnd-kit/sortable';
import { useState } from 'react';
const Wrapper = (props: SortableListProps<any> & {updateArgs: (args: Partial<SortableListProps<any>>) => void}) => {
const Wrapper = (props: SortableListProps<{id: string}> & {updateArgs: (args: Partial<SortableListProps<{id: string}>>) => void}) => {
// Seems like Storybook recreates items on every render, so we need to keep our own state
const [items, setItems] = useState(props.items);

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import StickyFooter from './StickyFooter';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Sticky Footer',
component: StickyFooter,
tags: ['autodocs'],
decorators: [(_story: any) => (
decorators: [(_story: () => ReactNode) => (
<div style={{
maxWidth: '600px',
margin: '0 auto 80px',

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Table from './Table';
import TableCell from './TableCell';
@ -34,5 +35,5 @@ export const Default: Story = {
args: {
children: tableRows
},
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
};

View File

@ -1,13 +1,14 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import ToastContainer from './ToastContainer';
import {Toaster} from 'react-hot-toast';
import { Toaster } from 'react-hot-toast';
const meta = {
title: 'Global / Toast',
component: ToastContainer,
tags: ['autodocs'],
decorators: [(_story: any) => (
decorators: [(_story: () => ReactNode) => (
<>
<Toaster />
{_story()}

View File

@ -1,7 +1,7 @@
import Icon from './Icon';
import React from 'react';
import clsx from 'clsx';
import {ToastOptions, toast} from 'react-hot-toast';
import { Toast as HotToast, ToastOptions, toast } from 'react-hot-toast';
export type ToastType = 'neutral' | 'success' | 'error' | 'pageError';
@ -13,7 +13,7 @@ export interface ShowToastProps {
}
interface ToastProps {
t: any;
t: HotToast;
/**
* Can be a name of an icon from the icon library or a react component

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import DesktopChrome from './DesktopChrome';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Chrome / Desktop Chrome',
component: DesktopChrome,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
} satisfies Meta<typeof DesktopChrome>;
export default meta;

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import MobileChrome from './MobileChrome';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Chrome / Mobile Chrome',
component: MobileChrome,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
} satisfies Meta<typeof MobileChrome>;
export default meta;

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Checkbox from './Checkbox';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Form / Checkbox',
component: Checkbox,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
argTypes: {
hint: {
control: 'text'

View File

@ -1,4 +1,4 @@
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
import React, { ReactNode, Suspense, useCallback, useMemo } from 'react';
export interface HtmlEditorProps {
value?: string
@ -10,12 +10,14 @@ export interface HtmlEditorProps {
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
'@tryghost/koenig-lexical': any;
}
}
const fetchKoenig = function ({editorUrl, editorVersion}: { editorUrl: string; editorVersion: string; }) {
let status = 'pending';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
const fetchPackage = async () => {
@ -64,7 +66,7 @@ class ErrorHandler extends React.Component<{ children: ReactNode }> {
return {hasError: true};
}
componentDidCatch(error: any, errorInfo: any) {
componentDidCatch(error: unknown, errorInfo: unknown) {
console.error(error, errorInfo); // eslint-disable-line
}
@ -87,7 +89,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
placeholder,
nodes
}) => {
const onError = useCallback((error: any) => {
const onError = useCallback((error: unknown) => {
// ensure we're still showing errors in development
console.error(error); // eslint-disable-line
@ -105,6 +107,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
// don't rethrow, Lexical will attempt to gracefully recover
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
get: (_target, prop) => {
return editor.read()[prop];

View File

@ -1,14 +1,16 @@
import Heading from '../Heading';
import Hint from '../Hint';
import HtmlEditor, {HtmlEditorProps} from './HtmlEditor';
import HtmlEditor, { HtmlEditorProps } from './HtmlEditor';
import React from 'react';
import clsx from 'clsx';
export type EditorConfig = { editor: { url: string; version: string; } }
export type HtmlFieldProps = HtmlEditorProps & {
/**
* Should be passed the Ghost instance config to get the editor JS URL
*/
config: { editor: { url: string; version: string; } };
config: EditorConfig;
title?: string;
hideTitle?: boolean;
error?: boolean;

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import ImageUpload from './ImageUpload';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Form / Image upload',
component: ImageUpload,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
} satisfies Meta<typeof ImageUpload>;
export default meta;
@ -48,4 +49,4 @@ export const ImageUploaded: Story = {
alert('Delete image');
}
}
};
};

View File

@ -1,12 +1,13 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Radio, {RadioOption} from './Radio';
import Radio, { RadioOption } from './Radio';
const meta = {
title: 'Global / Form / Radio',
component: Radio,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
argTypes: {
hint: {
control: 'text'

View File

@ -1,13 +1,14 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import Select, {SelectOption} from './Select';
import Select, { SelectOption } from './Select';
const meta = {
title: 'Global / Form / Select',
component: Select,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
argTypes: {
hint: {
control: 'text'

View File

@ -1,5 +1,6 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import TextArea from './TextArea';
@ -7,7 +8,7 @@ const meta = {
title: 'Global / Form / Textarea',
component: TextArea,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
argTypes: {
hint: {
control: 'text'

View File

@ -1,5 +1,6 @@
import {useArgs} from '@storybook/preview-api';
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import { useArgs } from '@storybook/preview-api';
import type { Meta, StoryObj } from '@storybook/react';
import TextField from './TextField';
@ -7,7 +8,7 @@ const meta = {
title: 'Global / Form / Textfield',
component: TextField,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
argTypes: {
hint: {
control: 'text'

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import Toggle from './Toggle';
@ -6,7 +7,7 @@ const meta = {
title: 'Global / Form / Toggle',
component: Toggle,
tags: ['autodocs'],
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)]
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)]
} satisfies Meta<typeof Toggle>;
export default meta;

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
import ConfirmationModal from './ConfirmationModal';
import ConfirmationModalContainer from './ConfirmationModalContainer';
@ -8,7 +9,7 @@ const meta = {
title: 'Global / Modal / Confirmation Modal',
component: ConfirmationModal,
tags: ['autodocs'],
decorators: [(_story: any, context: any) => (
decorators: [(_story: () => ReactNode, context: StoryContext) => (
<NiceModal.Provider>
<ConfirmationModalContainer {...context.args} />
</NiceModal.Provider>

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
import Modal from './Modal';
import ModalContainer from './ModalContainer';
@ -9,7 +10,7 @@ const meta = {
title: 'Global / Modal',
component: Modal,
tags: ['autodocs'],
decorators: [(_story: any, context: any) => (
decorators: [(_story: () => ReactNode, context: StoryContext) => (
<NiceModal.Provider>
<ModalContainer {...context.args} />
</NiceModal.Provider>
@ -166,4 +167,4 @@ export const Dirty: Story = {
title: 'Dirty modal',
children: <p>Simulates if there were unsaved changes of a form. Click on Cancel</p>
}
};
};

View File

@ -1,16 +1,17 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
import Heading from '../Heading';
import NiceModal from '@ebay/nice-modal-react';
import PreviewModal from './PreviewModal';
import PreviewModalContainer from './PreviewModalContainer';
import {Tab} from '../TabView';
import { Tab } from '../TabView';
const meta = {
title: 'Global / Modal / Preview Modal',
component: PreviewModal,
tags: ['autodocs'],
decorators: [(_story: any, context: any) => (
decorators: [(_story: () => ReactNode, context: StoryContext) => (
<NiceModal.Provider>
<PreviewModalContainer {...context.args} />
</NiceModal.Provider>
@ -86,4 +87,4 @@ export const FullBleed: Story = {
...Default.args,
size: 'bleed'
}
};
};

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import * as SettingGroupContentStories from './SettingGroupContent.stories';
import * as SettingGroupHeaderStories from './SettingGroupHeader.stories';
@ -12,7 +13,7 @@ const meta = {
title: 'Settings / Setting Group',
component: SettingGroup,
tags: ['autodocs'],
decorators: [(_story: any) => <div style={{maxWidth: '780px'}}>{_story()}</div>],
decorators: [(_story: () => ReactNode) => <div style={{maxWidth: '780px'}}>{_story()}</div>],
argTypes: {
description: {
control: 'text'

View File

@ -1,4 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import { ReactNode } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import * as SettingGroupStories from './SettingGroup.stories';
import SettingGroup from './SettingGroup';
@ -8,7 +9,7 @@ const meta = {
title: 'Settings / Setting Section',
component: SettingSection,
tags: ['autodocs'],
decorators: [(_story: any) => <div style={{maxWidth: '780px'}}>{_story()}</div>]
decorators: [(_story: () => ReactNode) => <div style={{maxWidth: '780px'}}>{_story()}</div>]
} satisfies Meta<typeof SettingSection>;
export default meta;

View File

@ -1,23 +1,11 @@
import React, {useContext} from 'react';
import React from 'react';
import EmailSettings from './settings/email/EmailSettings';
import GeneralSettings from './settings/general/GeneralSettings';
import MembershipSettings from './settings/membership/MembershipSettings';
import SiteSettings from './settings/site/SiteSettings';
import {SettingsContext} from './providers/SettingsProvider';
const Settings: React.FC = () => {
const {settings} = useContext(SettingsContext) || {};
// Show loader while settings is first fetched
if (!settings) {
return (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center text-2xl font-bold">Loading...</div>
</div>
);
}
return (
<>
<GeneralSettings />

View File

@ -1,22 +1,82 @@
import React from 'react';
import {RolesProvider} from './RolesProvider';
import {SettingsProvider} from './SettingsProvider';
import {UsersProvider} from './UsersProvider';
import React, {ReactNode, createContext, useContext} from 'react';
import {Config, Setting, SiteData, Tier, User} from '../../types/api';
import {UserInvite, useBrowseInvites} from '../../utils/api/invites';
import {useBrowseConfig} from '../../utils/api/config';
import {useBrowseSettings} from '../../utils/api/settings';
import {useBrowseSite} from '../../utils/api/site';
import {useBrowseTiers} from '../../utils/api/tiers';
import {useBrowseUsers, useCurrentUser} from '../../utils/api/users';
type DataProviderProps = {
children: React.ReactNode;
};
interface GlobalData {
settings: Setting[]
siteData: SiteData
config: Config
users: User[]
currentUser: User
invites: UserInvite[]
tiers: Tier[]
}
const GlobalDataContext = createContext<GlobalData | undefined>(undefined);
const GlobalDataProvider = ({children}: { children: ReactNode }) => {
const settings = useBrowseSettings();
const site = useBrowseSite();
const config = useBrowseConfig();
const users = useBrowseUsers();
const currentUser = useCurrentUser();
const invites = useBrowseInvites();
const tiers = useBrowseTiers();
const requests = [
settings,
site,
config,
users,
currentUser,
invites,
tiers
];
const error = requests.map(request => request.error).find(Boolean);
if (error) {
throw error;
}
if (requests.some(request => request.isLoading)) {
return (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center text-2xl font-bold">Loading...</div>
</div>
);
}
return <GlobalDataContext.Provider value={{
settings: settings.data!.settings,
siteData: site.data!.site,
config: config.data!.config,
users: users.data!.users,
currentUser: currentUser.data!,
invites: invites.data!.invites,
tiers: tiers.data!.tiers
}}>
{children}
</GlobalDataContext.Provider>;
};
export const useGlobalData = () => useContext(GlobalDataContext)!;
const DataProvider: React.FC<DataProviderProps> = ({children}) => {
return (
<SettingsProvider>
<UsersProvider>
<RolesProvider>
{children}
</RolesProvider>
</UsersProvider>
</SettingsProvider>
<GlobalDataProvider>
{children}
</GlobalDataProvider>
);
};
export default DataProvider;
export default DataProvider;

View File

@ -1,53 +0,0 @@
import React, {createContext, useContext, useEffect, useState} from 'react';
import {ServicesContext} from './ServiceProvider';
import {UserRole} from '../../types/api';
interface RolesContextProps {
roles: UserRole[];
assignableRoles: UserRole[];
}
interface RolesProviderProps {
children?: React.ReactNode;
}
const RolesContext = createContext<RolesContextProps>({
roles: [],
assignableRoles: []
});
const RolesProvider: React.FC<RolesProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext);
const [roles, setRoles] = useState <UserRole[]> ([]);
const [assignableRoles, setAssignableRoles] = useState <UserRole[]> ([]);
useEffect(() => {
const fetchRoles = async (): Promise<void> => {
try {
const rolesData = await api.roles.browse();
const assignableRolesData = await api.roles.browse({
queryParams: {
permissions: 'assign'
}
});
setRoles(rolesData.roles);
setAssignableRoles(assignableRolesData.roles);
} catch (error) {
// Log error in API
}
};
fetchRoles();
}, [api]);
return (
<RolesContext.Provider value={{
roles,
assignableRoles
}}>
{children}
</RolesContext.Provider>
);
};
export {RolesContext, RolesProvider};

View File

@ -1,13 +1,12 @@
import ChangeThemeModal from '../settings/site/ThemeModal';
import DesignModal from '../settings/site/DesignModal';
import InviteUserModal from '../settings/general/InviteUserModal';
import NavigationModal from '../settings/site/NavigationModal';
import NiceModal from '@ebay/nice-modal-react';
import PortalModal from '../settings/membership/portal/PortalModal';
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import StripeConnectModal from '../settings/membership/stripe/StripeConnectModal';
import TierDetailModal from '../settings/membership/tiers/TierDetailModal';
import {SettingsContext} from './SettingsProvider';
import ChangeThemeModal from "../settings/site/ThemeModal";
import DesignModal from "../settings/site/DesignModal";
import InviteUserModal from "../settings/general/InviteUserModal";
import NavigationModal from "../settings/site/NavigationModal";
import NiceModal from "@ebay/nice-modal-react";
import PortalModal from "../settings/membership/portal/PortalModal";
import React, { createContext, useCallback, useEffect, useState } from "react";
import StripeConnectModal from "../settings/membership/stripe/StripeConnectModal";
import TierDetailModal from "../settings/membership/tiers/TierDetailModal";
type RoutingContextProps = {
route: string;
@ -18,11 +17,11 @@ type RoutingContextProps = {
};
export const RouteContext = createContext<RoutingContextProps>({
route: '',
scrolledRoute: '',
route: "",
scrolledRoute: "",
yScroll: 0,
updateRoute: () => {},
updateScrolled: () => {}
updateScrolled: () => {},
});
function getHashPath(urlPath: string | undefined) {
@ -42,9 +41,9 @@ function getHashPath(urlPath: string | undefined) {
const scrollToSectionGroup = (pathName: string) => {
const element = document.getElementById(pathName);
if (element) {
element.scrollIntoView({behavior: 'smooth'});
element.scrollIntoView({ behavior: "smooth" });
}
}
};
const handleNavigation = (scroll: boolean = true) => {
// Get the hash from the URL
@ -57,19 +56,19 @@ const handleNavigation = (scroll: boolean = true) => {
const pathName = getHashPath(hash);
if (pathName) {
if (pathName === 'design/edit/themes') {
if (pathName === "design/edit/themes") {
NiceModal.show(ChangeThemeModal);
} else if (pathName === 'design/edit') {
} else if (pathName === "design/edit") {
NiceModal.show(DesignModal);
} else if (pathName === 'navigation/edit') {
} else if (pathName === "navigation/edit") {
NiceModal.show(NavigationModal);
} else if (pathName === 'users/invite') {
} else if (pathName === "users/invite") {
NiceModal.show(InviteUserModal);
} else if (pathName === 'portal/edit') {
} else if (pathName === "portal/edit") {
NiceModal.show(PortalModal);
} else if (pathName === 'tiers/add') {
} else if (pathName === "tiers/add") {
NiceModal.show(TierDetailModal);
} else if (pathName === 'stripe-connect') {
} else if (pathName === "stripe-connect") {
NiceModal.show(StripeConnectModal);
}
@ -79,31 +78,32 @@ const handleNavigation = (scroll: boolean = true) => {
return pathName;
}
return '';
return "";
};
type RouteProviderProps = {
children: React.ReactNode;
};
const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
const [route, setRoute] = useState<string>('');
const RoutingProvider: React.FC<RouteProviderProps> = ({ children }) => {
const [route, setRoute] = useState<string>("");
const [yScroll, setYScroll] = useState(0);
const [scrolledRoute, setScrolledRoute] = useState<string>('');
const [scrolledRoute, setScrolledRoute] = useState<string>("");
const {settingsLoaded} = useContext(SettingsContext) || {};
const updateRoute = useCallback((newPath: string) => {
if (newPath) {
if (newPath === route) {
scrollToSectionGroup(newPath);
const updateRoute = useCallback(
(newPath: string) => {
if (newPath) {
if (newPath === route) {
scrollToSectionGroup(newPath);
} else {
window.location.hash = `/settings-x/${newPath}`;
}
} else {
window.location.hash = `/settings-x/${newPath}`;
window.location.hash = `/settings-x`;
}
} else {
window.location.hash = `/settings-x`;
}
}, [route]);
},
[route]
);
const updateScrolled = useCallback((newPath: string) => {
setScrolledRoute(newPath);
@ -116,37 +116,37 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
};
const handleScroll = () => {
const element = document.getElementById('admin-x-root');
const element = document.getElementById("admin-x-root");
const scrollPosition = element!.scrollTop;
setYScroll(scrollPosition);
};
const element = document.getElementById('admin-x-root');
if (settingsLoaded) {
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
element!.addEventListener('scroll', handleScroll);
}
const element = document.getElementById("admin-x-root");
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
element!.addEventListener("scroll", handleScroll);
window.addEventListener('hashchange', handleHashChange);
window.addEventListener("hashchange", handleHashChange);
return () => {
element!.removeEventListener('scroll', handleScroll);
window.removeEventListener('hashchange', handleHashChange);
element!.removeEventListener("scroll", handleScroll);
window.removeEventListener("hashchange", handleHashChange);
};
}, [settingsLoaded]);
}, []);
return (
<RouteContext.Provider value={{
route,
scrolledRoute,
yScroll,
updateRoute,
updateScrolled
}}>
<RouteContext.Provider
value={{
route,
scrolledRoute,
yScroll,
updateRoute,
updateScrolled,
}}
>
{children}
</RouteContext.Provider>
);
};
export default RoutingProvider;
export default RoutingProvider;

View File

@ -1,19 +1,11 @@
import React, {createContext, useContext, useMemo} from 'react';
import setupGhostApi from '../../utils/api';
import useDataService, {DataService, bulkEdit, placeholderDataService} from '../../utils/dataService';
import React, {createContext, useContext} from 'react';
import useSearchService, {SearchService} from '../../utils/search';
import {OfficialTheme} from '../../models/themes';
import {Tier} from '../../types/api';
export interface FileService {
uploadImage: (file: File) => Promise<string>;
}
interface ServicesContextProps {
api: ReturnType<typeof setupGhostApi>;
fileService: FileService|null;
ghostVersion: string
officialThemes: OfficialTheme[];
search: SearchService
tiers: DataService<Tier>
}
interface ServicesProviderProps {
@ -23,36 +15,19 @@ interface ServicesProviderProps {
}
const ServicesContext = createContext<ServicesContextProps>({
api: setupGhostApi({ghostVersion: ''}),
fileService: null,
ghostVersion: '',
officialThemes: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
tiers: placeholderDataService
search: {filter: '', setFilter: () => {}, checkVisible: () => true}
});
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, officialThemes}) => {
const apiService = useMemo(() => setupGhostApi({ghostVersion}), [ghostVersion]);
const fileService = useMemo(() => ({
uploadImage: async (file: File): Promise<string> => {
const response = await apiService.images.upload({file});
return response.images[0].url;
}
}), [apiService]);
const search = useSearchService();
const tiers = useDataService({
key: 'tiers',
browse: apiService.tiers.browse,
edit: bulkEdit('tiers', apiService.tiers.edit),
add: apiService.tiers.add
});
return (
<ServicesContext.Provider value={{
api: apiService,
fileService,
ghostVersion,
officialThemes,
search,
tiers
search
}}>
{children}
</ServicesContext.Provider>
@ -63,10 +38,6 @@ export {ServicesContext, ServicesProvider};
export const useServices = () => useContext(ServicesContext);
export const useApi = () => useServices().api;
export const useOfficialThemes = () => useServices().officialThemes;
export const useSearch = () => useServices().search;
export const useTiers = () => useServices().tiers;

View File

@ -1,146 +0,0 @@
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {Config, Setting, SiteData} from '../../types/api';
import {ServicesContext} from './ServiceProvider';
import {SettingsResponseType} from '../../utils/api';
// Define the Settings Context
interface SettingsContextProps {
settings: Setting[] | null;
saveSettings: (updatedSettings: Setting[]) => Promise<SettingsResponseType>;
siteData: SiteData | null;
config: Config | null;
settingsLoaded: boolean;
}
interface SettingsProviderProps {
children?: React.ReactNode;
}
const SettingsContext = createContext<SettingsContextProps>({
settings: null,
siteData: null,
config: null,
settingsLoaded: false,
saveSettings: async () => ({settings: []})
});
function serialiseSettingsData(settings: Setting[]): Setting[] {
return settings.map((setting) => {
if (setting.key === 'facebook' && setting.value) {
const value = setting.value as string;
let [, user] = value.match(/(\S+)/) || [];
return {
key: setting.key,
value: `https://www.facebook.com/${user}`
};
}
if (setting.key === 'twitter' && setting.value) {
const value = setting.value as string;
let [, user] = value.match(/@?([^/]*)/) || [];
return {
key: setting.key,
value: `https://twitter.com/${user}`
};
}
return {
key: setting.key,
value: setting.value
};
});
}
function deserializeSettings(settings: Setting[]): Setting[] {
return settings.map((setting) => {
if (setting.key === 'facebook' && setting.value) {
const deserialized = setting.value as string;
let [, user] = deserialized.match(/(?:https:\/\/)(?:www\.)(?:facebook\.com)\/(?:#!\/)?(\w+\/?\S+)/mi) || [];
return {
key: setting.key,
value: user
};
}
if (setting.key === 'twitter' && setting.value) {
const deserialized = setting.value as string;
let [, user] = deserialized.match(/(?:https:\/\/)(?:twitter\.com)\/(?:#!\/)?@?([^/]*)/) || [];
return {
key: setting.key,
value: `@${user}`
};
}
return {
key: setting.key,
value: setting.value
};
});
}
// 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 [config, setConfig] = useState<Config | null> (null);
const [settingsLoaded, setSettingsLoaded] = useState<boolean> (false);
useEffect(() => {
const fetchSettings = async (): Promise<void> => {
try {
// Make an API call to fetch the settings
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);
setSettingsLoaded(true);
} catch (error) {
// Log error in settings API
}
};
// Fetch the initial settings from the API
fetchSettings();
}, [api]);
const saveSettings = useCallback(async (updatedSettings: Setting[]) => {
try {
// handle transformation for settings before save
updatedSettings = deserializeSettings(updatedSettings);
// Make an API call to save the updated settings
const data = await api.settings.edit(updatedSettings);
const newSettings = serialiseSettingsData(data.settings);
setSettings(newSettings);
return {
settings: newSettings,
meta: data.meta
};
} catch (error) {
// Log error in settings API
return {settings: []};
}
}, [api]);
// Provide the settings and the saveSettings function to the children components
return (
<SettingsContext.Provider value={{
settings, saveSettings, siteData, config, settingsLoaded
}}>
{children}
</SettingsContext.Provider>
);
};
export {SettingsContext, SettingsProvider};

View File

@ -1,82 +0,0 @@
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {ServicesContext} from './ServiceProvider';
import {User} from '../../types/api';
import {UserInvite} from '../../utils/api';
interface UsersContextProps {
users: User[];
invites: UserInvite[];
currentUser: User|null;
updateUser?: (user: User) => Promise<void>;
setInvites: (invites: UserInvite[]) => void;
setUsers: React.Dispatch<React.SetStateAction<User[]>>
}
interface UsersProviderProps {
children?: React.ReactNode;
}
const UsersContext = createContext<UsersContextProps>({
users: [],
invites: [],
currentUser: null,
setInvites: () => {},
setUsers: () => {}
});
const UsersProvider: React.FC<UsersProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext);
const [users, setUsers] = useState <User[]> ([]);
const [invites, setInvites] = useState <UserInvite[]> ([]);
const [currentUser, setCurrentUser] = useState <User|null> (null);
useEffect(() => {
const fetchUsers = async (): Promise<void> => {
try {
// get list of staff users from the API
const data = await api.users.browse();
const user = await api.users.currentUser();
const invitesRes = await api.invites.browse();
setUsers(data.users);
setCurrentUser(user);
setInvites(invitesRes.invites);
} catch (error) {
// Log error in API
}
};
fetchUsers();
}, [api]);
const updateUser = useCallback(async (user: User): Promise<void> => {
try {
// Make an API call to save the updated settings
const data = await api.users.edit(user);
setUsers((usersState) => {
return usersState.map((u) => {
if (u.id === user.id) {
return data.users[0];
}
return u;
});
});
} catch (error) {
// Log error in settings API
}
}, [api]);
return (
<UsersContext.Provider value={{
users,
invites,
currentUser,
updateUser,
setInvites,
setUsers
}}>
{children}
</UsersContext.Provider>
);
};
export {UsersContext, UsersProvider};

View File

@ -1,13 +1,14 @@
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
import React, {useContext, useEffect, useState} from 'react';
import React, {useState} from 'react';
import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {GroupBase, MultiValue} from 'react-select';
import {Label, Offer, Tier} from '../../../types/api';
import {ServicesContext} from '../../providers/ServiceProvider';
import {getOptionLabel, getPaidActiveTiers, getSettingValues} from '../../../utils/helpers';
import {getOptionLabel, getSettingValues} from '../../../utils/helpers';
import {useBrowseLabels} from '../../../utils/api/labels';
import {useBrowseOffers} from '../../../utils/api/offers';
import {useGlobalData} from '../../providers/DataProvider';
type RefipientValueArgs = {
defaultEmailRecipients: string;
@ -80,24 +81,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
defaultEmailRecipientsFilter
}));
const {api} = useContext(ServicesContext);
const [tiers, setTiers] = useState<Tier[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [offers, setOffers] = useState<Offer[]>([]);
useEffect(() => {
api.tiers.browse().then((response) => {
setTiers(getPaidActiveTiers(response.tiers));
});
api.labels.browse().then((response) => {
setLabels(response.labels);
});
api.offers.browse().then((response) => {
setOffers(response.offers);
});
}, [api]);
const {tiers} = useGlobalData();
const {data: {labels} = {}} = useBrowseLabels();
const {data: {offers} = {}} = useBrowseOffers();
const setDefaultRecipientValue = (value: string) => {
if (['visibility', 'disabled'].includes(value)) {
@ -136,11 +122,11 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
},
{
label: 'Labels',
options: labels.map(label => ({value: `label:${label.slug}`, label: label.name, color: 'grey'}))
options: labels?.map(label => ({value: `label:${label.slug}`, label: label.name, color: 'grey'})) || []
},
{
label: 'Offers',
options: offers.map(offer => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'}))
options: offers?.map(offer => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'})) || []
}
];

View File

@ -1,11 +1,11 @@
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
import React, {useContext} from 'react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
import {getImageUrl, useUploadImage} from '../../../utils/api/images';
import {getSettingValues} from '../../../utils/helpers';
const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
@ -20,7 +20,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
handleEditingChange
} = useSettingGroup();
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
const {mutateAsync: uploadImage} = useUploadImage();
const [
facebookTitle, facebookDescription, facebookImage, siteTitle, siteDescription
@ -35,7 +35,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
};
const handleImageUpload = async (file: File) => {
const imageUrl = await fileService.uploadImage(file);
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
};

View File

@ -2,20 +2,19 @@ import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useRoles from '../../../hooks/useRoles';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {ServicesContext} from '../../providers/ServiceProvider';
import {showToast} from '../../../admin-x-ds/global/Toast';
import {useContext, useEffect, useRef, useState} from 'react';
import { showToast } from '../../../admin-x-ds/global/Toast';
import { useAddInvite } from '../../../utils/api/invites';
import { useBrowseRoles } from '../../../utils/api/roles';
import { useEffect, useRef, useState } from 'react';
type RoleType = 'administrator' | 'editor' | 'author' | 'contributor';
const InviteUserModal = NiceModal.create(() => {
const {api} = useContext(ServicesContext);
const {roles, assignableRoles, getRoleId} = useRoles();
const {invites, setInvites} = useStaffUsers();
const rolesQuery = useBrowseRoles();
const assignableRolesQuery = useBrowseRoles({limit: 'all', permissions: 'assign'});
const {updateRoute} = useRouting();
const focusRef = useRef<HTMLInputElement>(null);
@ -26,6 +25,8 @@ const InviteUserModal = NiceModal.create(() => {
email?: string;
}>({});
const {mutateAsync: addInvite} = useAddInvite();
useEffect(() => {
if (focusRef.current) {
focusRef.current.focus();
@ -40,6 +41,13 @@ const InviteUserModal = NiceModal.create(() => {
}
}, [saveState]);
if (!rolesQuery.data?.roles || !assignableRolesQuery.data?.roles) {
return null;
}
const roles = rolesQuery.data.roles;
const assignableRoles = assignableRolesQuery.data.roles;
let okLabel = 'Send invitation now';
if (saveState === 'saving') {
okLabel = 'Sending...';
@ -62,21 +70,18 @@ const InviteUserModal = NiceModal.create(() => {
}
setSaveState('saving');
try {
const res = await api.invites.add({
await addInvite({
email,
roleId: getRoleId(role, roles)
roleId: roles.find(({name}) => name.toLowerCase() === role.toLowerCase())!.id
});
// Update invites list
setInvites([...invites, res.invites[0]]);
setSaveState('saved');
showToast({
message: `Invitation successfully sent to ${email}`,
type: 'success'
});
} catch (e: any) {
} catch (e) {
setSaveState('error');
showToast({

View File

@ -1,10 +1,10 @@
import React, {useRef, useState} from 'react';
import React, { useRef, useState } from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import validator from 'validator';
import {getSettingValues} from '../../../utils/helpers';
import { getSettingValues } from '../../../utils/helpers';
function validateFacebookUrl(newUrl: string) {
const errMessage = 'The URL must be in a format like https://www.facebook.com/yourPage';
@ -121,7 +121,7 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
if (focusRef.current) {
focusRef.current.value = newUrl;
}
} catch (err: any) {
} catch (err) {
// ignore error
}
}}
@ -143,7 +143,7 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
if (twitterInputRef.current) {
twitterInputRef.current.value = newUrl;
}
} catch (err: any) {
} catch (err) {
// ignore error
}
}}
@ -172,14 +172,18 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
} = {};
try {
validateFacebookUrl(facebookUrl);
} catch (e: any) {
formErrors.facebook = e?.message;
} catch (e) {
if (e instanceof Error) {
formErrors.facebook = e.message;
}
}
try {
validateTwitterUrl(twitterUrl);
} catch (e: any) {
formErrors.twitter = e?.message;
} catch (e) {
if (e instanceof Error) {
formErrors.twitter = e.message;
}
}
setErrors(formErrors);

View File

@ -1,12 +1,12 @@
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
import React, {useContext} from 'react';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';
import {getSettingValues} from '../../../utils/helpers';
import { ReactComponent as TwitterLogo } from '../../../admin-x-ds/assets/images/twitter-logo.svg';
import { getImageUrl, useUploadImage } from '../../../utils/api/images';
import { getSettingValues } from '../../../utils/helpers';
const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
@ -20,7 +20,7 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
handleEditingChange
} = useSettingGroup();
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
const {mutateAsync: uploadImage} = useUploadImage();
const [
twitterTitle, twitterDescription, twitterImage, siteTitle, siteDescription
@ -36,10 +36,10 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
const handleImageUpload = async (file: File) => {
try {
const imageUrl = await fileService.uploadImage(file);
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('twitter_image', imageUrl);
} catch (err: any) {
// handle error
} catch (err) {
// TODO: handle error
}
};

View File

@ -3,23 +3,24 @@ import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModa
import Heading from '../../../admin-x-ds/global/Heading';
import Icon from '../../../admin-x-ds/global/Icon';
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
import Menu, {MenuItem} from '../../../admin-x-ds/global/Menu';
import Menu, { MenuItem } from '../../../admin-x-ds/global/Menu';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import Radio from '../../../admin-x-ds/global/form/Radio';
import React, {useContext, useEffect, useRef, useState} from 'react';
import React, { useEffect, useRef, useState } from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useRoles from '../../../hooks/useRoles';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
import {User} from '../../../types/api';
import {isAdminUser, isOwnerUser} from '../../../utils/helpers';
import {showToast} from '../../../admin-x-ds/global/Toast';
import { User } from '../../../types/api';
import { getImageUrl, useUploadImage } from '../../../utils/api/images';
import { isAdminUser, isOwnerUser } from '../../../utils/helpers';
import { showToast } from '../../../admin-x-ds/global/Toast';
import { toast } from 'react-hot-toast';
import { useBrowseRoles } from '../../../utils/api/roles';
import { useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword } from '../../../utils/api/users';
interface CustomHeadingProps {
children?: React.ReactNode;
@ -47,7 +48,8 @@ const CustomHeader: React.FC<CustomHeadingProps> = ({children}) => {
};
const RoleSelector: React.FC<UserDetailProps> = ({user, setUserData}) => {
const {roles} = useRoles();
const {data: {roles} = {}} = useBrowseRoles();
if (isOwnerUser(user)) {
return (
<>
@ -303,7 +305,8 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
}>({});
const newPasswordRef = useRef<HTMLInputElement>(null);
const confirmNewPasswordRef = useRef<HTMLInputElement>(null);
const {api} = useContext(ServicesContext);
const {mutateAsync: updatePassword} = useUpdatePassword();
useEffect(() => {
if (saveState === 'saved') {
@ -378,7 +381,7 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
return;
}
try {
await api.users.updatePassword({
await updatePassword({
newPassword,
confirmNewPassword,
oldPassword: '',
@ -408,7 +411,6 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
interface UserDetailModalProps {
user: User;
updateUser?: (user: User) => void;
}
const UserMenuTrigger = () => (
@ -418,9 +420,8 @@ const UserMenuTrigger = () => (
</button>
);
const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
const {api} = useContext(ServicesContext);
const {users, setUsers, ownerUser} = useStaffUsers();
const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
const {ownerUser} = useStaffUsers();
const [userData, setUserData] = useState(user);
const [saveState, setSaveState] = useState('');
const [errors, setErrors] = useState<{
@ -429,8 +430,11 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
url?: string;
}>({});
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
const mainModal = useModal();
const {mutateAsync: uploadImage} = useUploadImage();
const {mutateAsync: updateUser} = useEditUser();
const {mutateAsync: deleteUser} = useDeleteUser();
const {mutateAsync: makeOwner} = useMakeOwner();
const confirmSuspend = (_user: User) => {
let warningText = 'This user will no longer be able to log in but their posts will be kept.';
@ -452,16 +456,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
..._user,
status: _user.status === 'inactive' ? 'active' : 'inactive'
};
const res = await api.users.edit(updatedUserData);
const updatedUser = res.users[0];
setUsers((_users) => {
return _users.map((u) => {
if (u.id === updatedUser.id) {
return updatedUser;
}
return u;
});
});
await updateUser(updatedUserData);
setUserData(updatedUserData);
modal?.remove();
showToast({
@ -484,9 +479,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
okLabel: 'Delete user',
okColor: 'red',
onOk: async (modal) => {
await api.users.delete(_user?.id);
const newUsers = users.filter(u => u.id !== _user.id);
setUsers(newUsers);
await deleteUser(_user?.id);
modal?.remove();
mainModal?.remove();
showToast({
@ -504,8 +497,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
okLabel: 'Yep — I\'m sure',
okColor: 'red',
onOk: async (modal) => {
const res = await api.users.makeOwner(user.id);
setUsers(res.users);
await makeOwner(user.id);
modal?.remove();
showToast({
message: 'Ownership transferred',
@ -517,7 +509,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
const handleImageUpload = async (image: string, file: File) => {
try {
const imageUrl = await fileService.uploadImage(file);
const imageUrl = getImageUrl(await uploadImage({file}));
switch (image) {
case 'cover_image':
@ -531,8 +523,8 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
});
break;
}
} catch (err: any) {
// handle error
} catch (err) {
// TODO: handle error
}
};

View File

@ -4,15 +4,14 @@ import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react';
import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
import React, {useContext, useState} from 'react';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import UserDetailModal from './UserDetailModal';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {ServicesContext} from '../../providers/ServiceProvider';
import {User} from '../../../types/api';
import {UserInvite} from '../../../utils/api';
import {UserInvite, useAddInvite, useDeleteInvite} from '../../../utils/api/invites';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {showToast} from '../../../admin-x-ds/global/Toast';
@ -31,9 +30,9 @@ interface InviteListProps {
updateUser?: (user: User) => void;
}
const Owner: React.FC<OwnerProps> = ({user, updateUser}) => {
const Owner: React.FC<OwnerProps> = ({user}) => {
const showDetailModal = () => {
NiceModal.show(UserDetailModal, {user, updateUser});
NiceModal.show(UserDetailModal, {user});
};
if (!user) {
@ -51,9 +50,9 @@ const Owner: React.FC<OwnerProps> = ({user, updateUser}) => {
);
};
const UsersList: React.FC<UsersListProps> = ({users, updateUser}) => {
const UsersList: React.FC<UsersListProps> = ({users}) => {
const showDetailModal = (user: User) => {
NiceModal.show(UserDetailModal, {user, updateUser});
NiceModal.show(UserDetailModal, {user});
};
if (!users || !users.length) {
@ -91,10 +90,12 @@ const UsersList: React.FC<UsersListProps> = ({users, updateUser}) => {
};
const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
const {api} = useContext(ServicesContext);
const {setInvites} = useStaffUsers();
const [revokeState, setRevokeState] = useState<'progress'|''>('');
const [resendState, setResendState] = useState<'progress'|''>('');
const {mutateAsync: deleteInvite} = useDeleteInvite();
const {mutateAsync: addInvite} = useAddInvite();
let revokeActionLabel = 'Revoke';
if (revokeState === 'progress') {
revokeActionLabel = 'Revoking...';
@ -111,9 +112,7 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
link={true}
onClick={async () => {
setRevokeState('progress');
await api.invites.delete(invite.id);
const res = await api.invites.browse();
setInvites(res.invites);
await deleteInvite(invite.id);
setRevokeState('');
showToast({
message: `Invitation revoked (${invite.email})`,
@ -128,13 +127,11 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
link={true}
onClick={async () => {
setResendState('progress');
await api.invites.delete(invite.id);
await api.invites.add({
await deleteInvite(invite.id);
await addInvite({
email: invite.email,
roleId: invite.role_id
});
const res = await api.invites.browse();
setInvites(res.invites);
setResendState('');
showToast({
message: `Invitation resent! (${invite.email})`,
@ -187,8 +184,7 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
editorUsers,
authorUsers,
contributorUsers,
invites,
updateUser
invites
} = useStaffUsers();
const {updateRoute} = useRouting();
const showInviteModal = () => {
@ -207,27 +203,27 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
{
id: 'users-admins',
title: 'Administrators',
contents: (<UsersList updateUser={updateUser} users={adminUsers} />)
contents: (<UsersList users={adminUsers} />)
},
{
id: 'users-editors',
title: 'Editors',
contents: (<UsersList updateUser={updateUser} users={editorUsers} />)
contents: (<UsersList users={editorUsers} />)
},
{
id: 'users-authors',
title: 'Authors',
contents: (<UsersList updateUser={updateUser} users={authorUsers} />)
contents: (<UsersList users={authorUsers} />)
},
{
id: 'users-contributors',
title: 'Contributors',
contents: (<UsersList updateUser={updateUser} users={contributorUsers} />)
contents: (<UsersList users={contributorUsers} />)
},
{
id: 'users-invited',
title: 'Invited',
contents: (<InvitesUserList updateUser={updateUser} users={invites} />)
contents: (<InvitesUserList users={invites} />)
}
];
@ -239,7 +235,7 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
testId='users'
title='Users and permissions'
>
<Owner updateUser={updateUser} user={ownerUser} />
<Owner user={ownerUser} />
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
</SettingGroup>
);

View File

@ -1,13 +1,12 @@
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
import React, {useContext, useEffect, useState} from 'react';
import React from 'react';
import Select from '../../../admin-x-ds/global/form/Select';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {GroupBase, MultiValue} from 'react-select';
import {ServicesContext} from '../../providers/ServiceProvider';
import {Tier} from '../../../types/api';
import {getOptionLabel, getPaidActiveTiers, getSettingValues} from '../../../utils/helpers';
import {getOptionLabel, getSettingValues} from '../../../utils/helpers';
import {useGlobalData} from '../../providers/DataProvider';
const MEMBERS_SIGNUP_ACCESS_OPTIONS = [
{value: 'all', label: 'Anyone can sign up'},
@ -47,14 +46,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
const defaultContentVisibilityLabel = getOptionLabel(DEFAULT_CONTENT_VISIBILITY_OPTIONS, defaultContentVisibility);
const commentsEnabledLabel = getOptionLabel(COMMENTS_ENABLED_OPTIONS, commentsEnabled);
const {api} = useContext(ServicesContext);
const [tiers, setTiers] = useState<Tier[]>([]);
useEffect(() => {
api.tiers.browse().then((response) => {
setTiers(getPaidActiveTiers(response.tiers));
});
}, [api]);
const {tiers} = useGlobalData();
const tierOptionGroups: GroupBase<MultiSelectOption>[] = [
{

View File

@ -1,16 +1,16 @@
import React, {useState} from 'react';
import React, { useState } from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import StripeButton from '../../../admin-x-ds/settings/StripeButton';
import TabView from '../../../admin-x-ds/global/TabView';
import TiersList from './tiers/TiersList';
import useRouting from '../../../hooks/useRouting';
import {Tier} from '../../../types/api';
import {getActiveTiers, getArchivedTiers} from '../../../utils/helpers';
import {useTiers} from '../../providers/ServiceProvider';
import { Tier } from '../../../types/api';
import { getActiveTiers, getArchivedTiers } from '../../../utils/helpers';
import { useGlobalData } from '../../providers/DataProvider';
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-tiers');
const {data: tiers, update: updateTier} = useTiers();
const {tiers} = useGlobalData();
const activeTiers = getActiveTiers(tiers);
const archivedTiers = getArchivedTiers(tiers);
const {updateRoute} = useRouting();
@ -34,12 +34,12 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
{
id: 'active-tiers',
title: 'Active',
contents: (<TiersList tab='active-tiers' tiers={sortTiers(activeTiers)} updateTier={updateTier} />)
contents: (<TiersList tab='active-tiers' tiers={sortTiers(activeTiers)} />)
},
{
id: 'archived-tiers',
title: 'Archived',
contents: (<TiersList tab='archive-tiers' tiers={sortTiers(archivedTiers)} updateTier={updateTier} />)
contents: (<TiersList tab='archive-tiers' tiers={sortTiers(archivedTiers)} />)
}
];

View File

@ -1,9 +1,9 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import React, {FocusEventHandler, useContext, useState} from 'react';
import React, {FocusEventHandler, useState} from 'react';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {Setting, SettingValue} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {fullEmailAddress, getEmailDomain, getSettingValues} from '../../../../utils/helpers';
import {useGlobalData} from '../../../providers/DataProvider';
const AccountPage: React.FC<{
localSettings: Setting[]
@ -11,7 +11,7 @@ const AccountPage: React.FC<{
}> = ({localSettings, updateSetting}) => {
const [membersSupportAddress] = getSettingValues(localSettings, ['members_support_address']);
const {siteData} = useContext(SettingsContext) || {};
const {siteData} = useGlobalData();
const emailDomain = getEmailDomain(siteData!);
const [value, setValue] = useState(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!));

View File

@ -1,5 +1,5 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import React, {useContext, useState} from 'react';
import React, {useState} from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
@ -15,7 +15,7 @@ import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-ico
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
import {ReactComponent as PortalIcon4} from '../../../../assets/icons/portal-icon-4.svg';
import {ReactComponent as PortalIcon5} from '../../../../assets/icons/portal-icon-5.svg';
import {ServicesContext} from '../../../providers/ServiceProvider';
import {getImageUrl, useUploadImage} from '../../../../utils/api/images';
const defaultButtonIcons = [
{
@ -44,7 +44,7 @@ const LookAndFeel: React.FC<{
localSettings: Setting[]
updateSetting: (key: string, setting: SettingValue) => void
}> = ({localSettings, updateSetting}) => {
const {fileService} = useContext(ServicesContext);
const {mutateAsync: uploadImage} = useUploadImage();
const [portalButton, portalButtonStyle, portalButtonIcon, portalButtonSignupText] = getSettingValues(localSettings, ['portal_button', 'portal_button_style', 'portal_button_icon', 'portal_button_signup_text']);
@ -54,7 +54,7 @@ const LookAndFeel: React.FC<{
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
const handleImageUpload = async (file: File) => {
const imageUrl = await fileService!.uploadImage(file);
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
};

View File

@ -1,7 +1,7 @@
import React, {useEffect, useRef, useState} from 'react';
import React, { useEffect, useRef, useState } from 'react';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Setting, SiteData, Tier} from '../../../../types/api';
import {getSettingValue} from '../../../../utils/helpers';
import { Setting, SiteData, Tier } from '../../../../types/api';
import { getSettingValue } from '../../../../utils/helpers';
type PortalFrameProps = {
settings: Setting[];
@ -91,7 +91,7 @@ const PortalFrame: React.FC<PortalFrameProps> = ({settings, tiers, selectedTab})
});
useEffect(() => {
const messageListener = (event: any) => {
const messageListener = (event: MessageEvent<'portal-ready' | {type: string}>) => {
if (!href) {
return;
}

View File

@ -2,12 +2,11 @@ import Button from '../../../../admin-x-ds/global/Button';
import List from '../../../../admin-x-ds/global/List';
import ListItem from '../../../../admin-x-ds/global/ListItem';
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
import React, {useContext, useEffect, useState} from 'react';
import React, {useEffect, useState} from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {getHomepageUrl, getPaidActiveTiers} from '../../../../utils/helpers';
import {useTiers} from '../../../providers/ServiceProvider';
import {useGlobalData} from '../../../providers/DataProvider';
interface PortalLinkPrefs {
name: string;
@ -39,8 +38,7 @@ const PortalLink: React.FC<PortalLinkPrefs> = ({name, value}) => {
const PortalLinks: React.FC = () => {
const [isDataAttributes, setIsDataAttributes] = useState(false);
const [selectedTier, setSelectedTier] = useState('');
const {siteData} = useContext(SettingsContext);
const {data: allTiers} = useTiers();
const {siteData, tiers: allTiers} = useGlobalData();
const tiers = getPaidActiveTiers(allTiers);
const toggleIsDataAttributes = () => {
@ -104,4 +102,4 @@ const PortalLinks: React.FC = () => {
);
};
export default PortalLinks;
export default PortalLinks;

View File

@ -3,16 +3,17 @@ import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationM
import LookAndFeel from './LookAndFeel';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PortalPreview from './PortalPreview';
import React, {useContext, useState} from 'react';
import React, {useState} from 'react';
import SignupOptions from './SignupOptions';
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
import useForm, {Dirtyable} from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSettings from '../../../../hooks/useSettings';
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
import {Setting, SettingValue, Tier} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {fullEmailAddress, getPaidActiveTiers} from '../../../../utils/helpers';
import {useTiers} from '../../../providers/ServiceProvider';
import {useEditTier} from '../../../../utils/api/tiers';
import {useGlobalData} from '../../../providers/DataProvider';
const Sidebar: React.FC<{
localSettings: Setting[]
@ -66,10 +67,12 @@ const PortalModal: React.FC = () => {
const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
const {settings, saveSettings, siteData} = useContext(SettingsContext);
const {data: allTiers, update: updateTiers} = useTiers();
const {settings, saveSettings, siteData} = useSettings();
const {tiers: allTiers} = useGlobalData();
const tiers = getPaidActiveTiers(allTiers);
const {mutateAsync: editTier} = useEditTier();
const {formState, saveState, handleSave, updateForm} = useForm({
initialState: {
settings: settings as Dirtyable<Setting>[],
@ -77,7 +80,8 @@ const PortalModal: React.FC = () => {
},
onSave: async () => {
await updateTiers(...formState.tiers.filter(tier => tier.dirty));
await Promise.all(formState.tiers.filter(({dirty}) => dirty).map(tier => editTier(tier)));
const {meta, settings: currentSettings} = await saveSettings(formState.settings.filter(setting => setting.dirty));
if (meta?.sent_email_verification) {

View File

@ -1,12 +1,12 @@
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
import Form from '../../../../admin-x-ds/global/form/Form';
import HtmlField from '../../../../admin-x-ds/global/form/HtmlField';
import React, {useContext, useEffect, useMemo} from 'react';
import HtmlField, { EditorConfig } from '../../../../admin-x-ds/global/form/HtmlField';
import React, { useEffect, useMemo } from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
import {Setting, SettingValue, Tier} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {checkStripeEnabled, getSettingValues} from '../../../../utils/helpers';
import { CheckboxProps } from '../../../../admin-x-ds/global/form/Checkbox';
import { Setting, SettingValue, Tier } from '../../../../types/api';
import { checkStripeEnabled, getSettingValues } from '../../../../utils/helpers';
import { useGlobalData } from '../../../providers/DataProvider';
const SignupOptions: React.FC<{
localSettings: Setting[]
@ -16,7 +16,7 @@ const SignupOptions: React.FC<{
errors: Record<string, string | undefined>
setError: (key: string, error: string | undefined) => void
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
const {config} = useContext(SettingsContext);
const {config} = useGlobalData();
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
@ -119,7 +119,7 @@ const SignupOptions: React.FC<{
)}
<HtmlField
config={config as { editor: any }}
config={config as EditorConfig}
error={Boolean(errors.portal_signup_terms_html)}
hint={errors.portal_signup_terms_html || <>Recommended: <strong>115</strong> characters. You&apos;ve used <strong className="text-green">{signupTermsLength}</strong></>}
nodes='MINIMAL_NODES'

View File

@ -3,8 +3,8 @@ import Form from '../../../../admin-x-ds/global/form/Form';
import Heading from '../../../../admin-x-ds/global/Heading';
import Icon from '../../../../admin-x-ds/global/Icon';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
import React, { useState } from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import SortableList from '../../../../admin-x-ds/global/SortableList';
import TextField from '../../../../admin-x-ds/global/form/TextField';
@ -14,12 +14,12 @@ import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
import {Tier} from '../../../../types/api';
import {currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {getSettingValues} from '../../../../utils/helpers';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import { Tier } from '../../../../types/api';
import { currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol } from '../../../../utils/currency';
import { getSettingValues } from '../../../../utils/helpers';
import { showToast } from '../../../../admin-x-ds/global/Toast';
import { toast } from 'react-hot-toast';
import {useTiers} from '../../../providers/ServiceProvider';
import { useAddTier, useEditTier } from '../../../../utils/api/tiers';
interface TierDetailModalProps {
tier?: Tier
@ -36,7 +36,8 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
const modal = useModal();
const {updateRoute} = useRouting();
const {update: updateTier, create: createTier} = useTiers();
const {mutateAsync: updateTier} = useEditTier();
const {mutateAsync: createTier} = useAddTier();
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
const {localSettings} = useSettingGroup();
const siteTitle = getSettingValues(localSettings, ['title']) as string[];

View File

@ -5,27 +5,24 @@ import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
import React from 'react';
import TierDetailModal from './TierDetailModal';
import useRouting from '../../../../hooks/useRouting';
import {Tier} from '../../../../types/api';
import {currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {numberWithCommas} from '../../../../utils/helpers';
import { Tier } from '../../../../types/api';
import { currencyToDecimal, getSymbol } from '../../../../utils/currency';
import { numberWithCommas } from '../../../../utils/helpers';
import { useEditTier } from '../../../../utils/api/tiers';
interface TiersListProps {
tab?: string;
tiers: Tier[];
updateTier: (data: Tier) => Promise<void>;
}
interface TierCardProps {
tier: Tier;
updateTier: (data: Tier) => Promise<void>;
}
const cardContainerClasses = 'group flex min-h-[200px] flex-col items-start justify-between gap-4 self-stretch rounded-sm border border-grey-300 p-4 transition-all hover:border-grey-400';
const TierCard: React.FC<TierCardProps> = ({
tier,
updateTier
}) => {
const TierCard: React.FC<TierCardProps> = ({tier}) => {
const {mutateAsync: updateTier} = useEditTier();
const currency = tier?.currency || 'USD';
const currencySymbol = currency ? getSymbol(currency) : '$';
@ -60,8 +57,7 @@ const TierCard: React.FC<TierCardProps> = ({
const TiersList: React.FC<TiersListProps> = ({
tab,
tiers,
updateTier
tiers
}) => {
const {updateRoute} = useRouting();
const openTierModal = () => {
@ -79,7 +75,7 @@ const TiersList: React.FC<TiersListProps> = ({
return (
<div className='mt-4 grid grid-cols-3 gap-4'>
{tiers.map((tier) => {
return <TierCard tier={tier} updateTier={updateTier} />;
return <TierCard tier={tier} />;
})}
{tab === 'active-tiers' && (
<button className={`${cardContainerClasses} group cursor-pointer`} type='button' onClick={() => {

View File

@ -1,20 +1,21 @@
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
import BrandSettings, { BrandSettingValues } from './designAndBranding/BrandSettings';
// import Button from '../../../admin-x-ds/global/Button';
// import ChangeThemeModal from './ThemeModal';
import Icon from '../../../admin-x-ds/global/Icon';
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
import React, {useContext, useEffect, useState} from 'react';
import NiceModal, { NiceModalHandler, useModal } from '@ebay/nice-modal-react';
import React, { useEffect, useState } from 'react';
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import TabView, { Tab } from '../../../admin-x-ds/global/TabView';
import ThemePreview from './designAndBranding/ThemePreview';
import ThemeSettings from './designAndBranding/ThemeSettings';
import useForm from '../../../hooks/useForm';
import useRouting from '../../../hooks/useRouting';
import {CustomThemeSetting, Post, Setting, SettingValue} from '../../../types/api';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
import {ServicesContext} from '../../providers/ServiceProvider';
import {SettingsContext} from '../../providers/SettingsProvider';
import {getHomepageUrl, getSettingValues} from '../../../utils/helpers';
import useSettings from '../../../hooks/useSettings';
import { CustomThemeSetting, Setting, SettingValue } from '../../../types/api';
import { PreviewModalContent } from '../../../admin-x-ds/global/modal/PreviewModal';
import { getHomepageUrl, getSettingValues } from '../../../utils/helpers';
import { useBrowseCustomThemeSettings, useEditCustomThemeSettings } from '../../../utils/api/customThemeSettings';
import { useBrowsePosts } from '../../../utils/api/posts';
const Sidebar: React.FC<{
brandSettings: BrandSettingValues
@ -78,19 +79,18 @@ const Sidebar: React.FC<{
const DesignModal: React.FC = () => {
const modal = useModal();
const {api} = useContext(ServicesContext);
const {settings, siteData, saveSettings} = useContext(SettingsContext);
const [themeSettings, setThemeSettings] = useState<Array<CustomThemeSetting>>([]);
const [latestPost, setLatestPost] = useState<Post | null>(null);
const {settings, siteData, saveSettings} = useSettings();
const {data: {posts: [latestPost]} = {posts: []}} = useBrowsePosts({
filter: 'status:published',
order: 'published_at DESC',
limit: '1',
fields: 'id,url'
});
const {data: themeSettings} = useBrowseCustomThemeSettings();
const {mutateAsync: editThemeSettings} = useEditCustomThemeSettings();
const [selectedPreviewTab, setSelectedPreviewTab] = useState('homepage');
const {updateRoute} = useRouting();
useEffect(() => {
api.latestPost.browse().then((response) => {
setLatestPost(response.posts[0]);
});
}, [api]);
const {
formState,
saveState,
@ -100,12 +100,11 @@ const DesignModal: React.FC = () => {
} = useForm({
initialState: {
settings: settings as Array<Setting & { dirty?: boolean }>,
themeSettings: themeSettings as Array<CustomThemeSetting & { dirty?: boolean }>
themeSettings: (themeSettings?.custom_theme_settings || []) as Array<CustomThemeSetting & { dirty?: boolean }>
},
onSave: async () => {
if (formState.themeSettings.some(setting => setting.dirty)) {
const response = await api.customThemeSettings.edit(formState.themeSettings);
setThemeSettings(response.custom_theme_settings);
const response = await editThemeSettings(formState.themeSettings);
updateForm(state => ({...state, themeSettings: response.custom_theme_settings}));
}
@ -117,11 +116,10 @@ const DesignModal: React.FC = () => {
});
useEffect(() => {
api.customThemeSettings.browse().then((response) => {
setThemeSettings(response.custom_theme_settings);
setFormState(state => ({...state, themeSettings: response.custom_theme_settings}));
});
}, [api, updateForm, setFormState]);
if (themeSettings) {
setFormState(state => ({...state, themeSettings: themeSettings.custom_theme_settings}));
}
}, [setFormState, themeSettings]);
const updateBrandSetting = (key: string, value: SettingValue) => {
updateForm(state => ({...state, settings: state.settings.map(setting => (

View File

@ -4,19 +4,17 @@ import Button from '../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
import FileUpload from '../../../admin-x-ds/global/form/FileUpload';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
import NiceModal, { NiceModalHandler, useModal } from '@ebay/nice-modal-react';
import OfficialThemes from './theme/OfficialThemes';
import PageHeader from '../../../admin-x-ds/global/layout/PageHeader';
import React, {useState} from 'react';
import React, { useState } from 'react';
import TabView from '../../../admin-x-ds/global/TabView';
import ThemeInstalledModal from './theme/ThemeInstalledModal';
import ThemePreview from './theme/ThemePreview';
import useRouting from '../../../hooks/useRouting';
import {API} from '../../../utils/api';
import {OfficialTheme} from '../../../models/themes';
import {Theme} from '../../../types/api';
import {useApi} from '../../providers/ServiceProvider';
import {useThemes} from '../../../hooks/useThemes';
import { OfficialTheme } from '../../../models/themes';
import { Theme } from '../../../types/api';
import { useBrowseThemes, useInstallTheme, useUploadTheme } from '../../../utils/api/themes';
interface ThemeToolbarProps {
selectedTheme: OfficialTheme|null;
@ -25,7 +23,6 @@ interface ThemeToolbarProps {
setSelectedTheme: (theme: OfficialTheme|null) => void;
modal: NiceModalHandler<Record<string, unknown>>;
themes: Theme[];
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
setPreviewMode: (mode: string) => void;
previewMode: string;
}
@ -34,92 +31,68 @@ interface ThemeModalContentProps {
onSelectTheme: (theme: OfficialTheme|null) => void;
currentTab: string;
themes: Theme[];
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
}
function addThemeToList(theme: Theme, themes: Theme[]): Theme[] {
const existingTheme = themes.find(t => t.name === theme.name);
if (existingTheme) {
return themes.map((t) => {
if (t.name === theme.name) {
return theme;
}
return t;
});
}
return [...themes, theme];
}
async function handleThemeUpload({
api,
file,
setThemes,
onActivate
}: {
api: API;
file: File;
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
onActivate?: () => void
}) {
const data = await api.themes.upload({file});
const uploadedTheme = data.themes[0];
setThemes((_themes: Theme[]) => {
return addThemeToList(uploadedTheme, _themes);
});
let title = 'Upload successful';
let prompt = <>
<strong>{uploadedTheme.name}</strong> uploaded successfully.
</>;
if (!uploadedTheme.active) {
prompt = <>
{prompt}{' '}
Do you want to activate it now?
</>;
}
if (uploadedTheme.errors?.length || uploadedTheme.warnings?.length) {
const hasErrors = uploadedTheme.errors?.length;
title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`;
prompt = <>
The theme <strong>&quot;{uploadedTheme.name}&quot;</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
</>;
if (!uploadedTheme.active) {
prompt = <>
{prompt}
You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so.
</>;
}
}
NiceModal.show(ThemeInstalledModal, {
title,
prompt,
installedTheme: uploadedTheme,
setThemes,
onActivate: onActivate
});
}
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
currentTab,
setCurrentTab,
modal,
themes,
setThemes
themes
}) => {
const {updateRoute} = useRouting();
const api = useApi();
const {mutateAsync: uploadTheme} = useUploadTheme();
const onClose = () => {
updateRoute('design/edit');
modal.remove();
};
const handleThemeUpload = async ({
file,
onActivate
}: {
file: File;
onActivate?: () => void
}) => {
const data = await uploadTheme({file});
const uploadedTheme = data.themes[0];
let title = 'Upload successful';
let prompt = <>
<strong>{uploadedTheme.name}</strong> uploaded successfully.
</>;
if (!uploadedTheme.active) {
prompt = <>
{prompt}{' '}
Do you want to activate it now?
</>;
}
if (uploadedTheme.errors?.length || uploadedTheme.warnings?.length) {
const hasErrors = uploadedTheme.errors?.length;
title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`;
prompt = <>
The theme <strong>&quot;{uploadedTheme.name}&quot;</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
</>;
if (!uploadedTheme.active) {
prompt = <>
{prompt}
You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so.
</>;
}
}
NiceModal.show(ThemeInstalledModal, {
title,
prompt,
installedTheme: uploadedTheme,
onActivate: onActivate
});
};
const left =
<Breadcrumbs
items={[
@ -160,14 +133,14 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
okRunningLabel: 'Overwriting...',
okColor: 'red',
onOk: async (confirmModal) => {
await handleThemeUpload({api, file, setThemes, onActivate: onClose});
await handleThemeUpload({file, onActivate: onClose});
setCurrentTab('installed');
confirmModal?.remove();
}
});
} else {
setCurrentTab('installed');
handleThemeUpload({api, file, setThemes, onActivate: onClose});
handleThemeUpload({file, onActivate: onClose});
}
}}>
<Button color='black' label='Upload theme' tag='div' />
@ -184,8 +157,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
currentTab,
onSelectTheme,
themes,
setThemes
themes
}) => {
switch (currentTab) {
case 'official':
@ -194,10 +166,7 @@ const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
);
case 'installed':
return (
<AdvancedThemeSettings
setThemes={setThemes}
themes={themes}
/>
<AdvancedThemeSettings themes={themes} />
);
}
return null;
@ -211,27 +180,27 @@ const ChangeThemeModal = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = useModal();
const {themes, setThemes} = useThemes();
const api = useApi();
const {data: {themes} = {}} = useBrowseThemes();
const {mutateAsync: installTheme} = useInstallTheme();
const onSelectTheme = (theme: OfficialTheme|null) => {
setSelectedTheme(theme);
};
if (!themes) {
return;
}
let installedTheme;
let onInstall;
if (selectedTheme) {
installedTheme = themes.find(theme => theme.name.toLowerCase() === selectedTheme!.name.toLowerCase());
onInstall = async () => {
setInstalling(true);
const data = await api.themes.install(selectedTheme.ref);
const data = await installTheme(selectedTheme.ref);
setInstalling(false);
const newlyInstalledTheme = data.themes[0];
setThemes([
...themes.map(theme => ({...theme, active: false})),
newlyInstalledTheme
]);
let title = 'Success';
let prompt = <>
@ -265,7 +234,6 @@ const ChangeThemeModal = NiceModal.create(() => {
title,
prompt,
installedTheme: newlyInstalledTheme,
setThemes,
onActivate: () => {
updateRoute('design/edit');
modal.remove();
@ -312,12 +280,10 @@ const ChangeThemeModal = NiceModal.create(() => {
setCurrentTab={setCurrentTab}
setPreviewMode={setPreviewMode}
setSelectedTheme={setSelectedTheme}
setThemes={setThemes}
themes={themes}
/>
<ThemeModalContent
currentTab={currentTab}
setThemes={setThemes}
themes={themes}
onSelectTheme={onSelectTheme}
/>

View File

@ -1,11 +1,11 @@
import Heading from '../../../../admin-x-ds/global/Heading';
import Hint from '../../../../admin-x-ds/global/Hint';
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import React, {useContext} from 'react';
import React from 'react';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import {ServicesContext} from '../../../providers/ServiceProvider';
import {SettingValue} from '../../../../types/api';
import {getImageUrl, useUploadImage} from '../../../../utils/api/images';
export interface BrandSettingValues {
description: string
@ -16,7 +16,7 @@ export interface BrandSettingValues {
}
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
const {fileService} = useContext(ServicesContext);
const {mutateAsync: uploadImage} = useUploadImage();
return (
<div className='mt-7'>
@ -59,7 +59,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
width={values.icon ? '66px' : '150px'}
onDelete={() => updateSetting('icon', null)}
onUpload={async (file) => {
updateSetting('icon', await fileService!.uploadImage(file));
updateSetting('icon', getImageUrl(await uploadImage({file})));
}}
>
Upload icon
@ -77,7 +77,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
imageURL={values.logo || ''}
onDelete={() => updateSetting('logo', null)}
onUpload={async (file) => {
updateSetting('logo', await fileService!.uploadImage(file));
updateSetting('logo', getImageUrl(await uploadImage({file})));
}}
>
Upload logo
@ -92,7 +92,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
imageURL={values.coverImage || ''}
onDelete={() => updateSetting('cover_image', null)}
onUpload={async (file) => {
updateSetting('cover_image', await fileService!.uploadImage(file));
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
}}
>
Upload cover

View File

@ -1,23 +1,23 @@
import Heading from '../../../../admin-x-ds/global/Heading';
import Hint from '../../../../admin-x-ds/global/Hint';
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import React, {useContext} from 'react';
import React from 'react';
import Select from '../../../../admin-x-ds/global/form/Select';
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {CustomThemeSetting} from '../../../../types/api';
import {ServicesContext} from '../../../providers/ServiceProvider';
import {humanizeSettingKey} from '../../../../utils/helpers';
import { CustomThemeSetting } from '../../../../types/api';
import { getImageUrl, useUploadImage } from '../../../../utils/api/images';
import { humanizeSettingKey } from '../../../../utils/helpers';
const ThemeSetting: React.FC<{
setting: CustomThemeSetting,
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
}> = ({setting, setSetting}) => {
const {fileService} = useContext(ServicesContext);
const {mutateAsync: uploadImage} = useUploadImage();
const handleImageUpload = async (file: File) => {
const imageUrl = await fileService!.uploadImage(file);
const imageUrl = getImageUrl(await uploadImage({file}));
setSetting(imageUrl);
};
@ -78,7 +78,7 @@ const ThemeSetting: React.FC<{
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
return (
<SettingGroupContent className='mt-7'>
{settings.map(setting => <ThemeSetting key={setting.key} setSetting={(value: any) => updateSetting({...setting, value})} setting={setting} />)}
{settings.map(setting => <ThemeSetting key={setting.key} setSetting={(value) => updateSetting({...setting, value} as CustomThemeSetting)} setting={setting} />)}
</SettingGroupContent>
);
};

View File

@ -9,17 +9,14 @@ import React from 'react';
import {Theme} from '../../../../types/api';
import {downloadFile, getGhostPaths} from '../../../../utils/helpers';
import {isActiveTheme, isDefaultTheme, isDeletableTheme} from '../../../../models/themes';
import {useApi} from '../../../providers/ServiceProvider';
import {useActivateTheme, useDeleteTheme} from '../../../../utils/api/themes';
interface ThemeActionProps {
theme: Theme;
themes: Theme[];
updateThemes: (themes: Theme[]) => void;
}
interface ThemeSettingProps {
themes: Theme[];
setThemes: (themes: Theme[]) => void;
}
function getThemeLabel(theme: Theme): React.ReactNode {
@ -49,26 +46,13 @@ function getThemeVersion(theme: Theme): string {
}
const ThemeActions: React.FC<ThemeActionProps> = ({
theme,
themes,
updateThemes
theme
}) => {
const api = useApi();
const {mutateAsync: activateTheme} = useActivateTheme();
const {mutateAsync: deleteTheme} = useDeleteTheme();
const handleActivate = async () => {
const data = await api.themes.activate(theme.name);
const updatedTheme = data.themes[0];
const updatedThemes: Theme[] = themes.map((t) => {
if (t.name === updatedTheme.name) {
return updatedTheme;
}
return {
...t,
active: false
};
});
updateThemes(updatedThemes);
await activateTheme(theme.name);
};
const handleDownload = async () => {
@ -98,9 +82,7 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
okRunningLabel: 'Deleting',
okColor: 'red',
onOk: async (modal) => {
await api.themes.delete(theme.name);
const updatedThemes = themes.filter(t => t.name !== theme.name);
updateThemes(updatedThemes);
await deleteTheme(theme.name);
modal?.remove();
}
});
@ -150,8 +132,7 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
};
const ThemeList:React.FC<ThemeSettingProps> = ({
themes,
setThemes
themes
}) => {
themes.sort((a, b) => {
if (a.active && !b.active) {
@ -173,13 +154,7 @@ const ThemeList:React.FC<ThemeSettingProps> = ({
return (
<ListItem
key={theme.name}
action={
<ThemeActions
theme={theme}
themes={themes}
updateThemes={setThemes}
/>
}
action={<ThemeActions theme={theme} />}
detail={detail}
id={`theme-${theme.name}`}
separator={false}
@ -192,16 +167,10 @@ const ThemeList:React.FC<ThemeSettingProps> = ({
);
};
const AdvancedThemeSettings: React.FC<ThemeSettingProps> = ({
themes,
setThemes
}) => {
const AdvancedThemeSettings: React.FC<ThemeSettingProps> = ({themes}) => {
return (
<ModalPage>
<ThemeList
setThemes={setThemes}
themes={themes}
/>
<ThemeList themes={themes} />
</ModalPage>
);
};

View File

@ -5,9 +5,9 @@ import ListItem from '../../../../admin-x-ds/global/ListItem';
import NiceModal from '@ebay/nice-modal-react';
import React, {ReactNode, useState} from 'react';
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import {InstalledTheme, Theme, ThemeProblem} from '../../../../types/api';
import {InstalledTheme, ThemeProblem} from '../../../../types/api';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useApi} from '../../../providers/ServiceProvider';
import {useActivateTheme} from '../../../../utils/api/themes';
const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
const [isExpanded, setExpanded] = useState(false);
@ -44,10 +44,9 @@ const ThemeInstalledModal: React.FC<{
title: string
prompt: ReactNode
installedTheme: InstalledTheme;
setThemes: (callback: (themes: Theme[]) => Theme[]) => void;
onActivate?: () => void;
}> = ({title, prompt, installedTheme, setThemes, onActivate}) => {
const api = useApi();
}> = ({title, prompt, installedTheme, onActivate}) => {
const {mutateAsync: activateTheme} = useActivateTheme();
let errorPrompt = null;
if (installedTheme.errors) {
@ -87,22 +86,9 @@ const ThemeInstalledModal: React.FC<{
title={title}
onOk={async (activateModal) => {
if (!installedTheme.active) {
const resData = await api.themes.activate(installedTheme.name);
const resData = await activateTheme(installedTheme.name);
const updatedTheme = resData.themes[0];
setThemes((_themes) => {
const updatedThemes: Theme[] = _themes.map((t) => {
if (t.name === updatedTheme.name) {
return updatedTheme;
}
return {
...t,
active: false
};
});
return updatedThemes;
});
showToast({
type: 'success',
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>

View File

@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import { useCallback, useEffect, useState } from 'react';
export type Dirtyable<Data> = Data & {
dirty?: boolean;
@ -23,7 +23,7 @@ export interface FormHook<State> {
// TODO: figure out if we need to extend `any`?
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
const useForm = <State extends any>({initialState, onSave}: {
const useForm = <State>({initialState, onSave}: {
initialState: State,
onSave: () => void | Promise<void>
}): FormHook<State> => {

View File

@ -1,37 +0,0 @@
import {useCallback, useEffect, useState} from 'react';
type FetcherFunction<T> = () => Promise<T>;
export function useRequest<T>(fetcher: FetcherFunction<T>) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
setIsLoading(true);
const responseData = await fetcher();
setData(responseData);
} catch (err: any) {
setError(err?.message);
} finally {
setIsLoading(false);
}
}, [fetcher]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = async () => {
setIsLoading(true);
await fetchData();
};
return {
data,
error,
isLoading,
refetch
};
}

View File

@ -1,29 +0,0 @@
import {RolesContext} from '../components/providers/RolesProvider';
import {UserRole} from '../types/api';
import {useContext} from 'react';
export type RolesHook = {
roles: UserRole[];
assignableRoles: UserRole[];
getRoleId: (roleName: string, roles: UserRole[]) => string;
};
function getRoleId(roleName: string, roles: UserRole[]): string {
const role = roles.find((r) => {
return r.name.toLowerCase() === roleName?.toLowerCase();
});
return role?.id || '';
}
const useRoles = (): RolesHook => {
const {roles, assignableRoles} = useContext(RolesContext);
return {
roles,
assignableRoles,
getRoleId
};
};
export default useRoles;

View File

@ -1,8 +1,8 @@
import React, {useContext, useEffect, useRef, useState} from 'react';
import React, {useEffect, useRef, useState} from 'react';
import useForm, {SaveState} from './useForm';
import useGlobalDirtyState from './useGlobalDirtyState';
import useSettings from './useSettings';
import {Setting, SettingValue, SiteData} from '../types/api';
import {SettingsContext} from '../components/providers/SettingsProvider';
interface LocalSetting extends Setting {
dirty?: boolean;
@ -24,8 +24,7 @@ const useSettingGroup = (): SettingGroupHook => {
// create a ref to focus the input field
const focusRef = useRef<HTMLInputElement>(null);
// get the settings and saveSettings function from the Settings Context
const {siteData, settings, saveSettings} = useContext(SettingsContext) || {};
const {siteData, settings, saveSettings} = useSettings();
const [isEditing, setEditing] = useState(false);

View File

@ -0,0 +1,96 @@
import {Setting} from '../types/api';
import {useCallback, useMemo} from 'react';
import {useEditSettings} from '../utils/api/settings';
import {useGlobalData} from '../components/providers/DataProvider';
function serialiseSettingsData(settings: Setting[]): Setting[] {
return settings.map((setting) => {
if (setting.key === 'facebook' && setting.value) {
const value = setting.value as string;
let [, user] = value.match(/(\S+)/) || [];
return {
key: setting.key,
value: `https://www.facebook.com/${user}`
};
}
if (setting.key === 'twitter' && setting.value) {
const value = setting.value as string;
let [, user] = value.match(/@?([^/]*)/) || [];
return {
key: setting.key,
value: `https://twitter.com/${user}`
};
}
return {
key: setting.key,
value: setting.value
};
});
}
function deserializeSettings(settings: Setting[]): Setting[] {
return settings.map((setting) => {
if (setting.key === 'facebook' && setting.value) {
const deserialized = setting.value as string;
let [, user] = deserialized.match(/(?:https:\/\/)(?:www\.)(?:facebook\.com)\/(?:#!\/)?(\w+\/?\S+)/mi) || [];
return {
key: setting.key,
value: user
};
}
if (setting.key === 'twitter' && setting.value) {
const deserialized = setting.value as string;
let [, user] = deserialized.match(/(?:https:\/\/)(?:twitter\.com)\/(?:#!\/)?@?([^/]*)/) || [];
return {
key: setting.key,
value: `@${user}`
};
}
return {
key: setting.key,
value: setting.value
};
});
}
// TODO: Make this not a provider
const useSettings = () => {
const {settings, siteData, config} = useGlobalData();
const {mutateAsync: editSettings} = useEditSettings();
const saveSettings = useCallback(async (updatedSettings: Setting[]) => {
try {
// handle transformation for settings before save
updatedSettings = deserializeSettings(updatedSettings);
// Make an API call to save the updated settings
const data = await editSettings(updatedSettings);
const newSettings = serialiseSettingsData(data.settings);
return {
settings: newSettings,
meta: data.meta
};
} catch (error) {
// Log error in settings API
return {settings: []};
}
}, [editSettings]);
const serializedSettings = useMemo(() => serialiseSettingsData(settings), [settings]);
return {
settings: serializedSettings,
saveSettings,
siteData,
config
};
};
export default useSettings;

View File

@ -1,8 +1,7 @@
import React, {useContext} from 'react';
import {RolesContext} from '../components/providers/RolesProvider';
import {User} from '../types/api';
import {UserInvite} from '../utils/api';
import {UsersContext} from '../components/providers/UsersProvider';
import {UserInvite} from '../utils/api/invites';
import {useBrowseRoles} from '../utils/api/roles';
import {useGlobalData} from '../components/providers/DataProvider';
export type UsersHook = {
users: User[];
@ -13,9 +12,6 @@ export type UsersHook = {
authorUsers: User[];
contributorUsers: User[];
currentUser: User|null;
updateUser?: (user: User) => Promise<void>;
setInvites: (invites: UserInvite[]) => void;
setUsers: React.Dispatch<React.SetStateAction<User[]>>
};
function getUsersByRole(users: User[], role: string): User[] {
@ -31,15 +27,16 @@ function getOwnerUser(users: User[]): User {
}
const useStaffUsers = (): UsersHook => {
const {users, currentUser, updateUser, invites, setInvites, setUsers} = useContext(UsersContext);
const {roles} = useContext(RolesContext);
const {users, currentUser, invites} = useGlobalData();
const {data: {roles} = {}} = useBrowseRoles();
const ownerUser = getOwnerUser(users);
const adminUsers = getUsersByRole(users, 'Administrator');
const editorUsers = getUsersByRole(users, 'Editor');
const authorUsers = getUsersByRole(users, 'Author');
const contributorUsers = getUsersByRole(users, 'Contributor');
const mappedInvites = invites?.map((invite) => {
let role = roles.find((r) => {
let role = roles?.find((r) => {
return invite.role_id === r.id;
});
return {
@ -56,10 +53,7 @@ const useStaffUsers = (): UsersHook => {
authorUsers,
contributorUsers,
currentUser,
invites: mappedInvites,
updateUser,
setInvites,
setUsers
invites: mappedInvites
};
};

View File

@ -1,25 +0,0 @@
import {Theme} from '../types/api';
import {ThemesResponseType} from '../utils/api';
import {useApi} from '../components/providers/ServiceProvider';
import {useEffect, useState} from 'react';
import {useRequest} from './useRequest';
export function useThemes() {
const api = useApi();
const [themes, setThemes] = useState<Theme[]>([]);
const {data, error, isLoading} = useRequest<ThemesResponseType>(api.themes.browse);
useEffect(() => {
if (data) {
setThemes(data.themes);
}
}, [data]);
return {
themes,
error,
isLoading,
setThemes
};
}

View File

@ -1,3 +1,7 @@
export type JSONValue = string|number|boolean|null|Date|JSONObject|JSONArray;
export interface JSONObject { [key: string]: JSONValue }
export interface JSONArray extends Array<string|number|boolean|Date|JSONObject|JSONValue> {}
export type SettingValue = string | boolean | null;
export type Setting = {
@ -5,9 +9,7 @@ export type Setting = {
value: SettingValue;
}
export type Config = {
[key: string]: any;
}
export type Config = JSONObject;
export type User = {
id: string;

View File

@ -1,482 +0,0 @@
import {Config, CustomThemeSetting, InstalledTheme, Label, Offer, Post, Setting, SiteData, Theme, Tier, User, UserRole} from '../types/api';
import {getGhostPaths} from './helpers';
export interface Meta {
pagination: {
page: number;
limit: number;
pages: number;
total: number;
next: number;
prev: number;
}
}
export type SettingsResponseMeta = Meta & { sent_email_verification?: boolean }
export interface SettingsResponseType {
meta?: SettingsResponseMeta;
settings: Setting[];
}
export interface ConfigResponseType {
config: Config;
}
export interface UsersResponseType {
meta?: Meta;
users: User[];
}
export interface DeleteUserResponse {
meta: {
filename: string;
}
}
export interface RolesResponseType {
meta?: Meta;
roles: UserRole[];
}
export interface UserInvite {
created_at: string;
email: string;
expires: number;
id: string;
role_id: string;
role?: string;
status: string;
updated_at: string;
}
export interface InvitesResponseType {
meta?: Meta;
invites: UserInvite[];
}
export interface CustomThemeSettingsResponseType {
custom_theme_settings: CustomThemeSetting[];
}
export interface PostsResponseType {
meta?: Meta
posts: Post[];
}
export interface TiersResponseType {
meta?: Meta
tiers: Tier[]
}
export interface LabelsResponseType {
meta?: Meta
labels: Label[]
}
export interface OffersResponseType {
meta?: Meta
offers: Offer[]
}
export interface SiteResponseType {
site: SiteData;
}
export interface ImagesResponseType {
images: {
url: string;
ref: string | null;
}[];
}
export interface PasswordUpdateResponseType {
password: [{
message: string;
}];
}
export interface ThemesResponseType {
themes: Theme[];
}
export interface ThemesInstallResponseType {
themes: InstalledTheme[];
}
interface RequestOptions {
method?: string;
body?: string | FormData;
headers?: {
'Content-Type'?: string;
};
}
interface BrowseRoleOptions {
queryParams: {
[key: string]: string;
}
}
interface UpdatePasswordOptions {
newPassword: string;
confirmNewPassword: string;
userId: string;
oldPassword?: string;
}
export interface API {
settings: {
browse: () => Promise<SettingsResponseType>;
edit: (newSettings: Setting[]) => Promise<SettingsResponseType>;
};
config: {
browse: () => Promise<ConfigResponseType>;
};
users: {
browse: () => Promise<UsersResponseType>;
currentUser: () => Promise<User>;
edit: (editedUser: User) => Promise<UsersResponseType>;
delete: (userId: string) => Promise<DeleteUserResponse>;
updatePassword: (options: UpdatePasswordOptions) => Promise<PasswordUpdateResponseType>;
makeOwner: (userId: string) => Promise<UsersResponseType>;
};
roles: {
browse: (options?: BrowseRoleOptions) => Promise<RolesResponseType>;
};
site: {
browse: () => Promise<SiteResponseType>;
};
images: {
upload: ({file}: {file: File}) => Promise<ImagesResponseType>;
};
invites: {
browse: () => Promise<InvitesResponseType>;
add: ({email, roleId} : {
email: string;
roleId: string;
expires?: number;
status?: string;
token?: string;
}) => Promise<InvitesResponseType>;
delete: (inviteId: string) => Promise<void>;
};
customThemeSettings: {
browse: () => Promise<CustomThemeSettingsResponseType>
edit: (newSettings: CustomThemeSetting[]) => Promise<CustomThemeSettingsResponseType>
};
latestPost: {
browse: () => Promise<PostsResponseType>
};
tiers: {
browse: () => Promise<TiersResponseType>
edit: (newTier: Tier) => Promise<TiersResponseType>
add: (newTier: Partial<Tier>) => Promise<TiersResponseType>
};
labels: {
browse: () => Promise<LabelsResponseType>
};
offers: {
browse: () => Promise<OffersResponseType>
};
themes: {
browse: () => Promise<ThemesResponseType>;
activate: (themeName: string) => Promise<ThemesResponseType>;
delete: (themeName: string) => Promise<void>;
install: (repo: string) => Promise<ThemesInstallResponseType>;
upload: ({file}: {file: File}) => Promise<ThemesInstallResponseType>;
};
}
interface GhostApiOptions {
ghostVersion: string;
}
function setupGhostApi({ghostVersion}: GhostApiOptions): API {
const {apiRoot} = getGhostPaths();
function fetcher(url: string, options: RequestOptions = {}) {
const endpoint = `${apiRoot}${url}`;
// By default, we set the Content-Type header to application/json
const defaultHeaders = {
'app-pragma': 'no-cache',
'x-ghost-version': ghostVersion
};
const headers = options?.headers || {
'Content-Type': 'application/json'
};
return fetch(endpoint, {
headers: {
...defaultHeaders,
...headers
},
method: 'GET',
mode: 'cors',
credentials: 'include',
...options
});
}
const api: API = {
settings: {
browse: async () => {
const queryString = `group=site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura`;
const response = await fetcher(`/settings/?${queryString}`, {});
const data: SettingsResponseType = await response.json();
return data;
},
edit: async (newSettings: Setting[]) => {
const payload = JSON.stringify({
settings: newSettings
});
const response = await fetcher(`/settings/`, {
method: 'PUT',
body: payload
});
const data: SettingsResponseType = await response.json();
return data;
}
},
config: {
browse: async () => {
const response = await fetcher(`/config/`, {});
const data: ConfigResponseType = await response.json();
return data;
}
},
users: {
browse: async () => {
const response = await fetcher(`/users/?limit=all&include=roles`, {});
const data: UsersResponseType = await response.json();
return data;
},
currentUser: async (): Promise<User> => {
const response = await fetcher(`/users/me/`, {});
const data: UsersResponseType = await response.json();
return data.users[0];
},
edit: async (editedUser: User) => {
const payload = JSON.stringify({
users: [editedUser]
});
const response = await fetcher(`/users/${editedUser.id}/?include=roles`, {
method: 'PUT',
body: payload
});
const data: UsersResponseType = await response.json();
return data;
},
updatePassword: async ({newPassword, confirmNewPassword, userId, oldPassword}) => {
const payload = JSON.stringify({
password: [{
user_id: userId,
oldPassword: oldPassword || '',
newPassword: newPassword,
ne2Password: confirmNewPassword
}]
});
const response = await fetcher(`/users/password/`, {
method: 'PUT',
body: payload
});
const data: PasswordUpdateResponseType = await response.json();
return data;
},
delete: async (userId: string) => {
const response = await fetcher(`/users/${userId}/`, {
method: 'DELETE'
});
const data: DeleteUserResponse = await response.json();
return data;
},
makeOwner: async (userId: string) => {
const payload = JSON.stringify({
owner: [{
id: userId
}]
});
const response = await fetcher(`/users/owner/`, {
method: 'PUT',
body: payload
});
const data: UsersResponseType = await response.json();
return data;
}
},
roles: {
browse: async (options?: BrowseRoleOptions) => {
const queryParams = options?.queryParams || {};
queryParams.limit = 'all';
const queryString = Object.keys(options?.queryParams || {})
.map(key => `${key}=${options?.queryParams[key]}`)
.join('&');
const response = await fetcher(`/roles/?${queryString}`, {});
const data: RolesResponseType = await response.json();
return data;
}
},
site: {
browse: async () => {
const response = await fetcher(`/site/`, {});
const data: any = await response.json();
return data;
}
},
images: {
upload: async ({file}: {file: File}) => {
const formData = new FormData();
formData.append('file', file);
formData.append('purpose', 'image');
const response = await fetcher(`/images/upload/`, {
method: 'POST',
body: formData,
headers: {}
});
const data: any = await response.json();
return data;
}
},
invites: {
browse: async () => {
const response = await fetcher(`/invites/`, {});
const data: InvitesResponseType = await response.json();
return data;
},
add: async ({email, roleId}) => {
const payload = JSON.stringify({
invites: [{
email: email,
role_id: roleId,
expires: null,
status: null,
token: null
}]
});
const response = await fetcher(`/invites/`, {
method: 'POST',
body: payload
});
const data: InvitesResponseType = await response.json();
return data;
},
delete: async (inviteId: string) => {
await fetcher(`/invites/${inviteId}/`, {
method: 'DELETE'
});
return;
}
},
customThemeSettings: {
browse: async () => {
const response = await fetcher('/custom_theme_settings/');
const data: CustomThemeSettingsResponseType = await response.json();
return data;
},
edit: async (newSettings) => {
const response = await fetcher('/custom_theme_settings/', {
method: 'PUT',
body: JSON.stringify({custom_theme_settings: newSettings})
});
const data: CustomThemeSettingsResponseType = await response.json();
return data;
}
},
latestPost: {
browse: async () => {
const response = await fetcher('/posts/?filter=status%3Apublished&order=published_at%20DESC&limit=1&fields=id,url');
const data: PostsResponseType = await response.json();
return data;
}
},
tiers: {
browse: async () => {
const response = await fetcher(`/tiers/?limit=all`);
const data: TiersResponseType = await response.json();
return data;
},
edit: async (tier) => {
const response = await fetcher(`/tiers/${tier.id}`, {
method: 'PUT',
body: JSON.stringify({tiers: [tier]})
});
const data: TiersResponseType = await response.json();
return data;
},
add: async (tier) => {
const response = await fetcher(`/tiers/`, {
method: 'POST',
body: JSON.stringify({tiers: [tier]})
});
const data: TiersResponseType = await response.json();
return data;
}
},
labels: {
browse: async () => {
const response = await fetcher('/labels/?limit=all');
const data: LabelsResponseType = await response.json();
return data;
}
},
offers: {
browse: async () => {
const response = await fetcher('/offers/?limit=all');
const data: OffersResponseType = await response.json();
return data;
}
},
themes: {
browse: async () => {
const response = await fetcher('/themes/');
const data: ThemesResponseType = await response.json();
return data;
},
activate: async (themeName: string) => {
const response = await fetcher(`/themes/${themeName}/activate/`, {
method: 'PUT'
});
const data: ThemesResponseType = await response.json();
return data;
},
delete: async (themeName: string) => {
await fetcher(`/themes/${themeName}/`, {
method: 'DELETE'
});
return;
},
install: async (repo) => {
const response = await fetcher(`/themes/install/?source=github&ref=${encodeURIComponent(repo)}`, {
method: 'POST'
});
const data: ThemesResponseType = await response.json();
return data;
},
upload: async ({file}: {file: File}) => {
const formData = new FormData();
formData.append('file', file);
const response = await fetcher(`/themes/upload/`, {
method: 'POST',
body: formData,
headers: {}
});
const data: ThemesInstallResponseType = await response.json();
return data;
}
}
};
return api;
}
export default setupGhostApi;

View File

@ -0,0 +1,13 @@
import {Config} from '../../types/api';
import {createQuery} from '../apiRequests';
export interface ConfigResponseType {
config: Config;
}
const dataType = 'ConfigResponseType';
export const useBrowseConfig = createQuery<ConfigResponseType>({
dataType,
path: '/config/'
});

View File

@ -0,0 +1,24 @@
import { CustomThemeSetting, Setting } from '../../types/api';
import { createMutation, createQuery } from '../apiRequests';
export interface CustomThemeSettingsResponseType {
custom_theme_settings: CustomThemeSetting[];
}
const dataType = 'CustomThemeSettingsResponseType';
export const useBrowseCustomThemeSettings = createQuery<CustomThemeSettingsResponseType>({
dataType,
path: '/custom_theme_settings/'
});
export const useEditCustomThemeSettings = createMutation<CustomThemeSettingsResponseType, Setting[]>({
method: 'PUT',
path: () => '/custom_theme_settings/',
body: settings => ({custom_theme_settings: settings}),
updateQueries: {
dataType,
update: newData => newData
}
});

View File

@ -0,0 +1,21 @@
import {createMutation} from '../apiRequests';
export interface ImagesResponseType {
images: {
url: string;
ref: string | null;
}[];
}
export const useUploadImage = createMutation<ImagesResponseType, {file: File}>({
method: 'POST',
path: () => '/images/upload/',
body: ({file}) => {
const formData = new FormData();
formData.append('file', file);
formData.append('purpose', 'image');
return formData;
}
});
export const getImageUrl = (response: ImagesResponseType) => response.images[0].url;

View File

@ -0,0 +1,61 @@
import { Meta, createMutation, createQuery } from '../apiRequests';
export interface UserInvite {
created_at: string;
email: string;
expires: number;
id: string;
role_id: string;
role?: string;
status: string;
updated_at: string;
}
export interface InvitesResponseType {
meta?: Meta;
invites: UserInvite[];
}
const dataType = 'InvitesResponseType';
export const useBrowseInvites = createQuery<InvitesResponseType>({
dataType,
path: '/invites/'
});
export const useAddInvite = createMutation<InvitesResponseType, {email: string, roleId: string}>({
method: 'POST',
path: () => '/invites/',
body: ({email, roleId}) => ({
invites: [{
email: email,
role_id: roleId,
expires: null,
status: null,
token: null
}]
}),
updateQueries: {
dataType,
// Assume that all invite queries should include this new one
update: (newData, currentData) => ({
...(currentData as InvitesResponseType),
invites: [
...((currentData as InvitesResponseType).invites),
...newData.invites
]
})
}
});
export const useDeleteInvite = createMutation<unknown, string>({
path: id => `/invites/${id}/`,
method: 'DELETE',
updateQueries: {
dataType,
update: (_, currentData, id) => ({
...(currentData as InvitesResponseType),
invites: (currentData as InvitesResponseType).invites.filter(invite => invite.id !== id)
})
}
});

View File

@ -0,0 +1,15 @@
import {Label} from '../../types/api';
import {Meta, createQuery} from '../apiRequests';
export interface LabelsResponseType {
meta?: Meta
labels: Label[]
}
const dataType = 'LabelsResponseType';
export const useBrowseLabels = createQuery<LabelsResponseType>({
dataType,
path: '/labels/',
defaultSearchParams: {limit: 'all'}
});

View File

@ -0,0 +1,15 @@
import {Meta, createQuery} from '../apiRequests';
import {Offer} from '../../types/api';
export interface OffersResponseType {
meta?: Meta
offers: Offer[]
}
const dataType = 'OffersResponseType';
export const useBrowseOffers = createQuery<OffersResponseType>({
dataType,
path: '/offers/',
defaultSearchParams: {limit: 'all'}
});

View File

@ -0,0 +1,14 @@
import {Meta, createQuery} from '../apiRequests';
import {Post} from '../../types/api';
export interface PostsResponseType {
meta?: Meta
posts: Post[];
}
const dataType = 'PostsResponseType';
export const useBrowsePosts = createQuery<PostsResponseType>({
dataType,
path: '/posts/'
});

View File

@ -0,0 +1,15 @@
import {Meta, createQuery} from '../apiRequests';
import {UserRole} from '../../types/api';
export interface RolesResponseType {
meta?: Meta;
roles: UserRole[];
}
const dataType = 'RolesResponseType';
export const useBrowseRoles = createQuery<RolesResponseType>({
dataType,
path: '/roles/',
defaultSearchParams: {limit: 'all'}
});

View File

@ -0,0 +1,29 @@
import {Meta, createMutation, createQuery} from '../apiRequests';
import {Setting} from '../../types/api';
export type SettingsResponseMeta = Meta & { sent_email_verification?: boolean }
export interface SettingsResponseType {
meta?: SettingsResponseMeta;
settings: Setting[];
}
const dataType = 'SettingsResponseType';
export const useBrowseSettings = createQuery<SettingsResponseType>({
dataType,
path: '/settings/',
defaultSearchParams: {
group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura'
}
});
export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
method: 'PUT',
path: () => '/settings/',
body: settings => ({settings}),
updateQueries: {
dataType,
update: newData => newData
}
});

View File

@ -0,0 +1,13 @@
import {SiteData} from '../../types/api';
import {createQuery} from '../apiRequests';
export interface SiteResponseType {
site: SiteData;
}
const dataType = 'SiteResponseType';
export const useBrowseSite = createQuery<SiteResponseType>({
dataType,
path: '/site/'
});

View File

@ -0,0 +1,87 @@
import { InstalledTheme, Theme } from '../../types/api';
import { createMutation, createQuery } from '../apiRequests';
export interface ThemesResponseType {
themes: Theme[];
}
export interface ThemesInstallResponseType {
themes: InstalledTheme[];
}
const dataType = 'ThemesResponseType';
export const useBrowseThemes = createQuery<ThemesResponseType>({
dataType,
path: '/themes/'
});
export const useActivateTheme = createMutation<ThemesResponseType, string>({
method: 'PUT',
path: name => `/themes/${name}/activate/`,
updateQueries: {
dataType,
update: (newData: ThemesResponseType, currentData: unknown) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.map((theme) => {
const newTheme = newData.themes.find(({name}) => name === theme.name);
if (newTheme) {
return newTheme;
} else {
return {...theme, active: false};
}
})
})
}
});
export const useDeleteTheme = createMutation<unknown, string>({
method: 'DELETE',
path: name => `/themes/${name}/`,
updateQueries: {
dataType,
update: (_, currentData, name) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.filter(theme => theme.name !== name)
})
}
});
export const useInstallTheme = createMutation<ThemesInstallResponseType, string>({
method: 'POST',
path: () => '/themes/install/',
searchParams: repo => ({source: 'github', ref: repo}),
updateQueries: {
dataType,
// Assume that all invite queries should include this new one
update: (newData, currentData) => ({
...(currentData as ThemesResponseType),
themes: [
...((currentData as ThemesResponseType).themes),
...newData.themes
]
})
}
});
export const useUploadTheme = createMutation<ThemesInstallResponseType, {file: File}>({
method: 'POST',
path: () => '/themes/upload/',
body: ({file}) => {
const formData = new FormData();
formData.append('file', file);
return formData;
},
updateQueries: {
dataType,
// Assume that all invite queries should include this new one
update: (newData, currentData) => ({
...(currentData as ThemesResponseType),
themes: [
...((currentData as ThemesResponseType).themes),
...newData.themes
]
})
}
});

View File

@ -0,0 +1,41 @@
import { Meta, createMutation, createQuery } from '../apiRequests';
import { Tier } from '../../types/api';
export interface TiersResponseType {
meta?: Meta
tiers: Tier[]
}
const dataType = 'TiersResponseType';
export const useBrowseTiers = createQuery<TiersResponseType>({
dataType,
path: '/tiers/',
defaultSearchParams: {
limit: 'all'
}
});
export const useAddTier = createMutation<TiersResponseType, Partial<Tier>>({
method: 'POST',
path: () => '/tiers/',
body: tier => ({tiers: [tier]}),
// We may have queries for paid/archived/etc, so we can't assume how to update the global store and need to reload queries from the server
invalidateQueries: {dataType}
});
export const useEditTier = createMutation<TiersResponseType, Tier>({
method: 'PUT',
path: tier => `/tiers/${tier.id}/`,
body: tier => ({tiers: [tier]}),
updateQueries: {
dataType,
update: (newData, currentData) => ({
...(currentData as TiersResponseType),
tiers: (currentData as TiersResponseType).tiers.map((tier) => {
const newTier = newData.tiers.find(({id}) => id === tier.id);
return newTier || tier;
})
})
}
});

View File

@ -0,0 +1,97 @@
import { Meta, createMutation, createQuery } from '../apiRequests';
import { User } from '../../types/api';
export interface UsersResponseType {
meta?: Meta;
users: User[];
}
interface UpdatePasswordOptions {
newPassword: string;
confirmNewPassword: string;
userId: string;
oldPassword?: string;
}
export interface PasswordUpdateResponseType {
password: [{
message: string;
}];
}
export interface DeleteUserResponse {
meta: {
filename: string;
}
}
const dataType = 'UsersResponseType';
const updateUsers = (newData: UsersResponseType, currentData: unknown) => ({
...(currentData as UsersResponseType),
users: (currentData as UsersResponseType).users.map((user) => {
const newUser = newData.users.find(({id}) => id === user.id);
return newUser || user;
})
});
export const useBrowseUsers = createQuery<UsersResponseType>({
dataType,
path: '/users/',
defaultSearchParams: {limit: 'all', include: 'roles'}
});
export const useCurrentUser = createQuery<User>({
dataType,
path: '/users/me/',
returnData: (originalData) => (originalData as UsersResponseType).users?.[0]
});
export const useEditUser = createMutation<UsersResponseType, User>({
method: 'PUT',
path: user => `/users/${user.id}/`,
body: user => ({users: [user]}),
updateQueries: {
dataType,
update: updateUsers
}
});
export const useDeleteUser = createMutation<DeleteUserResponse, string>({
method: 'DELETE',
path: id => `/users/${id}/`,
updateQueries: {
dataType,
update: (_, currentData, id) => ({
...(currentData as UsersResponseType),
users: (currentData as UsersResponseType).users.filter(user => user.id !== id)
})
}
});
export const useUpdatePassword = createMutation<PasswordUpdateResponseType, UpdatePasswordOptions>({
method: 'PUT',
path: () => '/users/password/',
body: ({newPassword, confirmNewPassword, userId, oldPassword}) => ({
password: [{
user_id: userId,
oldPassword: oldPassword || '',
newPassword: newPassword,
ne2Password: confirmNewPassword
}]
})
});
export const useMakeOwner = createMutation<UsersResponseType, string>({
method: 'PUT',
path: () => '/users/owner/',
body: userId => ({
owner: [{
id: userId
}]
}),
updateQueries: {
dataType,
update: updateUsers
}
});

View File

@ -0,0 +1,140 @@
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getGhostPaths } from './helpers';
import { useServices } from '../components/providers/ServiceProvider';
export interface Meta {
pagination: {
page: number;
limit: number;
pages: number;
total: number;
next: number;
prev: number;
}
}
interface RequestOptions {
method?: string;
body?: string | FormData;
headers?: {
'Content-Type'?: string;
};
}
export const useFetchApi = () => {
const {ghostVersion} = useServices();
return async (endpoint: string | URL, options: RequestOptions = {}) => {
// By default, we set the Content-Type header to application/json
const defaultHeaders = {
'app-pragma': 'no-cache',
'x-ghost-version': ghostVersion
};
const headers = options?.headers || {
'Content-Type': 'application/json'
};
const response = await fetch(endpoint, {
headers: {
...defaultHeaders,
...headers
},
method: 'GET',
mode: 'cors',
credentials: 'include',
...options
});
if (response.status === 204) {
return;
} else {
return await response.json();
}
};
};
const {apiRoot} = getGhostPaths();
const apiUrl = (path: string, searchParams: { [key: string]: string } = {}) => {
const url = new URL(`${apiRoot}${path}`, window.location.origin);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
};
const parameterizedPath = (path: string, params: string | string[]) => {
const paramList = Array.isArray(params) ? params : [params];
return paramList.reduce((updatedPath, param) => updatedPath.replace(/:[a-z0-9]+/, encodeURIComponent(param)), path);
};
interface QueryOptions<ResponseData> {
dataType: string
path: string
defaultSearchParams?: { [key: string]: string };
returnData?: (originalData: unknown) => ResponseData;
}
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => (searchParams?: { [key: string]: string }) => {
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
const fetchApi = useFetchApi();
const result = useQuery<ResponseData>({
queryKey: [options.dataType, url],
queryFn: () => fetchApi(url)
});
return {
...result,
data: (result.data && options.returnData) ? options.returnData(result.data) : result.data
};
};
export const createQueryWithId = <ResponseData>(options: QueryOptions<ResponseData>) => (id: string, searchParams?: { [key: string]: string }) => {
const queryHook = createQuery<ResponseData>({...options, path: parameterizedPath(options.path, id)});
return queryHook(searchParams || options.defaultSearchParams);
};
interface MutationOptions<ResponseData, Payload> extends Omit<QueryOptions<ResponseData>, 'dataType' | 'path'>, Omit<RequestOptions, 'body'> {
path: (payload: Payload) => string;
body?: (payload: Payload) => FormData | object;
searchParams?: (payload: Payload) => { [key: string]: string; };
invalidateQueries?: { dataType: string; };
updateQueries?: { dataType: string; update: <CurrentData>(newData: ResponseData, currentData: CurrentData, payload: Payload) => unknown };
}
const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, options}: {
fetchApi: ReturnType<typeof useFetchApi>;
path: string;
payload?: Payload;
searchParams?: { [key: string]: string };
options: MutationOptions<ResponseData, Payload>
}) => {
const {defaultSearchParams, body, ...requestOptions} = options;
const url = apiUrl(path, searchParams || defaultSearchParams);
console.log('api url', path, searchParams, url)
const generatedBody = payload && body?.(payload);
const requestBody = (generatedBody && generatedBody instanceof FormData) ? generatedBody : JSON.stringify(generatedBody)
return fetchApi(url, {
body: requestBody,
...requestOptions
});
};
const afterMutate = <ResponseData, Payload>(newData: ResponseData, payload: Payload, queryClient: QueryClient, options: MutationOptions<ResponseData, Payload>) => {
if (options.invalidateQueries) {
queryClient.invalidateQueries([options.invalidateQueries.dataType]);
}
if (options.updateQueries) {
queryClient.setQueriesData([options.updateQueries.dataType], (data: unknown) => options.updateQueries!.update(newData, data, payload));
}
};
export const createMutation = <ResponseData, Payload>(options: MutationOptions<ResponseData, Payload>) => () => {
const fetchApi = useFetchApi();
const queryClient = useQueryClient();
return useMutation<ResponseData, unknown, Payload>({
mutationFn: payload => mutate({fetchApi, path: options.path(payload), payload, searchParams: options.searchParams?.(payload) || options.defaultSearchParams, options}),
onSuccess: (newData, payload) => afterMutate(newData, payload, queryClient, options)
});
};

View File

@ -1,64 +0,0 @@
import {useEffect, useState} from 'react';
export interface DataService<Data> {
data: Data[];
update: (...data: Data[]) => Promise<void>;
create: (data: Partial<Data>) => Promise<void>;
}
// eslint-disable-next-line no-unused-vars
type BulkEditFunction<Data, DataKey extends string> = (newData: Data[]) => Promise<{ [k in DataKey]: Data[] }>
// eslint-disable-next-line no-unused-vars
type AddFunction<Data, DataKey extends string> = (newData: Partial<Data>) => Promise<{ [k in DataKey]: Data[] }>
const useDataService = <Data extends { id: string }, DataKey extends string>({key, browse, edit, add}: {
key: DataKey
// eslint-disable-next-line no-unused-vars
browse: () => Promise<{ [k in DataKey]: Data[] }>
// eslint-disable-next-line no-unused-vars
edit: BulkEditFunction<Data, DataKey>
add: AddFunction<Data, DataKey>
}): DataService<Data> => {
const [data, setData] = useState<Data[]>([]);
useEffect(() => {
browse().then((response) => {
setData(response[key]);
});
}, [browse, key]);
const update = async (...newData: Data[]) => {
const response = await edit(newData);
setData(data.map((item) => {
const replacement = response[key].find(newItem => newItem.id === item.id);
return replacement || item;
}));
};
const create = async (newData: Partial<Data>) => {
const response = await add(newData);
setData([...data, response[key][0]]);
};
return {data, update, create};
};
export default useDataService;
// Utility for APIs which edit one object at a time
export const bulkEdit = <Data extends { id: string }, DataKey extends string>(
key: DataKey,
// eslint-disable-next-line no-unused-vars
updateOne: (data: Data) => Promise<{ [k in DataKey]: Data[] }>
): BulkEditFunction<Data, DataKey> => {
return async (newData: Data[]) => {
const response = await Promise.all(newData.map(updateOne));
return {
[key]: response.reduce((all, current) => all.concat(current[key]), [] as Data[])
// eslint-disable-next-line no-unused-vars
} as { [k in DataKey]: Data[] };
};
};
export const placeholderDataService = {data: [], update: async () => {}, create: async () => {}};

View File

@ -1,30 +1,9 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '../../utils/e2e';
import { expect, test } from '@playwright/test';
import { mockApi, responseFixtures } from '../../utils/e2e';
test.describe('Tier settings', async () => {
test('Supports creating a new tier', async ({page}) => {
const lastApiRequests = await mockApi({page, responses: {
tiers: {
add: {
tiers: [{
id: 'new-tier',
type: 'paid',
active: true,
name: 'Plus tier',
slug: 'plus-tier',
description: null,
monthly_price: 800,
yearly_price: 8000,
benefits: [],
welcome_page_url: null,
trial_days: 0,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}]
}
}
}});
await mockApi({page});
await page.goto('/');
@ -44,6 +23,31 @@ test.describe('Tier settings', async () => {
await modal.getByLabel('Monthly price').fill('8');
await modal.getByLabel('Yearly price').fill('80');
const newTier = {
id: 'new-tier',
type: 'paid',
active: true,
name: 'Plus tier',
slug: 'plus-tier',
description: null,
monthly_price: 800,
yearly_price: 8000,
benefits: [],
welcome_page_url: null,
trial_days: 0,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
const lastApiRequests = await mockApi({page, responses: {
tiers: {
add: { tiers: [newTier] },
// This request will be reloaded after the new tier is added
browse: { tiers: [...responseFixtures.tiers.tiers, newTier] }
}
}});
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);

View File

@ -1,5 +1,5 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '../../utils/e2e';
import { expect, test } from '@playwright/test';
import { mockApi, responseFixtures } from '../../utils/e2e';
test.describe('Theme settings', async () => {
test('Browsing and installing default themes', async ({page}) => {
@ -36,7 +36,7 @@ test.describe('Theme settings', async () => {
const modal = page.getByTestId('theme-modal');
// // The default theme is always considered "installed"
// The default theme is always considered "installed"
await modal.getByRole('button', {name: /Casper/}).click();

View File

@ -1,6 +1,18 @@
import {ConfigResponseType, CustomThemeSettingsResponseType, ImagesResponseType, InvitesResponseType, LabelsResponseType, OffersResponseType, PostsResponseType, RolesResponseType, SettingsResponseType, SiteResponseType, ThemesResponseType, TiersResponseType, UsersResponseType} from '../../src/utils/api';
import {Page, Request} from '@playwright/test';
import {readFileSync} from 'fs';
import { ConfigResponseType } from '../../src/utils/api/config'
import { CustomThemeSettingsResponseType } from '../../src/utils/api/customThemeSettings'
import { ImagesResponseType } from '../../src/utils/api/images'
import { InvitesResponseType } from '../../src/utils/api/invites'
import { LabelsResponseType } from '../../src/utils/api/labels'
import { OffersResponseType } from '../../src/utils/api/offers'
import { Page, Request } from '@playwright/test'
import { PostsResponseType } from '../../src/utils/api/posts'
import { RolesResponseType } from '../../src/utils/api/roles'
import { SettingsResponseType } from '../../src/utils/api/settings'
import { SiteResponseType } from '../../src/utils/api/site'
import { ThemesResponseType } from '../../src/utils/api/themes'
import { TiersResponseType } from '../../src/utils/api/tiers'
import { UsersResponseType } from '../../src/utils/api/users'
import { readFileSync } from 'fs'
export const responseFixtures = {
settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()) as SettingsResponseType,
@ -80,7 +92,7 @@ interface Responses {
interface RequestRecord {
url?: string
body?: any
body?: object | null
headers?: {[key: string]: string}
}
@ -472,7 +484,7 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
interface ResponseOptions {
condition?: (request: Request) => boolean
body: any
body: object | string
status?: number
updateLastRequest: RequestRecord
}
@ -492,7 +504,7 @@ async function mockApiResponse({page, path, respondTo}: { page: Page; path: stri
await route.fulfill({
status: response.status || 200,
body: JSON.stringify(response.body)
body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body)
});
});
}

View File

@ -5818,6 +5818,19 @@
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
"@tanstack/query-core@4.29.25":
version "4.29.25"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.29.25.tgz#605d357968a740544af6754004eed1dfd4587cb8"
integrity sha512-DI4y4VC6Uw4wlTpOocEXDky69xeOScME1ezLKsj+hOk7DguC9fkqXtp6Hn39BVb9y0b5IBrY67q6kIX623Zj4Q==
"@tanstack/react-query@4.29.25":
version "4.29.25"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.29.25.tgz#64df3260b65760fbd3c81ffae23b7b3802c71aa6"
integrity sha512-c1+Ezu+XboYrdAMdusK2fTdRqXPMgPAnyoTrzHOZQqr8Hqz6PNvV9DSKl8agUo6nXX4np7fdWabIprt+838dLg==
dependencies:
"@tanstack/query-core" "4.29.25"
use-sync-external-store "^1.2.0"
"@testing-library/dom@^8.0.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6"
@ -28951,6 +28964,11 @@ use-resize-observer@^9.1.0:
dependencies:
"@juggle/resize-observer" "^3.3.1"
use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"