mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 11:22:19 +03:00
Replace custom data loading with react-query (#17537)
refs https://github.com/TryGhost/Product/issues/3349
This commit is contained in:
parent
841e52ccfe
commit
55d243f470
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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']
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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'}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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>)]
|
||||
};
|
||||
|
@ -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()}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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'
|
||||
|
@ -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];
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
|
@ -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 />
|
||||
|
@ -1,21 +1,81 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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};
|
@ -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,34 +116,34 @@ 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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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};
|
||||
|
@ -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};
|
@ -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'})) || []
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>[] = [
|
||||
{
|
||||
|
@ -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)} />)
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -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!));
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 = () => {
|
||||
|
@ -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) {
|
||||
|
@ -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've used <strong className="text-green">{signupTermsLength}</strong></>}
|
||||
nodes='MINIMAL_NODES'
|
||||
|
@ -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[];
|
||||
|
@ -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={() => {
|
||||
|
@ -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 => (
|
||||
|
@ -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>"{uploadedTheme.name}"</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>"{uploadedTheme.name}"</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}
|
||||
/>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
|
@ -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> => {
|
@ -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
|
||||
};
|
||||
}
|
@ -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;
|
@ -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);
|
||||
|
||||
|
96
apps/admin-x-settings/src/hooks/useSettings.tsx
Normal file
96
apps/admin-x-settings/src/hooks/useSettings.tsx
Normal 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;
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
};
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
13
apps/admin-x-settings/src/utils/api/config.ts
Normal file
13
apps/admin-x-settings/src/utils/api/config.ts
Normal 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/'
|
||||
});
|
24
apps/admin-x-settings/src/utils/api/customThemeSettings.ts
Normal file
24
apps/admin-x-settings/src/utils/api/customThemeSettings.ts
Normal 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
|
||||
}
|
||||
});
|
21
apps/admin-x-settings/src/utils/api/images.ts
Normal file
21
apps/admin-x-settings/src/utils/api/images.ts
Normal 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;
|
61
apps/admin-x-settings/src/utils/api/invites.ts
Normal file
61
apps/admin-x-settings/src/utils/api/invites.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
});
|
15
apps/admin-x-settings/src/utils/api/labels.ts
Normal file
15
apps/admin-x-settings/src/utils/api/labels.ts
Normal 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'}
|
||||
});
|
15
apps/admin-x-settings/src/utils/api/offers.ts
Normal file
15
apps/admin-x-settings/src/utils/api/offers.ts
Normal 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'}
|
||||
});
|
14
apps/admin-x-settings/src/utils/api/posts.ts
Normal file
14
apps/admin-x-settings/src/utils/api/posts.ts
Normal 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/'
|
||||
});
|
15
apps/admin-x-settings/src/utils/api/roles.ts
Normal file
15
apps/admin-x-settings/src/utils/api/roles.ts
Normal 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'}
|
||||
});
|
29
apps/admin-x-settings/src/utils/api/settings.ts
Normal file
29
apps/admin-x-settings/src/utils/api/settings.ts
Normal 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
|
||||
}
|
||||
});
|
13
apps/admin-x-settings/src/utils/api/site.ts
Normal file
13
apps/admin-x-settings/src/utils/api/site.ts
Normal 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/'
|
||||
});
|
87
apps/admin-x-settings/src/utils/api/themes.ts
Normal file
87
apps/admin-x-settings/src/utils/api/themes.ts
Normal 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
|
||||
]
|
||||
})
|
||||
}
|
||||
});
|
41
apps/admin-x-settings/src/utils/api/tiers.ts
Normal file
41
apps/admin-x-settings/src/utils/api/tiers.ts
Normal 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;
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
97
apps/admin-x-settings/src/utils/api/users.ts
Normal file
97
apps/admin-x-settings/src/utils/api/users.ts
Normal 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
|
||||
}
|
||||
});
|
140
apps/admin-x-settings/src/utils/apiRequests.ts
Normal file
140
apps/admin-x-settings/src/utils/apiRequests.ts
Normal 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)
|
||||
});
|
||||
};
|
@ -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 () => {}};
|
@ -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/);
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
18
yarn.lock
18
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user