Merge branch 'main' into main
This commit is contained in:
commit
f59813db48
@ -2,6 +2,8 @@ import React from 'react';
|
||||
|
||||
import '../src/styles/demo.css';
|
||||
import type { Preview } from "@storybook/react";
|
||||
import '../src/admin-x-ds/providers/DesignSystemProvider';
|
||||
import DesignSystemProvider from '../src/admin-x-ds/providers/DesignSystemProvider';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@ -29,7 +31,9 @@ const preview: Preview = {
|
||||
background: (scheme === 'dark' ? '#131416' : '')
|
||||
}}>
|
||||
{/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
|
||||
<Story />
|
||||
<DesignSystemProvider>
|
||||
<Story />
|
||||
</DesignSystemProvider>
|
||||
</div>);
|
||||
},
|
||||
],
|
||||
|
@ -43,6 +43,7 @@
|
||||
"@dnd-kit/core": "6.0.8",
|
||||
"@dnd-kit/sortable": "7.0.2",
|
||||
"@ebay/nice-modal-react": "1.2.10",
|
||||
"@sentry/react": "7.70.0",
|
||||
"@tanstack/react-query": "4.35.3",
|
||||
"@tryghost/color-utils": "0.1.24",
|
||||
"@tryghost/limit-service": "^1.2.10",
|
||||
@ -55,7 +56,7 @@
|
||||
"validator": "7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.38.0",
|
||||
"@playwright/test": "1.38.1",
|
||||
"@storybook/addon-essentials": "7.4.0",
|
||||
"@storybook/addon-interactions": "7.4.0",
|
||||
"@storybook/addon-links": "7.4.0",
|
||||
|
@ -1,9 +1,11 @@
|
||||
import DesignSystemProvider from './admin-x-ds/providers/DesignSystemProvider';
|
||||
import GlobalDataProvider from './components/providers/GlobalDataProvider';
|
||||
import MainContent from './MainContent';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import RoutingProvider, {ExternalLink} from './components/providers/RoutingProvider';
|
||||
import clsx from 'clsx';
|
||||
import {DefaultHeaderTypes} from './utils/unsplash/UnsplashTypes';
|
||||
import {ErrorBoundary} from '@sentry/react';
|
||||
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
|
||||
import {OfficialTheme, ServicesProvider} from './components/providers/ServiceProvider';
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||
@ -15,9 +17,12 @@ interface AppProps {
|
||||
officialThemes: OfficialTheme[];
|
||||
zapierTemplates: ZapierTemplate[];
|
||||
externalNavigate: (link: ExternalLink) => void;
|
||||
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||
darkMode?: boolean;
|
||||
unsplashConfig: DefaultHeaderTypes
|
||||
sentryDSN: string | null;
|
||||
onUpdate: (dataType: string, response: unknown) => void;
|
||||
onInvalidate: (dataType: string) => void;
|
||||
onDelete: (dataType: string, id: string) => void;
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@ -30,33 +35,45 @@ const queryClient = new QueryClient({
|
||||
}
|
||||
});
|
||||
|
||||
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, toggleFeatureFlag, darkMode = false, unsplashConfig}: AppProps) {
|
||||
function SentryErrorBoundary({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, darkMode = false, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}: AppProps) {
|
||||
const appClassName = clsx(
|
||||
'admin-x-settings h-[100vh] w-full overflow-y-auto overflow-x-hidden',
|
||||
darkMode && 'dark'
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} toggleFeatureFlag={toggleFeatureFlag} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates}>
|
||||
<GlobalDataProvider>
|
||||
<RoutingProvider externalNavigate={externalNavigate}>
|
||||
<GlobalDirtyStateProvider>
|
||||
<div className={appClassName} id="admin-x-root" style={{
|
||||
height: '100vh',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<Toaster />
|
||||
<NiceModal.Provider>
|
||||
<MainContent />
|
||||
</NiceModal.Provider>
|
||||
</div>
|
||||
</GlobalDirtyStateProvider>
|
||||
</RoutingProvider>
|
||||
</GlobalDataProvider>
|
||||
</ServicesProvider>
|
||||
</QueryClientProvider>
|
||||
<SentryErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} sentryDSN={sentryDSN} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates} onDelete={onDelete} onInvalidate={onInvalidate} onUpdate={onUpdate}>
|
||||
<GlobalDataProvider>
|
||||
<RoutingProvider externalNavigate={externalNavigate}>
|
||||
<GlobalDirtyStateProvider>
|
||||
<DesignSystemProvider>
|
||||
<div className={appClassName} id="admin-x-root" style={{
|
||||
height: '100vh',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
<Toaster />
|
||||
<NiceModal.Provider>
|
||||
<MainContent />
|
||||
</NiceModal.Provider>
|
||||
</div>
|
||||
</DesignSystemProvider>
|
||||
</GlobalDirtyStateProvider>
|
||||
</RoutingProvider>
|
||||
</GlobalDataProvider>
|
||||
</ServicesProvider>
|
||||
</QueryClientProvider>
|
||||
</SentryErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import Banner from './Banner';
|
||||
import React, {ComponentType, ErrorInfo, ReactNode} from 'react';
|
||||
|
||||
@ -17,7 +18,10 @@ class ErrorBoundary extends React.Component<{children: ReactNode, name: ReactNod
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||
// TODO: Log to Sentry
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('adminX settings component-', info.componentStack);
|
||||
Sentry.captureException(error);
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
// eslint-disable-next-line no-console
|
||||
|
@ -41,11 +41,11 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({size, color,
|
||||
|
||||
switch (color) {
|
||||
case 'light':
|
||||
styles += ' border-white/20 before:bg-white ';
|
||||
styles += ' border-white/20 before:bg-white dark:border-black/10 dark:before:bg-black ';
|
||||
break;
|
||||
case 'dark':
|
||||
default:
|
||||
styles += ' border-black/10 before:bg-black ';
|
||||
styles += ' border-black/10 before:bg-black dark:border-white/20 dark:before:bg-white ';
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import clsx from 'clsx';
|
||||
export type Tab<ID = string> = {
|
||||
id: ID;
|
||||
title: string;
|
||||
counter?: number | null;
|
||||
|
||||
/**
|
||||
* Optional, so you can just use the tabs to other views
|
||||
@ -51,21 +52,26 @@ function TabView<ID extends string = string>({
|
||||
<section>
|
||||
<div className={containerClasses} role='tablist'>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
aria-selected={selectedTab === tab.id}
|
||||
className={clsx(
|
||||
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
|
||||
border && 'border-b-[3px]',
|
||||
selectedTab === tab.id && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
|
||||
selectedTab === tab.id && 'font-bold'
|
||||
)}
|
||||
id={tab.id}
|
||||
role='tab'
|
||||
title={tab.title}
|
||||
type="button"
|
||||
onClick={handleTabChange}
|
||||
>{tab.title}</button>
|
||||
<div>
|
||||
<button
|
||||
key={tab.id}
|
||||
aria-selected={selectedTab === tab.id}
|
||||
className={clsx(
|
||||
'-m-b-px cursor-pointer appearance-none whitespace-nowrap py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] dark:text-white',
|
||||
border && 'border-b-[3px]',
|
||||
selectedTab === tab.id && border ? 'border-black dark:border-white' : 'border-transparent hover:border-grey-500',
|
||||
selectedTab === tab.id && 'font-bold'
|
||||
)}
|
||||
id={tab.id}
|
||||
role='tab'
|
||||
title={tab.title}
|
||||
type="button"
|
||||
onClick={handleTabChange}
|
||||
>
|
||||
{tab.title}
|
||||
{(typeof tab.counter === 'number') && <span className='ml-1.5 rounded-full bg-grey-200 px-1.5 py-[2px] text-xs font-normal text-grey-800 dark:bg-grey-900 dark:text-grey-300'>{tab.counter}</span>}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{tabs.map((tab) => {
|
||||
|
@ -8,6 +8,11 @@ import clsx from 'clsx';
|
||||
import {LoadingIndicator} from './LoadingIndicator';
|
||||
import {PaginationData} from '../../hooks/usePagination';
|
||||
|
||||
export interface ShowMoreData {
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
interface TableProps {
|
||||
/**
|
||||
* If the table is the primary content on a page (e.g. Members table) then you can set a pagetitle to be consistent
|
||||
@ -21,6 +26,7 @@ interface TableProps {
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
pagination?: PaginationData;
|
||||
showMore?: ShowMoreData;
|
||||
}
|
||||
|
||||
const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
|
||||
@ -31,7 +37,19 @@ const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
|
||||
return <Pagination {...pagination}/>;
|
||||
};
|
||||
|
||||
const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, isLoading}) => {
|
||||
const OptionalShowMore = ({showMore}: {showMore?: ShowMoreData}) => {
|
||||
if (!showMore || !showMore.hasMore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mt-1 flex items-center gap-2 text-xs text-green`}>
|
||||
<button type='button' onClick={showMore.loadMore}>Show all</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSeparator, pageTitle, className, pagination, showMore, isLoading}) => {
|
||||
const tableClasses = clsx(
|
||||
(borderTop || pageTitle) && 'border-t border-grey-300',
|
||||
'w-full',
|
||||
@ -109,12 +127,13 @@ const Table: React.FC<TableProps> = ({header, children, borderTop, hint, hintSep
|
||||
|
||||
{isLoading && <LoadingIndicator delay={200} size='lg' style={loadingStyle} />}
|
||||
|
||||
{(hint || pagination) &&
|
||||
{(hint || pagination || showMore) &&
|
||||
<div className="-mt-px">
|
||||
{(hintSeparator || pagination) && <Separator />}
|
||||
<div className="flex flex-col-reverse items-start justify-between gap-1 pt-2 md:flex-row md:items-center md:gap-0 md:pt-0">
|
||||
<Hint>{hint ?? ' '}</Hint>
|
||||
<OptionalPagination pagination={pagination} />
|
||||
<OptionalShowMore showMore={showMore} />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
@ -28,7 +28,7 @@ const TableRow: React.FC<TableRowProps> = ({id, action, hideActions, className,
|
||||
'group/table-row',
|
||||
bgOnHover && 'hover:bg-gradient-to-r hover:from-white hover:to-grey-50 dark:hover:from-black dark:hover:to-grey-950',
|
||||
onClick && 'cursor-pointer',
|
||||
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-900 dark:hover:border-grey-800' : 'border-y border-none first-of-type:hover:border-t-transparent',
|
||||
separator ? 'border-b border-grey-100 last-of-type:border-b-transparent hover:border-grey-200 dark:border-grey-950 dark:hover:border-grey-900' : 'border-y border-none first-of-type:hover:border-t-transparent',
|
||||
className
|
||||
);
|
||||
|
||||
|
@ -45,7 +45,7 @@ const Toast: React.FC<ToastProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames} data-testid='toast'>
|
||||
<div className={classNames} data-testid={`toast-${props?.type}`}>
|
||||
<div className='flex items-start gap-3'>
|
||||
{props?.icon && (typeof props.icon === 'string' ?
|
||||
<div className='mt-0.5'><Icon className='grow' colorClass={props.type === 'success' ? 'text-green' : 'text-white'} name={props.icon} size='sm' /></div> : props.icon)}
|
||||
|
@ -5,6 +5,7 @@ import React, {forwardRef, useEffect, useId, useRef, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import {Extension} from '@codemirror/state';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
export interface CodeEditorProps extends Omit<ReactCodeMirrorProps, 'value' | 'onChange' | 'extensions'> {
|
||||
title?: string;
|
||||
@ -43,6 +44,15 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
|
||||
const sizeRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(100);
|
||||
const [resolvedExtensions, setResolvedExtensions] = React.useState<Extension[] | null>(null);
|
||||
const {setFocusState} = useFocusContext();
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocusState(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setFocusState(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all(extensions).then(setResolvedExtensions);
|
||||
@ -76,7 +86,9 @@ const CodeEditorView = forwardRef<ReactCodeMirrorRef, CodeEditorProps>(function
|
||||
height={height === 'full' ? '100%' : height}
|
||||
theme={theme}
|
||||
value={value}
|
||||
onBlur={handleBlur}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
{...props}
|
||||
/>
|
||||
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
export interface HtmlEditorProps {
|
||||
value?: string
|
||||
@ -90,22 +92,30 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
|
||||
nodes
|
||||
}) => {
|
||||
const onError = useCallback((error: unknown) => {
|
||||
// ensure we're still showing errors in development
|
||||
try {
|
||||
Sentry.captureException({
|
||||
error,
|
||||
tags: {lexical: true},
|
||||
contexts: {
|
||||
koenig: {
|
||||
version: window['@tryghost/koenig-lexical']?.version
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// if this fails, Sentry is probably not initialized
|
||||
console.error(e); // eslint-disable-line
|
||||
}
|
||||
console.error(error); // eslint-disable-line
|
||||
|
||||
// Pass down Sentry from Ember?
|
||||
// if (this.config.sentry_dsn) {
|
||||
// Sentry.captureException(error, {
|
||||
// tags: {lexical: true},
|
||||
// contexts: {
|
||||
// koenig: {
|
||||
// version: window['@tryghost/koenig-lexical']?.version
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// don't rethrow, Lexical will attempt to gracefully recover
|
||||
}, []);
|
||||
const {setFocusState} = useFocusContext();
|
||||
|
||||
const handleBlur = () => {
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
setFocusState(false);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
|
||||
@ -153,7 +163,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
|
||||
placeholderClassName='koenig-lexical-editor-input-placeholder'
|
||||
placeholderText={placeholder}
|
||||
singleParagraph={true}
|
||||
onBlur={onBlur}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<koenig.HtmlOutputPlugin html={value} setHtml={handleSetHtml} />
|
||||
</koenig.KoenigComposableEditor>
|
||||
@ -173,9 +183,13 @@ const HtmlEditor: React.FC<HtmlEditorProps & {
|
||||
editorUrl: config.editor.url,
|
||||
editorVersion: config.editor.version
|
||||
}), [config.editor.url, config.editor.version]);
|
||||
|
||||
const {setFocusState} = useFocusContext();
|
||||
// this is not ideal, we need to add a focus plugin inside the Koenig editor package to handle this properly
|
||||
const handleFocus = () => {
|
||||
setFocusState(true);
|
||||
};
|
||||
return <div className={className || 'w-full'}>
|
||||
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit">
|
||||
<div className="koenig-react-editor w-full [&_*]:!font-inherit [&_*]:!text-inherit" onFocus={handleFocus}>
|
||||
<ErrorHandler>
|
||||
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||
<KoenigWrapper {...props} editor={editorResource} />
|
||||
|
@ -1,9 +1,11 @@
|
||||
import AsyncCreatableSelect from 'react-select/async-creatable';
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import CreatableSelect from 'react-select/creatable';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import React, {useId, useMemo} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, default as ReactSelect, components} from 'react-select';
|
||||
import {DropdownIndicatorProps, GroupBase, MultiValue, OptionProps, OptionsOrGroups, Props, default as ReactSelect, components} from 'react-select';
|
||||
|
||||
export type MultiSelectColor = 'grey' | 'black' | 'green' | 'pink';
|
||||
type FieldStyles = 'text' | 'dropdown';
|
||||
@ -14,9 +16,22 @@ export type MultiSelectOption = {
|
||||
color?: MultiSelectColor;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>) => void) => void
|
||||
|
||||
type MultiSelectOptionProps = {
|
||||
async: true;
|
||||
defaultOptions: boolean | OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>;
|
||||
loadOptions: LoadOptions;
|
||||
options?: never;
|
||||
} | {
|
||||
async?: false;
|
||||
options: OptionsOrGroups<MultiSelectOption, GroupBase<MultiSelectOption>>;
|
||||
values: MultiSelectOption[];
|
||||
defaultOptions?: never;
|
||||
loadOptions?: never;
|
||||
}
|
||||
|
||||
type MultiSelectProps = MultiSelectOptionProps & {
|
||||
values: MultiValue<MultiSelectOption>;
|
||||
title?: string;
|
||||
clearBg?: boolean;
|
||||
error?: boolean;
|
||||
@ -70,7 +85,10 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
|
||||
size = 'md',
|
||||
fieldStyle = 'dropdown',
|
||||
hint = '',
|
||||
async,
|
||||
options,
|
||||
defaultOptions,
|
||||
loadOptions,
|
||||
values,
|
||||
onChange,
|
||||
canCreate = false,
|
||||
@ -110,60 +128,37 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
|
||||
return (ddiProps: DropdownIndicatorProps<MultiSelectOption, true>) => <DropdownIndicator {...ddiProps} clearBg={clearBg} fieldStyle={fieldStyle} />;
|
||||
}, [clearBg, fieldStyle]);
|
||||
|
||||
const commonOptions: Props<MultiSelectOption, true> = {
|
||||
classNames: {
|
||||
menuList: () => 'z-50',
|
||||
valueContainer: () => customClasses.valueContainer,
|
||||
control: () => customClasses.control,
|
||||
placeholder: () => customClasses.placeHolder,
|
||||
menu: () => customClasses.menu,
|
||||
option: () => customClasses.option,
|
||||
multiValue: ({data}) => customClasses.multiValue(data.color),
|
||||
noOptionsMessage: () => customClasses.noOptionsMessage,
|
||||
groupHeading: () => customClasses.groupHeading
|
||||
},
|
||||
closeMenuOnSelect: false,
|
||||
components: {DropdownIndicator: dropdownIndicatorComponent, Option},
|
||||
inputId: id,
|
||||
isClearable: false,
|
||||
placeholder: placeholder ? placeholder : '',
|
||||
value: values,
|
||||
isMulti: true,
|
||||
unstyled: true,
|
||||
onChange,
|
||||
...props
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{title && <Heading htmlFor={id} grey useLabelTag>{title}</Heading>}
|
||||
{
|
||||
canCreate ?
|
||||
<CreatableSelect
|
||||
classNames={{
|
||||
menuList: () => 'z-50',
|
||||
valueContainer: () => customClasses.valueContainer,
|
||||
control: () => customClasses.control,
|
||||
placeholder: () => customClasses.placeHolder,
|
||||
menu: () => customClasses.menu,
|
||||
option: () => customClasses.option,
|
||||
multiValue: ({data}) => customClasses.multiValue(data.color),
|
||||
noOptionsMessage: () => customClasses.noOptionsMessage,
|
||||
groupHeading: () => customClasses.groupHeading
|
||||
}}
|
||||
closeMenuOnSelect={false}
|
||||
components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
|
||||
inputId={id}
|
||||
isClearable={false}
|
||||
options={options}
|
||||
placeholder={placeholder ? placeholder : ''}
|
||||
value={values}
|
||||
isMulti
|
||||
unstyled
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
:
|
||||
<ReactSelect
|
||||
classNames={{
|
||||
menuList: () => 'z-50',
|
||||
valueContainer: () => customClasses.valueContainer,
|
||||
control: () => customClasses.control,
|
||||
placeholder: () => customClasses.placeHolder,
|
||||
menu: () => customClasses.menu,
|
||||
option: () => customClasses.option,
|
||||
multiValue: ({data}) => customClasses.multiValue(data.color),
|
||||
noOptionsMessage: () => customClasses.noOptionsMessage,
|
||||
groupHeading: () => customClasses.groupHeading
|
||||
}}
|
||||
closeMenuOnSelect={false}
|
||||
components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
|
||||
inputId={id}
|
||||
isClearable={false}
|
||||
options={options}
|
||||
placeholder={placeholder ? placeholder : ''}
|
||||
value={values}
|
||||
isMulti
|
||||
unstyled
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
async ?
|
||||
(canCreate ? <AsyncCreatableSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} /> : <AsyncSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} />) :
|
||||
(canCreate ? <CreatableSelect {...commonOptions} options={options} /> : <ReactSelect {...commonOptions} options={options} />)
|
||||
}
|
||||
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
</div>
|
||||
|
@ -78,7 +78,7 @@ export const WithSelectedOption: Story = {
|
||||
args: {
|
||||
title: 'Title',
|
||||
options: selectOptions,
|
||||
selectedOption: 'option-3',
|
||||
selectedOption: selectOptions.find(option => option.value === 'option-3'),
|
||||
hint: 'Here\'s some hint'
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,9 @@
|
||||
import React, {useId, useMemo} from 'react';
|
||||
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
|
||||
|
||||
import AsyncSelect from 'react-select/async';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import Icon from '../Icon';
|
||||
import React, {useId, useMemo} from 'react';
|
||||
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface SelectOption {
|
||||
@ -31,14 +31,28 @@ export interface SelectControlClasses {
|
||||
clearIndicator?: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Props<SelectOption, false> {
|
||||
export type LoadOptions = (inputValue: string, callback: (options: OptionsOrGroups<SelectOption, GroupBase<SelectOption>>) => void) => void
|
||||
|
||||
type SelectOptionProps = {
|
||||
async: true;
|
||||
defaultOptions: boolean | OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;
|
||||
loadOptions: LoadOptions;
|
||||
options?: never;
|
||||
} | {
|
||||
async?: false;
|
||||
options: OptionsOrGroups<SelectOption, GroupBase<SelectOption>>;
|
||||
defaultOptions?: never;
|
||||
loadOptions?: never;
|
||||
}
|
||||
|
||||
export type SelectProps = Props<SelectOption, false> & SelectOptionProps & {
|
||||
async?: boolean;
|
||||
title?: string;
|
||||
hideTitle?: boolean;
|
||||
size?: 'xs' | 'md';
|
||||
prompt?: string;
|
||||
options: SelectOption[] | SelectOptionGroup[];
|
||||
selectedOption?: string
|
||||
onSelect: (value: string | undefined) => void;
|
||||
selectedOption?: SelectOption
|
||||
onSelect: (option: SelectOption | null) => void;
|
||||
error?:boolean;
|
||||
hint?: React.ReactNode;
|
||||
clearBg?: boolean;
|
||||
@ -71,6 +85,7 @@ const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...option
|
||||
);
|
||||
|
||||
const Select: React.FC<SelectProps> = ({
|
||||
async,
|
||||
title,
|
||||
hideTitle,
|
||||
size = 'md',
|
||||
@ -107,8 +122,8 @@ const Select: React.FC<SelectProps> = ({
|
||||
const customClasses = {
|
||||
control: clsx(
|
||||
controlClasses?.control,
|
||||
'min-h-[40px] w-full cursor-pointer appearance-none pr-4 outline-none dark:text-white',
|
||||
size === 'xs' ? 'py-0 text-xs' : 'py-2',
|
||||
'min-h-[40px] w-full cursor-pointer appearance-none outline-none dark:text-white',
|
||||
size === 'xs' ? 'py-0 pr-2 text-xs' : 'py-2 pr-4',
|
||||
border && 'border-b',
|
||||
!clearBg && 'bg-grey-75 px-[10px] dark:bg-grey-950',
|
||||
error ? 'border-red' : 'border-grey-500 hover:border-grey-700 dark:border-grey-800 dark:hover:border-grey-700',
|
||||
@ -133,39 +148,36 @@ const Select: React.FC<SelectProps> = ({
|
||||
};
|
||||
}, [clearBg]);
|
||||
|
||||
const individualOptions = options.flatMap((option) => {
|
||||
if ('options' in option) {
|
||||
return option.options;
|
||||
}
|
||||
return option;
|
||||
});
|
||||
const customProps = {
|
||||
classNames: {
|
||||
menuList: () => 'z-[300]',
|
||||
valueContainer: () => customClasses.valueContainer,
|
||||
control: () => customClasses.control,
|
||||
placeholder: () => customClasses.placeHolder,
|
||||
menu: () => customClasses.menu,
|
||||
option: () => customClasses.option,
|
||||
noOptionsMessage: () => customClasses.noOptionsMessage,
|
||||
groupHeading: () => customClasses.groupHeading,
|
||||
clearIndicator: () => customClasses.clearIndicator
|
||||
},
|
||||
components: {DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator},
|
||||
inputId: id,
|
||||
isClearable: false,
|
||||
options,
|
||||
placeholder: prompt ? prompt : '',
|
||||
value: selectedOption,
|
||||
unstyled: true,
|
||||
onChange: onSelect
|
||||
};
|
||||
|
||||
const select = (
|
||||
<>
|
||||
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
<div className={containerClasses}>
|
||||
<ReactSelect<SelectOption, false>
|
||||
classNames={{
|
||||
menuList: () => 'z-[300]',
|
||||
valueContainer: () => customClasses.valueContainer,
|
||||
control: () => customClasses.control,
|
||||
placeholder: () => customClasses.placeHolder,
|
||||
menu: () => customClasses.menu,
|
||||
option: () => customClasses.option,
|
||||
noOptionsMessage: () => customClasses.noOptionsMessage,
|
||||
groupHeading: () => customClasses.groupHeading,
|
||||
clearIndicator: () => customClasses.clearIndicator
|
||||
}}
|
||||
components={{DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator}}
|
||||
inputId={id}
|
||||
isClearable={false}
|
||||
options={options}
|
||||
placeholder={prompt ? prompt : ''}
|
||||
value={individualOptions.find(option => option.value === selectedOption)}
|
||||
unstyled
|
||||
onChange={option => onSelect(option?.value)}
|
||||
{...props}
|
||||
/>
|
||||
{async ?
|
||||
<AsyncSelect<SelectOption, false> {...customProps} {...props} /> :
|
||||
<ReactSelect<SelectOption, false> {...customProps} {...props} />
|
||||
}
|
||||
</div>
|
||||
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
|
||||
</>
|
||||
|
@ -3,6 +3,7 @@ import React, {useId} from 'react';
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
type ResizeOptions = 'both' | 'vertical' | 'horizontal' | 'none';
|
||||
type FontStyles = 'sans' | 'mono';
|
||||
@ -40,6 +41,15 @@ const TextArea: React.FC<TextAreaProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const id = useId();
|
||||
const {setFocusState} = useFocusContext();
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocusState(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setFocusState(false);
|
||||
};
|
||||
|
||||
let styles = clsx(
|
||||
'peer order-2 rounded-sm border px-3 py-2 dark:text-white',
|
||||
@ -78,7 +88,9 @@ const TextArea: React.FC<TextAreaProps> = ({
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value}
|
||||
onBlur={handleBlur}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
{...props}>
|
||||
</textarea>
|
||||
{title && <Heading className={'order-1 !text-grey-700 peer-focus:!text-black dark:!text-grey-300 dark:peer-focus:!text-white'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
|
||||
|
@ -10,7 +10,11 @@ const meta = {
|
||||
title: 'Global / Form / Textfield',
|
||||
component: TextField,
|
||||
tags: ['autodocs'],
|
||||
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||
decorators: [(_story: () => ReactNode) => (
|
||||
<div style={{maxWidth: '400px'}}>
|
||||
{_story()}
|
||||
</div>
|
||||
)],
|
||||
argTypes: {
|
||||
hint: {
|
||||
control: 'text'
|
||||
|
@ -2,6 +2,7 @@ import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
import React, {useId} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
export type TextFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
@ -50,6 +51,18 @@ const TextField: React.FC<TextFieldProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const id = useId();
|
||||
const {setFocusState} = useFocusContext();
|
||||
|
||||
const handleFocus = () => {
|
||||
setFocusState(true);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (onBlur) {
|
||||
onBlur(e);
|
||||
}
|
||||
setFocusState(false);
|
||||
};
|
||||
|
||||
const disabledBorderClasses = border && 'border-grey-300 dark:border-grey-900';
|
||||
const enabledBorderClasses = border && 'border-grey-500 hover:border-grey-700 focus:border-black dark:border-grey-800 dark:hover:border-grey-700 dark:focus:border-grey-500';
|
||||
@ -77,8 +90,9 @@ const TextField: React.FC<TextFieldProps> = ({
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
onBlur={handleBlur}
|
||||
onChange={onChange}
|
||||
onFocus={handleFocus}
|
||||
{...props} />;
|
||||
|
||||
if (rightPlaceholder) {
|
||||
@ -120,7 +134,7 @@ const TextField: React.FC<TextFieldProps> = ({
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return field;
|
||||
return (field);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import TextField, {TextFieldProps} from './TextField';
|
||||
import validator from 'validator';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
const formatUrl = (value: string, baseUrl?: string) => {
|
||||
let url = value.trim();
|
||||
@ -99,19 +100,32 @@ const formatUrl = (value: string, baseUrl?: string) => {
|
||||
*/
|
||||
const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
|
||||
baseUrl?: string;
|
||||
transformPathWithoutSlash?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}> = ({baseUrl, value, onChange, ...props}) => {
|
||||
}> = ({baseUrl, value, transformPathWithoutSlash, onChange, ...props}) => {
|
||||
const [displayedUrl, setDisplayedUrl] = useState('');
|
||||
const {setFocusState} = useFocusContext();
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedUrl(formatUrl(value || '', baseUrl).display);
|
||||
}, [value, baseUrl]);
|
||||
|
||||
const updateUrl = () => {
|
||||
const {save, display} = formatUrl(displayedUrl, baseUrl);
|
||||
let urls = formatUrl(displayedUrl, baseUrl);
|
||||
|
||||
setDisplayedUrl(display);
|
||||
onChange(save);
|
||||
// If the user entered something like "bla", try to parse it as a relative URL
|
||||
// If parsing as "/bla" results in a valid URL, use that instead
|
||||
if (transformPathWithoutSlash && !urls.display.includes('//')) {
|
||||
const candidate = formatUrl('/' + displayedUrl, baseUrl);
|
||||
|
||||
if (candidate.display.includes('//')) {
|
||||
urls = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
setDisplayedUrl(urls.display);
|
||||
onChange(urls.save);
|
||||
setFocusState(false);
|
||||
};
|
||||
|
||||
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
|
||||
@ -121,6 +135,7 @@ const URLTextField: React.FC<Omit<TextFieldProps, 'onChange'> & {
|
||||
}
|
||||
|
||||
props.onFocus?.(e);
|
||||
setFocusState(true);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
|
@ -108,7 +108,11 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
|
||||
let toolbarLeft = (<></>);
|
||||
if (previewToolbarURLs) {
|
||||
toolbarLeft = (
|
||||
<Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={url => url && onSelectURL?.(url)} />
|
||||
<Select
|
||||
options={previewToolbarURLs!}
|
||||
selectedOption={previewToolbarURLs!.find(option => option.value === selectedURL)}
|
||||
onSelect={option => option && onSelectURL?.(option.value)}
|
||||
/>
|
||||
);
|
||||
} else if (previewToolbarTabs) {
|
||||
toolbarLeft = <TabView
|
||||
|
@ -0,0 +1,37 @@
|
||||
// FocusContext.tsx
|
||||
import React, {createContext, useContext, useState} from 'react';
|
||||
|
||||
interface DesignSystemContextType {
|
||||
isAnyTextFieldFocused: boolean;
|
||||
setFocusState: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const DesignSystemContext = createContext<DesignSystemContextType | undefined>(undefined);
|
||||
|
||||
export const useFocusContext = () => {
|
||||
const context = useContext(DesignSystemContext);
|
||||
if (!context) {
|
||||
throw new Error('useFocusContext must be used within a FocusProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface DesignSystemProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const DesignSystemProvider: React.FC<DesignSystemProviderProps> = ({children}) => {
|
||||
const [isAnyTextFieldFocused, setIsAnyTextFieldFocused] = useState(false);
|
||||
|
||||
const setFocusState = (value: boolean) => {
|
||||
setIsAnyTextFieldFocused(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<DesignSystemContext.Provider value={{isAnyTextFieldFocused, setFocusState}}>
|
||||
{children}
|
||||
</DesignSystemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignSystemProvider;
|
@ -1,7 +1,7 @@
|
||||
import {ExternalLink, InternalLink} from '../components/providers/RoutingProvider';
|
||||
import {InfiniteData} from '@tanstack/react-query';
|
||||
import {JSONObject} from './config';
|
||||
import {Meta, createInfiniteQuery} from '../utils/apiRequests';
|
||||
import {Meta, createInfiniteQuery} from '../utils/api/hooks';
|
||||
|
||||
// Types
|
||||
|
||||
@ -74,10 +74,12 @@ export const useBrowseActions = createInfiniteQuery<ActionsResponseType>({
|
||||
}
|
||||
});
|
||||
|
||||
const meta = pages.at(-1)!.meta;
|
||||
|
||||
return {
|
||||
actions: actions.reverse(),
|
||||
meta: pages.at(-1)!.meta,
|
||||
isEnd: pages.at(-1)!.actions.length < pages.at(-1)!.meta.pagination.limit
|
||||
meta,
|
||||
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {IntegrationsResponseType, integrationsDataType} from './integrations';
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
|
||||
// Types
|
||||
|
||||
@ -23,6 +23,7 @@ export const useRefreshAPIKey = createMutation<IntegrationsResponseType, {integr
|
||||
path: ({integrationId, apiKeyId}) => `/integrations/${integrationId}/api_key/${apiKeyId}/refresh/`,
|
||||
body: ({integrationId}) => ({integrations: [{id: integrationId}]}),
|
||||
updateQueries: {
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
dataType: integrationsDataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as IntegrationsResponseType),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createQuery} from '../utils/apiRequests';
|
||||
import {createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type JSONValue = string|number|boolean|null|Date|JSONObject|JSONArray;
|
||||
export interface JSONObject { [key: string]: JSONValue }
|
||||
@ -40,6 +40,10 @@ export type Config = {
|
||||
billing?: {
|
||||
enabled?: boolean
|
||||
url?: string
|
||||
},
|
||||
pintura?: {
|
||||
js?: string
|
||||
css?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Setting} from './settings';
|
||||
import {createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {createMutation, createQuery} from '../utils/api/hooks';
|
||||
|
||||
type CustomThemeSettingData =
|
||||
{ type: 'text', value: string | null, default: string | null } |
|
||||
@ -40,6 +40,7 @@ export const useEditCustomThemeSettings = createMutation<CustomThemeSettingsResp
|
||||
body: settings => ({custom_theme_settings: settings}),
|
||||
|
||||
updateQueries: {
|
||||
emberUpdateType: 'skip',
|
||||
dataType,
|
||||
update: newData => newData
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
import {downloadFromEndpoint} from '../utils/helpers';
|
||||
|
||||
export const useImportContent = createMutation<unknown, File>({
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createMutation} from '../utils/apiRequests';
|
||||
import {Meta, createMutation} from '../utils/api/hooks';
|
||||
|
||||
export type emailVerification = {
|
||||
token: string;
|
||||
@ -16,6 +16,7 @@ export const verifyEmailToken = createMutation<EmailVerificationResponseType, em
|
||||
body: ({token}) => ({token}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: newData => ({
|
||||
...newData,
|
||||
settings: newData.settings
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {useFetchApi} from '../utils/apiRequests';
|
||||
import {useFetchApi} from '../utils/api/hooks';
|
||||
|
||||
export type GhostSiteResponse = {
|
||||
site: {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
|
||||
export interface FilesResponseType {
|
||||
files: {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
|
||||
export interface ImagesResponseType {
|
||||
images: {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {APIKey} from './apiKeys';
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createMutation, createQuery} from '../utils/api/hooks';
|
||||
import {Webhook} from './webhooks';
|
||||
|
||||
// Types
|
||||
@ -41,6 +41,7 @@ export const useCreateIntegration = createMutation<IntegrationsResponseType, Par
|
||||
searchParams: () => ({include: 'api_keys,webhooks'}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.concat(newData.integrations)
|
||||
@ -55,6 +56,7 @@ export const useEditIntegration = createMutation<IntegrationsResponseType, Integ
|
||||
searchParams: () => ({include: 'api_keys,webhooks'}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
|
||||
@ -70,6 +72,7 @@ export const useDeleteIntegration = createMutation<unknown, string>({
|
||||
path: id => `/integrations/${id}/`,
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'delete',
|
||||
update: (_, currentData, id) => ({
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.filter(user => user.id !== id)
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createMutation, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export interface UserInvite {
|
||||
created_at: string;
|
||||
@ -37,6 +37,7 @@ export const useAddInvite = createMutation<InvitesResponseType, {email: string,
|
||||
}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
// Assume that all invite queries should include this new one
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as InvitesResponseType),
|
||||
@ -53,6 +54,7 @@ export const useDeleteInvite = createMutation<unknown, string>({
|
||||
method: 'DELETE',
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'delete',
|
||||
update: (_, currentData, id) => ({
|
||||
...(currentData as InvitesResponseType),
|
||||
invites: (currentData as InvitesResponseType).invites.filter(invite => invite.id !== id)
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type Label = {
|
||||
id: string;
|
||||
@ -17,6 +17,5 @@ const dataType = 'LabelsResponseType';
|
||||
|
||||
export const useBrowseLabels = createQuery<LabelsResponseType>({
|
||||
dataType,
|
||||
path: '/labels/',
|
||||
defaultSearchParams: {limit: 'all'}
|
||||
path: '/labels/'
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type Member = {
|
||||
id: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createPaginatedQuery} from '../utils/apiRequests';
|
||||
import {Meta, createPaginatedQuery} from '../utils/api/hooks';
|
||||
|
||||
export type Mention = {
|
||||
id: string;
|
||||
|
@ -1,4 +1,6 @@
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {InfiniteData} from '@tanstack/react-query';
|
||||
import {Meta, createInfiniteQuery, createMutation} from '../utils/api/hooks';
|
||||
import {insertToQueryCache, updateQueryCache} from '../utils/api/updateQueries';
|
||||
|
||||
export type Newsletter = {
|
||||
id: string;
|
||||
@ -46,10 +48,25 @@ export interface NewslettersResponseType {
|
||||
|
||||
const dataType = 'NewslettersResponseType';
|
||||
|
||||
export const useBrowseNewsletters = createQuery<NewslettersResponseType>({
|
||||
export const useBrowseNewsletters = createInfiniteQuery<NewslettersResponseType & {isEnd: boolean}>({
|
||||
dataType,
|
||||
path: '/newsletters/',
|
||||
defaultSearchParams: {include: 'count.active_members,count.posts', limit: 'all'}
|
||||
defaultSearchParams: {include: 'count.active_members,count.posts', limit: '50'},
|
||||
defaultNextPageParams: (lastPage, otherParams) => ({
|
||||
...otherParams,
|
||||
page: (lastPage.meta?.pagination.next || 1).toString()
|
||||
}),
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<NewslettersResponseType>;
|
||||
const newsletters = pages.flatMap(page => page.newsletters);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
|
||||
return {
|
||||
newsletters: newsletters,
|
||||
meta,
|
||||
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter> & {opt_in_existing: boolean}>({
|
||||
@ -60,10 +77,8 @@ export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<
|
||||
searchParams: payload => ({opt_in_existing: payload.opt_in_existing.toString(), include: 'count.active_members,count.posts'}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as NewslettersResponseType),
|
||||
newsletters: (currentData as NewslettersResponseType).newsletters.concat(newData.newsletters)
|
||||
})
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: insertToQueryCache('newsletters')
|
||||
}
|
||||
});
|
||||
|
||||
@ -78,12 +93,7 @@ export const useEditNewsletter = createMutation<NewslettersEditResponseType, New
|
||||
defaultSearchParams: {include: 'count.active_members,count.posts'},
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as NewslettersResponseType),
|
||||
newsletters: (currentData as NewslettersResponseType).newsletters.map((newsletter) => {
|
||||
const newNewsletter = newData.newsletters.find(({id}) => id === newsletter.id);
|
||||
return newNewsletter || newsletter;
|
||||
})
|
||||
})
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: updateQueryCache('newsletters')
|
||||
}
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {apiUrl, useFetchApi} from '../utils/apiRequests';
|
||||
import {apiUrl, useFetchApi} from '../utils/api/hooks';
|
||||
|
||||
export type OembedResponse = {
|
||||
metadata: {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type Offer = {
|
||||
id: string;
|
||||
@ -30,6 +30,5 @@ const dataType = 'OffersResponseType';
|
||||
|
||||
export const useBrowseOffers = createQuery<OffersResponseType>({
|
||||
dataType,
|
||||
path: '/offers/',
|
||||
defaultSearchParams: {limit: 'all'}
|
||||
path: '/offers/'
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type Post = {
|
||||
id: string;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import {Meta, apiUrl, createMutation, createPaginatedQuery, useFetchApi} from '../utils/apiRequests';
|
||||
import {InfiniteData} from '@tanstack/react-query';
|
||||
import {Meta, apiUrl, createInfiniteQuery, createMutation, useFetchApi} from '../utils/api/hooks';
|
||||
|
||||
export type Recommendation = {
|
||||
id: string
|
||||
@ -28,16 +29,29 @@ export interface RecommendationDeleteResponseType {}
|
||||
|
||||
const dataType = 'RecommendationResponseType';
|
||||
|
||||
export const useBrowseRecommendations = createPaginatedQuery<RecommendationResponseType>({
|
||||
export const useBrowseRecommendations = createInfiniteQuery<RecommendationResponseType>({
|
||||
dataType,
|
||||
path: '/recommendations/',
|
||||
defaultSearchParams: {}
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<RecommendationResponseType>;
|
||||
let recommendations = pages.flatMap(page => page.recommendations);
|
||||
|
||||
// Remove duplicates
|
||||
recommendations = recommendations.filter((recommendation, index) => {
|
||||
return recommendations.findIndex(({id}) => id === recommendation.id) === index;
|
||||
});
|
||||
|
||||
return {
|
||||
recommendations,
|
||||
meta: pages[pages.length - 1].meta
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const useDeleteRecommendation = createMutation<RecommendationDeleteResponseType, Recommendation>({
|
||||
method: 'DELETE',
|
||||
path: recommendation => `/recommendations/${recommendation.id}/`,
|
||||
// Clear all queries because pagination needs to be re-checked
|
||||
|
||||
invalidateQueries: {
|
||||
dataType
|
||||
}
|
||||
@ -47,15 +61,9 @@ export const useEditRecommendation = createMutation<RecommendationEditResponseTy
|
||||
method: 'PUT',
|
||||
path: recommendation => `/recommendations/${recommendation.id}/`,
|
||||
body: recommendation => ({recommendations: [recommendation]}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as RecommendationResponseType),
|
||||
recommendations: (currentData as RecommendationResponseType).recommendations.map((recommendation) => {
|
||||
const newRecommendation = newData.recommendations.find(({id}) => id === recommendation.id);
|
||||
return newRecommendation || recommendation;
|
||||
})
|
||||
})
|
||||
|
||||
invalidateQueries: {
|
||||
dataType
|
||||
}
|
||||
});
|
||||
|
||||
@ -64,7 +72,6 @@ export const useAddRecommendation = createMutation<RecommendationResponseType, P
|
||||
path: () => '/recommendations/',
|
||||
body: ({...recommendation}) => ({recommendations: [recommendation]}),
|
||||
|
||||
// Clear all queries because pagination needs to be re-checked
|
||||
invalidateQueries: {
|
||||
dataType
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
import {downloadFromEndpoint} from '../utils/helpers';
|
||||
|
||||
export const useUploadRedirects = createMutation<unknown, File>({
|
||||
|
19
apps/admin-x-settings/src/api/referrers.ts
Normal file
19
apps/admin-x-settings/src/api/referrers.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import {createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type ReferrerHistoryItem = {
|
||||
date: string,
|
||||
signups: number,
|
||||
source: string|null,
|
||||
paid_conversions: number
|
||||
};
|
||||
|
||||
export interface ReferrerHistoryResponseType {
|
||||
stats: ReferrerHistoryItem[];
|
||||
}
|
||||
|
||||
const dataType = 'ReferrerHistoryResponseType';
|
||||
|
||||
export const useReferrerHistory = createQuery<ReferrerHistoryResponseType>({
|
||||
dataType,
|
||||
path: '/stats/referrers/'
|
||||
});
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createQuery} from '../utils/api/hooks';
|
||||
|
||||
export type UserRoleType = 'Owner' | 'Administrator' | 'Editor' | 'Author' | 'Contributor';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
import {downloadFromEndpoint} from '../utils/helpers';
|
||||
|
||||
export const useUploadRoutes = createMutation<unknown, File>({
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Config} from './config';
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createMutation, createQuery} from '../utils/api/hooks';
|
||||
|
||||
// Types
|
||||
|
||||
@ -35,6 +35,7 @@ export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
|
||||
body: settings => ({settings: settings.map(({key, value}) => ({key, value}))}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: newData => ({
|
||||
...newData,
|
||||
settings: newData.settings
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createQuery} from '../utils/apiRequests';
|
||||
import {createQuery} from '../utils/api/hooks';
|
||||
|
||||
// Types
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation} from '../utils/apiRequests';
|
||||
import {createMutation} from '../utils/api/hooks';
|
||||
|
||||
export const useTestSlack = createMutation<unknown, null>({
|
||||
method: 'POST',
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Meta, createMutation, createPaginatedQuery} from '../utils/apiRequests';
|
||||
import {Meta, createMutation, createPaginatedQuery} from '../utils/api/hooks';
|
||||
|
||||
export type staffToken = {
|
||||
id: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {createMutation, createQuery} from '../utils/api/hooks';
|
||||
import {customThemeSettingsDataType} from './customThemeSettings';
|
||||
|
||||
// Types
|
||||
@ -54,6 +54,7 @@ export const useActivateTheme = createMutation<ThemesResponseType, string>({
|
||||
path: name => `/themes/${name}/activate/`,
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (newData: ThemesResponseType, currentData: unknown) => ({
|
||||
...(currentData as ThemesResponseType),
|
||||
themes: (currentData as ThemesResponseType).themes.map((theme) => {
|
||||
@ -77,6 +78,7 @@ export const useDeleteTheme = createMutation<unknown, string>({
|
||||
path: name => `/themes/${name}/`,
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'delete',
|
||||
update: (_, currentData, name) => ({
|
||||
...(currentData as ThemesResponseType),
|
||||
themes: (currentData as ThemesResponseType).themes.filter(theme => theme.name !== name)
|
||||
@ -90,6 +92,7 @@ export const useInstallTheme = createMutation<ThemesInstallResponseType, string>
|
||||
searchParams: repo => ({source: 'github', ref: repo}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
// Assume that all invite queries should include this new one
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as ThemesResponseType),
|
||||
@ -111,6 +114,7 @@ export const useUploadTheme = createMutation<ThemesInstallResponseType, {file: F
|
||||
},
|
||||
updateQueries: {
|
||||
dataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
// Assume that all invite queries should include this new one
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as ThemesResponseType),
|
||||
|
@ -1,4 +1,6 @@
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {InfiniteData} from '@tanstack/react-query';
|
||||
import {Meta, createInfiniteQuery, createMutation} from '../utils/api/hooks';
|
||||
import {updateQueryCache} from '../utils/api/updateQueries';
|
||||
|
||||
// Types
|
||||
|
||||
@ -29,11 +31,23 @@ export interface TiersResponseType {
|
||||
|
||||
const dataType = 'TiersResponseType';
|
||||
|
||||
export const useBrowseTiers = createQuery<TiersResponseType>({
|
||||
export const useBrowseTiers = createInfiniteQuery<TiersResponseType & {isEnd: boolean}>({
|
||||
dataType,
|
||||
path: '/tiers/',
|
||||
defaultSearchParams: {
|
||||
limit: 'all'
|
||||
defaultNextPageParams: (lastPage, otherParams) => ({
|
||||
...otherParams,
|
||||
page: (lastPage.meta?.pagination.next || 1).toString()
|
||||
}),
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<TiersResponseType>;
|
||||
const tiers = pages.flatMap(page => page.tiers);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
|
||||
return {
|
||||
tiers,
|
||||
meta,
|
||||
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@ -51,13 +65,8 @@ export const useEditTier = createMutation<TiersResponseType, Tier>({
|
||||
body: tier => ({tiers: [tier]}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as TiersResponseType),
|
||||
tiers: (currentData as TiersResponseType).tiers.map((tier) => {
|
||||
const newTier = newData.tiers.find(({id}) => id === tier.id);
|
||||
return newTier || tier;
|
||||
})
|
||||
})
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: updateQueryCache('tiers')
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {InfiniteData} from '@tanstack/react-query';
|
||||
import {Meta, createInfiniteQuery, createMutation, createQuery} from '../utils/api/hooks';
|
||||
import {UserRole} from './roles';
|
||||
import {deleteFromQueryCache, updateQueryCache} from '../utils/api/updateQueries';
|
||||
|
||||
// Types
|
||||
|
||||
@ -28,6 +30,7 @@ export type User = {
|
||||
paid_subscription_canceled_notification: boolean;
|
||||
paid_subscription_started_notification: boolean;
|
||||
mention_notifications: boolean;
|
||||
recommendation_notifications: boolean;
|
||||
milestone_notifications: boolean;
|
||||
roles: UserRole[];
|
||||
url: string;
|
||||
@ -61,18 +64,25 @@ export interface DeleteUserResponse {
|
||||
|
||||
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>({
|
||||
export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: boolean}>({
|
||||
dataType,
|
||||
path: '/users/',
|
||||
defaultSearchParams: {limit: 'all', include: 'roles'}
|
||||
defaultSearchParams: {limit: '100', include: 'roles'},
|
||||
defaultNextPageParams: (lastPage, otherParams) => ({
|
||||
...otherParams,
|
||||
page: (lastPage.meta?.pagination.next || 1).toString()
|
||||
}),
|
||||
returnData: (originalData) => {
|
||||
const {pages} = originalData as InfiniteData<UsersResponseType>;
|
||||
const users = pages.flatMap(page => page.users);
|
||||
const meta = pages.at(-1)!.meta;
|
||||
|
||||
return {
|
||||
users: users,
|
||||
meta,
|
||||
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const useCurrentUser = createQuery<User>({
|
||||
@ -89,7 +99,8 @@ export const useEditUser = createMutation<UsersResponseType, User>({
|
||||
searchParams: () => ({include: 'roles'}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: updateUsers
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: updateQueryCache('users')
|
||||
}
|
||||
});
|
||||
|
||||
@ -98,10 +109,8 @@ export const useDeleteUser = createMutation<DeleteUserResponse, string>({
|
||||
path: id => `/users/${id}/`,
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (_, currentData, id) => ({
|
||||
...(currentData as UsersResponseType),
|
||||
users: (currentData as UsersResponseType).users.filter(user => user.id !== id)
|
||||
})
|
||||
emberUpdateType: 'delete',
|
||||
update: deleteFromQueryCache('users')
|
||||
}
|
||||
});
|
||||
|
||||
@ -128,7 +137,8 @@ export const useMakeOwner = createMutation<UsersResponseType, string>({
|
||||
}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: updateUsers
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: updateQueryCache('users')
|
||||
}
|
||||
});
|
||||
|
||||
@ -154,6 +164,10 @@ export function isContributorUser(user: User) {
|
||||
return user.roles.some(role => role.name === 'Contributor');
|
||||
}
|
||||
|
||||
export function isAuthorOrContributor(user: User) {
|
||||
return isAuthorUser(user) || isContributorUser(user);
|
||||
}
|
||||
|
||||
export function canAccessSettings(user: User) {
|
||||
return isOwnerUser(user) || isAdminUser(user) || isEditorUser(user);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {IntegrationsResponseType, integrationsDataType} from './integrations';
|
||||
import {Meta, createMutation} from '../utils/apiRequests';
|
||||
import {Meta, createMutation} from '../utils/api/hooks';
|
||||
|
||||
// Types
|
||||
|
||||
@ -31,6 +31,7 @@ export const useCreateWebhook = createMutation<WebhooksResponseType, Partial<Web
|
||||
body: webhook => ({webhooks: [webhook]}),
|
||||
updateQueries: {
|
||||
dataType: integrationsDataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
|
||||
@ -52,6 +53,7 @@ export const useEditWebhook = createMutation<WebhooksResponseType, Webhook>({
|
||||
body: webhook => ({webhooks: [webhook]}),
|
||||
updateQueries: {
|
||||
dataType: integrationsDataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (newData, currentData) => (currentData && {
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
|
||||
@ -69,6 +71,7 @@ export const useDeleteWebhook = createMutation<unknown, string>({
|
||||
path: id => `/webhooks/${id}/`,
|
||||
updateQueries: {
|
||||
dataType: integrationsDataType,
|
||||
emberUpdateType: 'createOrUpdate',
|
||||
update: (_, currentData, id) => ({
|
||||
...(currentData as IntegrationsResponseType),
|
||||
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
|
||||
|
@ -12,6 +12,7 @@ import {searchKeywords as generalSearchKeywords} from './settings/general/Genera
|
||||
import {getSettingValues} from '../api/settings';
|
||||
import {searchKeywords as membershipSearchKeywords} from './settings/membership/MembershipSettings';
|
||||
import {searchKeywords as siteSearchKeywords} from './settings/site/SiteSettings';
|
||||
import {useFocusContext} from '../admin-x-ds/providers/DesignSystemProvider';
|
||||
import {useGlobalData} from './providers/GlobalDataProvider';
|
||||
import {useSearch} from './providers/ServiceProvider';
|
||||
|
||||
@ -19,11 +20,12 @@ const Sidebar: React.FC = () => {
|
||||
const {filter, setFilter} = useSearch();
|
||||
const {updateRoute} = useRouting();
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const {isAnyTextFieldFocused} = useFocusContext();
|
||||
|
||||
// Focus in on search field when pressing CMD+K/CTRL+K
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.key === '/') {
|
||||
if (e.key === '/' && !isAnyTextFieldFocused) {
|
||||
e?.preventDefault();
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
@ -34,7 +36,7 @@ const Sidebar: React.FC = () => {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, []);
|
||||
});
|
||||
|
||||
// Auto-focus on searchfield on page load
|
||||
useEffect(() => {
|
||||
@ -73,7 +75,7 @@ const Sidebar: React.FC = () => {
|
||||
</div>
|
||||
<div className="no-scrollbar hidden pt-10 tablet:!visible tablet:!block tablet:h-[calc(100vh-5vmin-84px-64px)] tablet:w-[240px] tablet:overflow-y-auto" id='admin-x-settings-sidebar'>
|
||||
<SettingNavSection keywords={Object.values(generalSearchKeywords).flat()} title="General">
|
||||
<SettingNavItem keywords={generalSearchKeywords.titleAndDescription} navid='title-and-description' title="Title and description" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={generalSearchKeywords.titleAndDescription} navid='title-and-description' title="Title & description" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={generalSearchKeywords.timeZone} navid='timezone' title="Timezone" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={generalSearchKeywords.publicationLanguage} navid='publication-language' title="Publication language" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={generalSearchKeywords.metadata} navid='metadata' title="Meta data" onClick={handleSectionClick} />
|
||||
@ -85,7 +87,7 @@ const Sidebar: React.FC = () => {
|
||||
</SettingNavSection>
|
||||
|
||||
<SettingNavSection keywords={Object.values(siteSearchKeywords).flat()} title="Site">
|
||||
<SettingNavItem keywords={siteSearchKeywords.design} navid='design' title="Branding and design" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={siteSearchKeywords.design} navid='design' title="Design & branding" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={siteSearchKeywords.navigation} navid='navigation' title="Navigation" onClick={handleSectionClick} />
|
||||
<SettingNavItem keywords={siteSearchKeywords.announcementBar} navid='announcement-bar' title="Announcement bar" onClick={handleSectionClick} />
|
||||
</SettingNavSection>
|
||||
|
@ -18,7 +18,10 @@ interface ServicesContextProps {
|
||||
zapierTemplates: ZapierTemplate[];
|
||||
search: SearchService;
|
||||
unsplashConfig: DefaultHeaderTypes;
|
||||
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||
sentryDSN: string | null;
|
||||
onUpdate: (dataType: string, response: unknown) => void;
|
||||
onInvalidate: (dataType: string) => void;
|
||||
onDelete: (dataType: string, id: string) => void;
|
||||
}
|
||||
|
||||
interface ServicesProviderProps {
|
||||
@ -26,8 +29,11 @@ interface ServicesProviderProps {
|
||||
ghostVersion: string;
|
||||
zapierTemplates: ZapierTemplate[];
|
||||
officialThemes: OfficialTheme[];
|
||||
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||
unsplashConfig: DefaultHeaderTypes;
|
||||
sentryDSN: string | null;
|
||||
onUpdate: (dataType: string, response: unknown) => void;
|
||||
onInvalidate: (dataType: string) => void;
|
||||
onDelete: (dataType: string, id: string) => void;
|
||||
}
|
||||
|
||||
const ServicesContext = createContext<ServicesContextProps>({
|
||||
@ -35,17 +41,20 @@ const ServicesContext = createContext<ServicesContextProps>({
|
||||
officialThemes: [],
|
||||
zapierTemplates: [],
|
||||
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
|
||||
toggleFeatureFlag: () => {},
|
||||
unsplashConfig: {
|
||||
Authorization: '',
|
||||
'Accept-Version': '',
|
||||
'Content-Type': '',
|
||||
'App-Pragma': '',
|
||||
'X-Unsplash-Cache': true
|
||||
}
|
||||
},
|
||||
sentryDSN: null,
|
||||
onUpdate: () => {},
|
||||
onInvalidate: () => {},
|
||||
onDelete: () => {}
|
||||
});
|
||||
|
||||
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, toggleFeatureFlag, unsplashConfig}) => {
|
||||
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}) => {
|
||||
const search = useSearchService();
|
||||
|
||||
return (
|
||||
@ -55,7 +64,10 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
|
||||
zapierTemplates,
|
||||
search,
|
||||
unsplashConfig,
|
||||
toggleFeatureFlag
|
||||
sentryDSN,
|
||||
onUpdate,
|
||||
onInvalidate,
|
||||
onDelete
|
||||
}}>
|
||||
{children}
|
||||
</ServicesContext.Provider>
|
||||
@ -69,3 +81,5 @@ export const useServices = () => useContext(ServicesContext);
|
||||
export const useOfficialThemes = () => useServices().officialThemes;
|
||||
|
||||
export const useSearch = () => useServices().search;
|
||||
|
||||
export const useSentryDSN = () => useServices().sentryDSN;
|
||||
|
@ -11,10 +11,13 @@ import Popover from '../../../admin-x-ds/global/Popover';
|
||||
import Select, {SelectOption} from '../../../admin-x-ds/global/form/Select';
|
||||
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
||||
import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup';
|
||||
import useFilterableApi from '../../../hooks/useFilterableApi';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
||||
import {Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '../../../api/actions';
|
||||
import {LoadOptions} from '../../../admin-x-ds/global/form/MultiSelect';
|
||||
import {RoutingModalProps} from '../../providers/RoutingProvider';
|
||||
import {User} from '../../../api/users';
|
||||
import {debounce} from '../../../utils/debounce';
|
||||
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
|
||||
import {useCallback, useState} from 'react';
|
||||
|
||||
@ -73,15 +76,19 @@ const HistoryFilter: React.FC<{
|
||||
toggleResourceType: (resource: string, included: boolean) => void;
|
||||
}> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
const {users} = useStaffUsers();
|
||||
const usersApi = useFilterableApi<User, 'users', 'name'>({path: '/users/', filterKey: 'name', responseKey: 'users'});
|
||||
|
||||
const loadOptions: LoadOptions = async (input, callback) => {
|
||||
const users = await usersApi.loadData(input);
|
||||
callback(users.map(user => ({label: user.name, value: user.id})));
|
||||
};
|
||||
|
||||
const [searchedStaff, setSearchStaff] = useState<SelectOption | null>();
|
||||
|
||||
const resetStaff = () => {
|
||||
setSearchStaff(null);
|
||||
};
|
||||
|
||||
const userOptions = users.map(user => ({label: user.name, value: user.id}));
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-4'>
|
||||
<Popover position='right' trigger={<Button color='outline' label='Filter' size='sm' />}>
|
||||
@ -102,14 +109,16 @@ const HistoryFilter: React.FC<{
|
||||
</Popover>
|
||||
<div className='w-[200px]'>
|
||||
<Select
|
||||
options={userOptions}
|
||||
loadOptions={debounce(loadOptions, 500)}
|
||||
placeholder='Search staff'
|
||||
value={searchedStaff}
|
||||
async
|
||||
defaultOptions
|
||||
isClearable
|
||||
onSelect={(value) => {
|
||||
if (value) {
|
||||
setSearchStaff(userOptions.find(option => option.value === value)!);
|
||||
updateRoute(`history/view/${value}`);
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setSearchStaff(option);
|
||||
updateRoute(`history/view/${option.value}`);
|
||||
} else {
|
||||
resetStaff();
|
||||
updateRoute('history/view');
|
||||
|
@ -8,6 +8,7 @@ import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
|
||||
import React, {useState} from 'react';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import {ReactComponent as AmpIcon} from '../../../assets/icons/amp.svg';
|
||||
import {ReactComponent as FirstPromoterIcon} from '../../../assets/icons/firstpromoter.svg';
|
||||
@ -172,12 +173,16 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
|
||||
okColor: 'red',
|
||||
okLabel: 'Delete Integration',
|
||||
onOk: async (confirmModal) => {
|
||||
await deleteIntegration(integration.id);
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Integration deleted',
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
await deleteIntegration(integration.id);
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Integration deleted',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
|
@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
|
||||
import {RoutingModalProps} from '../../../providers/RoutingProvider';
|
||||
@ -40,9 +41,13 @@ const AddIntegrationModal: React.FC<RoutingModalProps> = () => {
|
||||
testId='add-integration-modal'
|
||||
title='Add integration'
|
||||
onOk={async () => {
|
||||
const data = await createIntegration({name});
|
||||
modal.remove();
|
||||
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
|
||||
try {
|
||||
const data = await createIntegration({name});
|
||||
modal.remove();
|
||||
updateRoute({route: `integrations/show/${data.integrations[0].id}`});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='mt-5'>
|
||||
|
@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/amp.svg';
|
||||
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
|
||||
@ -30,7 +31,11 @@ const AmpModal = NiceModal.create(() => {
|
||||
{key: 'amp', value: enabled},
|
||||
{key: 'amp_gtag_id', value: trackingId}
|
||||
];
|
||||
await editSettings(updates);
|
||||
try {
|
||||
await editSettings(updates);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -81,4 +86,4 @@ const AmpModal = NiceModal.create(() => {
|
||||
);
|
||||
});
|
||||
|
||||
export default AmpModal;
|
||||
export default AmpModal;
|
||||
|
@ -7,6 +7,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import WebhooksTable from './WebhooksTable';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {APIKey, useRefreshAPIKey} from '../../../../api/apiKeys';
|
||||
@ -30,6 +31,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
onSave: async () => {
|
||||
await editIntegration(formState);
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@ -64,9 +66,13 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
prompt: `You can regenerate ${name} API Key any time, but any scripts or applications using it will need to be updated.`,
|
||||
okLabel: `Regenerate ${name} API Key`,
|
||||
onOk: async (confirmModal) => {
|
||||
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
|
||||
setRegenerated(true);
|
||||
confirmModal?.remove();
|
||||
try {
|
||||
await refreshAPIKey({integrationId: integration.id, apiKeyId: apiKey.id});
|
||||
setRegenerated(true);
|
||||
confirmModal?.remove();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -104,8 +110,12 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
width='100px'
|
||||
onDelete={() => updateForm(state => ({...state, icon_image: null}))}
|
||||
onUpload={async (file) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateForm(state => ({...state, icon_image: imageUrl}));
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateForm(state => ({...state, icon_image: imageUrl}));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload icon
|
||||
|
@ -4,6 +4,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/firstpromoter.svg';
|
||||
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
|
||||
@ -53,9 +54,13 @@ const FirstpromoterModal = NiceModal.create(() => {
|
||||
testId='firstpromoter-modal'
|
||||
title=''
|
||||
onOk={async () => {
|
||||
await handleSave();
|
||||
updateRoute('integrations');
|
||||
modal.remove();
|
||||
try {
|
||||
await handleSave();
|
||||
updateRoute('integrations');
|
||||
modal.remove();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IntegrationHeader
|
||||
|
@ -4,6 +4,7 @@ import IntegrationHeader from './IntegrationHeader';
|
||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import pinturaScreenshot from '../../../../assets/images/pintura-screenshot.png';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/pintura.svg';
|
||||
@ -68,10 +69,7 @@ const PinturaModal = NiceModal.create(() => {
|
||||
});
|
||||
} catch (e) {
|
||||
setUploadingState({js: false, css: false});
|
||||
showToast({
|
||||
type: 'pageError',
|
||||
message: `Can't upload Pintura ${form}!`
|
||||
});
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -3,6 +3,7 @@ import IntegrationHeader from './IntegrationHeader';
|
||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
|
||||
import {Setting, getSettingValues, useEditSettings} from '../../../../api/settings';
|
||||
@ -19,7 +20,11 @@ const UnsplashModal = NiceModal.create(() => {
|
||||
const updates: Setting[] = [
|
||||
{key: 'unsplash', value: (e.target.checked)}
|
||||
];
|
||||
await editSettings(updates);
|
||||
try {
|
||||
await editSettings(updates);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import Select from '../../../../admin-x-ds/global/form/Select';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import toast from 'react-hot-toast';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import validator from 'validator';
|
||||
@ -30,6 +31,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
|
||||
await createWebhook({...formState, integration_id: integrationId});
|
||||
}
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@ -92,11 +94,11 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
|
||||
hint={errors.event}
|
||||
options={webhookEventOptions}
|
||||
prompt='Select an event'
|
||||
selectedOption={formState.event}
|
||||
selectedOption={webhookEventOptions.flatMap(group => group.options).find(option => option.value === formState.event)}
|
||||
title='Event'
|
||||
hideTitle
|
||||
onSelect={(event) => {
|
||||
updateForm(state => ({...state, event}));
|
||||
onSelect={(option) => {
|
||||
updateForm(state => ({...state, event: option?.value}));
|
||||
clearError('event');
|
||||
}}
|
||||
/>
|
||||
|
@ -6,6 +6,7 @@ import TableCell from '../../../../admin-x-ds/global/TableCell';
|
||||
import TableHead from '../../../../admin-x-ds/global/TableHead';
|
||||
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||
import WebhookModal from './WebhookModal';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import {Integration} from '../../../../api/integrations';
|
||||
import {getWebhookEventLabel} from './webhookEventOptions';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
@ -21,12 +22,16 @@ const WebhooksTable: React.FC<{integration: Integration}> = ({integration}) => {
|
||||
okColor: 'red',
|
||||
okLabel: 'Delete Webhook',
|
||||
onOk: async (confirmModal) => {
|
||||
await deleteWebhook(id);
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Webhook deleted',
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
await deleteWebhook(id);
|
||||
confirmModal?.remove();
|
||||
showToast({
|
||||
message: 'Webhook deleted',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import List from '../../../../admin-x-ds/global/List';
|
||||
import ListItem from '../../../../admin-x-ds/global/ListItem';
|
||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {ReactComponent as ArrowRightIcon} from '../../../../admin-x-ds/assets/icons/arrow-right.svg';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/zapier.svg';
|
||||
@ -57,9 +58,13 @@ const ZapierModal = NiceModal.create(() => {
|
||||
prompt: 'You will need to locate the Ghost App within your Zapier account and click on "Reconnect" to enter the new Admin API Key.',
|
||||
okLabel: 'Regenerate Admin API Key',
|
||||
onOk: async (confirmModal) => {
|
||||
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
|
||||
setRegenerated(true);
|
||||
confirmModal?.remove();
|
||||
try {
|
||||
await refreshAPIKey({integrationId: integration.id, apiKeyId: adminApiKey.id});
|
||||
setRegenerated(true);
|
||||
confirmModal?.remove();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ import FileUpload from '../../../../admin-x-ds/global/form/FileUpload';
|
||||
import LabItem from './LabItem';
|
||||
import List from '../../../../admin-x-ds/global/List';
|
||||
import React, {useState} from 'react';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
|
||||
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';
|
||||
@ -35,13 +36,18 @@ const BetaFeatures: React.FC = () => {
|
||||
<FileUpload
|
||||
id='upload-redirects'
|
||||
onUpload={async (file) => {
|
||||
setRedirectsUploading(true);
|
||||
await uploadRedirects(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Redirects uploaded successfully'
|
||||
});
|
||||
setRedirectsUploading(false);
|
||||
try {
|
||||
setRedirectsUploading(true);
|
||||
await uploadRedirects(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Redirects uploaded successfully'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setRedirectsUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button color='grey' label={redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} size='sm' tag='div' />
|
||||
@ -55,13 +61,18 @@ const BetaFeatures: React.FC = () => {
|
||||
<FileUpload
|
||||
id='upload-routes'
|
||||
onUpload={async (file) => {
|
||||
setRoutesUploading(true);
|
||||
await uploadRoutes(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Routes uploaded successfully'
|
||||
});
|
||||
setRoutesUploading(false);
|
||||
try {
|
||||
setRoutesUploading(true);
|
||||
await uploadRoutes(file);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Routes uploaded successfully'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setRoutesUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button color='grey' label={routesUploading ? 'Uploading ...' : 'Upload routes file'} size='sm' tag='div' />
|
||||
|
@ -1,34 +1,36 @@
|
||||
import React from 'react';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import {ConfigResponseType, configDataType} from '../../../../api/config';
|
||||
import {getSettingValue, useEditSettings} from '../../../../api/settings';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
import {useServices} from '../../../providers/ServiceProvider';
|
||||
|
||||
const FeatureToggle: React.FC<{ flag: string; }> = ({flag}) => {
|
||||
const {settings} = useGlobalData();
|
||||
const labs = JSON.parse(getSettingValue<string>(settings, 'labs') || '{}');
|
||||
const {mutateAsync: editSettings} = useEditSettings();
|
||||
const client = useQueryClient();
|
||||
const {toggleFeatureFlag} = useServices();
|
||||
|
||||
return <Toggle checked={labs[flag]} onChange={async () => {
|
||||
const newValue = !labs[flag];
|
||||
await editSettings([{
|
||||
key: 'labs',
|
||||
value: JSON.stringify({...labs, [flag]: newValue})
|
||||
}]);
|
||||
toggleFeatureFlag(flag, newValue);
|
||||
client.setQueriesData([configDataType], current => ({
|
||||
config: {
|
||||
...(current as ConfigResponseType).config,
|
||||
labs: {
|
||||
...(current as ConfigResponseType).config.labs,
|
||||
[flag]: newValue
|
||||
try {
|
||||
await editSettings([{
|
||||
key: 'labs',
|
||||
value: JSON.stringify({...labs, [flag]: newValue})
|
||||
}]);
|
||||
client.setQueriesData([configDataType], current => ({
|
||||
config: {
|
||||
...(current as ConfigResponseType).config,
|
||||
labs: {
|
||||
...(current as ConfigResponseType).config.labs,
|
||||
[flag]: newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}} />;
|
||||
};
|
||||
|
||||
|
@ -5,6 +5,7 @@ import LabItem from './LabItem';
|
||||
import List from '../../../../admin-x-ds/global/List';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useState} from 'react';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
import {useQueryClient} from '@tanstack/react-query';
|
||||
@ -18,16 +19,22 @@ const ImportModalContent = () => {
|
||||
id="import-file"
|
||||
onUpload={async (file) => {
|
||||
setUploading(true);
|
||||
await importContent(file);
|
||||
modal.remove();
|
||||
NiceModal.show(ConfirmationModal, {
|
||||
title: 'Import in progress',
|
||||
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
|
||||
cancelLabel: '',
|
||||
okLabel: 'Got it',
|
||||
onOk: confirmModal => confirmModal?.remove(),
|
||||
formSheet: false
|
||||
});
|
||||
try {
|
||||
await importContent(file);
|
||||
modal.remove();
|
||||
NiceModal.show(ConfirmationModal, {
|
||||
title: 'Import in progress',
|
||||
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
|
||||
cancelLabel: '',
|
||||
okLabel: 'Got it',
|
||||
onOk: confirmModal => confirmModal?.remove(),
|
||||
formSheet: false
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="cursor-pointer bg-grey-75 p-10 text-center dark:bg-grey-950">
|
||||
@ -56,12 +63,16 @@ const MigrationOptions: React.FC = () => {
|
||||
okColor: 'red',
|
||||
okLabel: 'Delete',
|
||||
onOk: async () => {
|
||||
await deleteAllContent(null);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'All content deleted from database.'
|
||||
});
|
||||
await client.refetchQueries();
|
||||
try {
|
||||
await deleteAllContent(null);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'All content deleted from database.'
|
||||
});
|
||||
await client.refetchQueries();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -3,13 +3,11 @@ 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 useDefaultRecipientsOptions from './useDefaultRecipientsOptions';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {GroupBase, MultiValue} from 'react-select';
|
||||
import {MultiValue} from 'react-select';
|
||||
import {getOptionLabel} from '../../../utils/helpers';
|
||||
import {getSettingValues} from '../../../api/settings';
|
||||
import {useBrowseLabels} from '../../../api/labels';
|
||||
import {useBrowseOffers} from '../../../api/offers';
|
||||
import {useBrowseTiers} from '../../../api/tiers';
|
||||
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
||||
|
||||
type RefipientValueArgs = {
|
||||
@ -39,16 +37,6 @@ const RECIPIENT_FILTER_OPTIONS = [{
|
||||
value: 'none'
|
||||
}];
|
||||
|
||||
const SIMPLE_SEGMENT_OPTIONS: MultiSelectOption[] = [{
|
||||
label: 'Free members',
|
||||
value: 'status:free',
|
||||
color: 'green'
|
||||
}, {
|
||||
label: 'Paid members',
|
||||
value: 'status:-free',
|
||||
color: 'pink'
|
||||
}];
|
||||
|
||||
function getDefaultRecipientValue({
|
||||
defaultEmailRecipients,
|
||||
defaultEmailRecipientsFilter
|
||||
@ -88,9 +76,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
defaultEmailRecipientsFilter
|
||||
}));
|
||||
|
||||
const {data: {tiers} = {}} = useBrowseTiers();
|
||||
const {data: {labels} = {}} = useBrowseLabels();
|
||||
const {data: {offers} = {}} = useBrowseOffers();
|
||||
const {loadOptions, selectedSegments, setSelectedSegments} = useDefaultRecipientsOptions(selectedOption, defaultEmailRecipientsFilter);
|
||||
|
||||
const setDefaultRecipientValue = (value: string) => {
|
||||
if (['visibility', 'disabled'].includes(value)) {
|
||||
@ -115,34 +101,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
setSelectedOption(value);
|
||||
};
|
||||
|
||||
const segmentOptionGroups: GroupBase<MultiSelectOption>[] = [
|
||||
{
|
||||
options: SIMPLE_SEGMENT_OPTIONS
|
||||
},
|
||||
{
|
||||
label: 'Active Tiers',
|
||||
options: tiers?.filter(({active, type}) => active && type !== 'free').map(tier => ({value: tier.id, label: tier.name, color: 'black'})) || []
|
||||
},
|
||||
{
|
||||
label: 'Archived Tiers',
|
||||
options: tiers?.filter(({active}) => !active).map(tier => ({value: tier.id, label: tier.name, color: 'black'})) || []
|
||||
},
|
||||
{
|
||||
label: 'Labels',
|
||||
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'})) || []
|
||||
}
|
||||
];
|
||||
const updateSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
|
||||
setSelectedSegments(selected);
|
||||
|
||||
const filters = defaultEmailRecipientsFilter?.split(',') || [];
|
||||
const selectedSegments = segmentOptionGroups
|
||||
.flatMap(({options}) => options)
|
||||
.filter(({value}) => filters.includes(value));
|
||||
|
||||
const setSelectedSegments = (selected: MultiValue<MultiSelectOption>) => {
|
||||
if (selected.length) {
|
||||
const selectedGroups = selected?.map(({value}) => value).join(',');
|
||||
updateSetting('editor_default_email_recipients_filter', selectedGroups);
|
||||
@ -169,21 +130,23 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
<Select
|
||||
hint='Who should be able to subscribe to your site?'
|
||||
options={RECIPIENT_FILTER_OPTIONS}
|
||||
selectedOption={selectedOption}
|
||||
selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
|
||||
title="Default Newsletter recipients"
|
||||
onSelect={(value) => {
|
||||
if (value) {
|
||||
setDefaultRecipientValue(value);
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setDefaultRecipientValue(option.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(selectedOption === 'segment') && (
|
||||
{(selectedOption === 'segment') && selectedSegments && (
|
||||
<MultiSelect
|
||||
options={segmentOptionGroups.filter(group => group.options.length > 0)}
|
||||
loadOptions={loadOptions}
|
||||
title='Filter'
|
||||
values={selectedSegments}
|
||||
async
|
||||
clearBg
|
||||
onChange={setSelectedSegments}
|
||||
defaultOptions
|
||||
onChange={updateSelectedSegments}
|
||||
/>
|
||||
)}
|
||||
</SettingGroupContent>
|
||||
|
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import {Setting, getSettingValues, useEditSettings} from '../../../api/settings';
|
||||
import {useGlobalData} from '../../providers/GlobalDataProvider';
|
||||
@ -27,7 +28,11 @@ const EnableNewsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
updates.push({key: 'editor_default_email_recipients_filter', value: null});
|
||||
}
|
||||
|
||||
await editSettings(updates);
|
||||
try {
|
||||
await editSettings(updates);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const enableToggle = (
|
||||
|
@ -5,6 +5,7 @@ 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 TextField from '../../../admin-x-ds/global/form/TextField';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {getSettingValues, useEditSettings} from '../../../api/settings';
|
||||
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
||||
@ -64,10 +65,10 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
<div className='grid grid-cols-[120px_auto] gap-x-3 gap-y-6'>
|
||||
<Select
|
||||
options={MAILGUN_REGIONS}
|
||||
selectedOption={mailgunRegion}
|
||||
selectedOption={MAILGUN_REGIONS.find(option => option.value === mailgunRegion)}
|
||||
title="Mailgun region"
|
||||
onSelect={(value) => {
|
||||
updateSetting('mailgun_base_url', value || null);
|
||||
onSelect={(option) => {
|
||||
updateSetting('mailgun_base_url', option?.value || null);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@ -113,7 +114,12 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
// resulting in the mailgun base url remaining null
|
||||
// this should not fire if the user has changed the region or if the region is already set
|
||||
if (!mailgunRegion) {
|
||||
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
|
||||
try {
|
||||
await editSettings([{key: 'mailgun_base_url', value: MAILGUN_REGIONS[0].value}]);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
handleSave();
|
||||
}}
|
||||
|
@ -13,7 +13,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
updateRoute('newsletters/add');
|
||||
};
|
||||
const [selectedTab, setSelectedTab] = useState('active-newsletters');
|
||||
const {data: {newsletters} = {}} = useBrowseNewsletters();
|
||||
const {data: {newsletters, meta, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
|
||||
|
||||
const buttons = (
|
||||
<Button color='green' label='Add newsletter' link linkWithPadding onClick={() => {
|
||||
@ -43,6 +43,11 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
title='Newsletters'
|
||||
>
|
||||
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||
{isEnd === false && <Button
|
||||
label={`Load more (showing ${newsletters?.length || 0}/${meta?.pagination.total || 0} newsletters)`}
|
||||
link
|
||||
onClick={() => fetchNextPage()}
|
||||
/>}
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import React, {useEffect} from 'react';
|
||||
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {HostLimitError, useLimiter} from '../../../../hooks/useLimiter';
|
||||
@ -39,6 +40,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
|
||||
|
||||
updateRoute({route: `newsletters/show/${response.newsletters[0].id}`});
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
|
@ -19,6 +19,7 @@ import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
|
||||
import useForm, {ErrorMessages} from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
@ -84,12 +85,16 @@ const Sidebar: React.FC<{
|
||||
okLabel: 'Archive',
|
||||
okColor: 'red',
|
||||
onOk: async (modal) => {
|
||||
await editNewsletter({...newsletter, status: 'archived'});
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Newsletter archived successfully'
|
||||
});
|
||||
try {
|
||||
await editNewsletter({...newsletter, status: 'archived'});
|
||||
modal?.remove();
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'Newsletter archived successfully'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@ -155,7 +160,12 @@ const Sidebar: React.FC<{
|
||||
onChange={e => updateNewsletter({sender_email: e.target.value})}
|
||||
onKeyDown={() => clearError('sender_email')}
|
||||
/>
|
||||
<Select options={replyToEmails} selectedOption={newsletter.sender_reply_to} title="Reply-to email" onSelect={value => updateNewsletter({sender_reply_to: value})}/>
|
||||
<Select
|
||||
options={replyToEmails}
|
||||
selectedOption={replyToEmails.find(option => option.value === newsletter.sender_reply_to)}
|
||||
title="Reply-to email"
|
||||
onSelect={option => updateNewsletter({sender_reply_to: option?.value})}
|
||||
/>
|
||||
</Form>
|
||||
<Form className='mt-6' gap='sm' margins='lg' title='Member settings'>
|
||||
<Toggle
|
||||
@ -212,8 +222,12 @@ const Sidebar: React.FC<{
|
||||
updateNewsletter({header_image: null});
|
||||
}}
|
||||
onUpload={async (file) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateNewsletter({header_image: imageUrl});
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateNewsletter({header_image: imageUrl});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon colorClass='text-grey-700 dark:text-grey-300' name='picture' />
|
||||
@ -299,8 +313,8 @@ const Sidebar: React.FC<{
|
||||
<Select
|
||||
disabled={!newsletter.show_post_title_section}
|
||||
options={fontOptions}
|
||||
selectedOption={newsletter.title_font_category}
|
||||
onSelect={value => updateNewsletter({title_font_category: value})}
|
||||
selectedOption={fontOptions.find(option => option.value === newsletter.title_font_category)}
|
||||
onSelect={option => updateNewsletter({title_font_category: option?.value})}
|
||||
/>
|
||||
</div>
|
||||
<ButtonGroup buttons={[
|
||||
@ -350,9 +364,9 @@ const Sidebar: React.FC<{
|
||||
/>}
|
||||
<Select
|
||||
options={fontOptions}
|
||||
selectedOption={newsletter.body_font_category}
|
||||
selectedOption={fontOptions.find(option => option.value === newsletter.body_font_category)}
|
||||
title='Body style'
|
||||
onSelect={value => updateNewsletter({body_font_category: value})}
|
||||
onSelect={option => updateNewsletter({body_font_category: option?.value})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_feature_image}
|
||||
@ -447,6 +461,7 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
||||
modal.remove();
|
||||
}
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@ -500,9 +515,15 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
||||
};
|
||||
|
||||
const NewsletterDetailModal: React.FC<RoutingModalProps> = ({params}) => {
|
||||
const {data: {newsletters} = {}} = useBrowseNewsletters();
|
||||
const {data: {newsletters, isEnd} = {}, fetchNextPage} = useBrowseNewsletters();
|
||||
const newsletter = newsletters?.find(({id}) => id === params?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (!newsletter && !isEnd) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, isEnd, newsletter]);
|
||||
|
||||
if (newsletter) {
|
||||
return <NewsletterDetailModalContent newsletter={newsletter} onlyOne={newsletters!.length === 1} />;
|
||||
} else {
|
||||
|
@ -13,52 +13,7 @@ interface NewslettersListProps {
|
||||
}
|
||||
|
||||
const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => {
|
||||
// const {mutateAsync: editNewsletter} = useEditNewsletter();
|
||||
const {updateRoute} = useRouting();
|
||||
// const limiter = useLimiter();
|
||||
// const action = newsletter.status === 'active' ? (
|
||||
// <Button color='red' disabled={onlyOne} label='Archive' link onClick={() => {
|
||||
// NiceModal.show(ConfirmationModal, {
|
||||
// title: 'Archive newsletter',
|
||||
// prompt: <>
|
||||
// <p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
|
||||
// <p>Existing posts previously sent as this newsletter will remain unchanged.</p>
|
||||
// </>,
|
||||
// okLabel: 'Archive',
|
||||
// onOk: async (modal) => {
|
||||
// await editNewsletter({...newsletter, status: 'archived'});
|
||||
// modal?.remove();
|
||||
// }
|
||||
// });
|
||||
// }} />
|
||||
// ) : (
|
||||
// <Button color='green' label='Activate' link onClick={async () => {
|
||||
// try {
|
||||
// await limiter?.errorIfWouldGoOverLimit('newsletters');
|
||||
// } catch (error) {
|
||||
// if (error instanceof HostLimitError) {
|
||||
// NiceModal.show(LimitModal, {
|
||||
// prompt: error.message || `Your current plan doesn't support more newsletters.`
|
||||
// });
|
||||
// return;
|
||||
// } else {
|
||||
// throw error;
|
||||
// }
|
||||
// }
|
||||
|
||||
// NiceModal.show(ConfirmationModal, {
|
||||
// title: 'Reactivate newsletter',
|
||||
// prompt: <>
|
||||
// Reactivating <strong>{newsletter.name}</strong> will immediately make it visible to members and re-enable it as an option when publishing new posts.
|
||||
// </>,
|
||||
// okLabel: 'Reactivate',
|
||||
// onOk: async (modal) => {
|
||||
// await editNewsletter({...newsletter, status: 'active'});
|
||||
// modal?.remove();
|
||||
// }
|
||||
// });
|
||||
// }} />
|
||||
// );
|
||||
|
||||
const showDetails = () => {
|
||||
updateRoute({route: `newsletters/show/${newsletter.id}`});
|
||||
|
@ -0,0 +1,100 @@
|
||||
import useFilterableApi from '../../../hooks/useFilterableApi';
|
||||
import {GroupBase, MultiValue} from 'react-select';
|
||||
import {Label} from '../../../api/labels';
|
||||
import {LoadOptions, MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
|
||||
import {Offer} from '../../../api/offers';
|
||||
import {Tier} from '../../../api/tiers';
|
||||
import {debounce} from '../../../utils/debounce';
|
||||
import {isObjectId} from '../../../utils/helpers';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const SIMPLE_SEGMENT_OPTIONS: MultiSelectOption[] = [{
|
||||
label: 'Free members',
|
||||
value: 'status:free',
|
||||
color: 'green'
|
||||
}, {
|
||||
label: 'Paid members',
|
||||
value: 'status:-free',
|
||||
color: 'pink'
|
||||
}];
|
||||
|
||||
const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipientsFilter?: string | null) => {
|
||||
const tiers = useFilterableApi<Tier, 'tiers', 'name'>({path: '/tiers/', filterKey: 'name', responseKey: 'tiers'});
|
||||
const labels = useFilterableApi<Label, 'labels', 'name'>({path: '/labels/', filterKey: 'name', responseKey: 'labels'});
|
||||
const offers = useFilterableApi<Offer, 'offers', 'name'>({path: '/offers/', filterKey: 'name', responseKey: 'offers'});
|
||||
|
||||
const [selectedSegments, setSelectedSegments] = useState<MultiValue<MultiSelectOption> | null>(null);
|
||||
|
||||
const tierOption = (tier: Tier): MultiSelectOption => ({value: tier.id, label: tier.name, color: 'black'});
|
||||
const labelOption = (label: Label): MultiSelectOption => ({value: `label:${label.slug}`, label: label.name, color: 'grey'});
|
||||
const offerOption = (offer: Offer): MultiSelectOption => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'});
|
||||
|
||||
const loadOptions: LoadOptions = async (input, callback) => {
|
||||
const [tiersData, labelsData, offersData] = await Promise.all([tiers.loadData(input), labels.loadData(input), offers.loadData(input)]);
|
||||
|
||||
const segmentOptionGroups: GroupBase<MultiSelectOption>[] = [
|
||||
{
|
||||
options: SIMPLE_SEGMENT_OPTIONS.filter(({label}) => label.toLowerCase().includes(input.toLowerCase()))
|
||||
},
|
||||
{
|
||||
label: 'Active Tiers',
|
||||
options: tiersData.filter(({active, type}) => active && type !== 'free').map(tierOption) || []
|
||||
},
|
||||
{
|
||||
label: 'Archived Tiers',
|
||||
options: tiersData.filter(({active}) => !active).map(tierOption) || []
|
||||
},
|
||||
{
|
||||
label: 'Labels',
|
||||
options: labelsData.map(labelOption) || []
|
||||
},
|
||||
{
|
||||
label: 'Offers',
|
||||
options: offersData.map(offerOption) || []
|
||||
}
|
||||
];
|
||||
|
||||
if (selectedSegments === null) {
|
||||
initSelectedSegments();
|
||||
}
|
||||
|
||||
callback(segmentOptionGroups.filter(group => group.options.length > 0));
|
||||
};
|
||||
|
||||
const initSelectedSegments = async () => {
|
||||
const filters = defaultEmailRecipientsFilter?.split(',') || [];
|
||||
const tierIds: string[] = [], labelIds: string[] = [], offerIds: string[] = [];
|
||||
|
||||
for (const filter of filters) {
|
||||
if (filter.startsWith('label:')) {
|
||||
labelIds.push(filter.replace('label:', ''));
|
||||
} else if (filter.startsWith('offer_redemptions:')) {
|
||||
offerIds.push(filter.replace('offer_redemptions:', ''));
|
||||
} else if (isObjectId(filter)) {
|
||||
tierIds.push(filter);
|
||||
}
|
||||
}
|
||||
|
||||
const options = await Promise.all([
|
||||
tiers.loadInitialValues(tierIds).then(data => data.map(tierOption)),
|
||||
labels.loadInitialValues(labelIds).then(data => data.map(labelOption)),
|
||||
offers.loadInitialValues(offerIds).then(data => data.map(offerOption))
|
||||
]).then(results => results.flat());
|
||||
|
||||
setSelectedSegments(filters.map(filter => options.find(option => option.value === filter)!));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOption === 'segment') {
|
||||
loadOptions('', () => {});
|
||||
}
|
||||
}, [selectedOption]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
loadOptions: debounce(loadOptions, 500),
|
||||
selectedSegments,
|
||||
setSelectedSegments
|
||||
};
|
||||
};
|
||||
|
||||
export default useDefaultRecipientsOptions;
|
@ -3,6 +3,7 @@ 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 handleError from '../../../utils/api/handleError';
|
||||
import usePinturaEditor from '../../../hooks/usePinturaEditor';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
|
||||
@ -23,20 +24,16 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
} = useSettingGroup();
|
||||
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [pintura] = getSettingValues<boolean>(localSettings, ['pintura']);
|
||||
// const [unsplashEnabled] = getSettingValues<boolean>(localSettings, ['unsplash']);
|
||||
const [pinturaJsUrl] = getSettingValues<string>(localSettings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(localSettings, ['pintura_css_url']);
|
||||
// const [showUnsplash, setShowUnsplash] = useState<boolean>(false);
|
||||
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
}}
|
||||
);
|
||||
|
||||
const [
|
||||
@ -52,8 +49,12 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateSetting('og_image', imageUrl);
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateSetting('og_image', imageUrl);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageDelete = () => {
|
||||
@ -86,7 +87,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
imageURL={facebookImage}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
isEnabled: editor.isEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: facebookImage || '',
|
||||
handleSave: async (file:File) => {
|
||||
|
@ -2,6 +2,7 @@ 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 handleError from '../../../utils/api/handleError';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import validator from 'validator';
|
||||
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
|
||||
@ -119,6 +120,7 @@ const InviteUserModal = NiceModal.create(() => {
|
||||
message: `Failed to send invitation to ${email}`,
|
||||
type: 'error'
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
@ -49,7 +49,7 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
|
||||
const [publicationTimezone] = getSettingValues(localSettings, ['timezone']) as string[];
|
||||
|
||||
const timezoneOptions = timezoneData.map((tzOption: TimezoneDataDropdownOption) => {
|
||||
const timezoneOptions: Array<{value: string; label: string}> = timezoneData.map((tzOption: TimezoneDataDropdownOption) => {
|
||||
return {
|
||||
value: tzOption.name,
|
||||
label: tzOption.label
|
||||
@ -76,9 +76,9 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
<Select
|
||||
hint={<Hint timezone={publicationTimezone} />}
|
||||
options={timezoneOptions}
|
||||
selectedOption={publicationTimezone}
|
||||
selectedOption={timezoneOptions.find(option => option.value === publicationTimezone)}
|
||||
title="Site timezone"
|
||||
onSelect={handleTimezoneChange}
|
||||
onSelect={option => handleTimezoneChange(option?.value)}
|
||||
/>
|
||||
</SettingGroupContent>
|
||||
);
|
||||
|
@ -3,6 +3,7 @@ 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 handleError from '../../../utils/api/handleError';
|
||||
import usePinturaEditor from '../../../hooks/usePinturaEditor';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';
|
||||
@ -23,18 +24,15 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
} = useSettingGroup();
|
||||
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [pintura] = getSettingValues<boolean>(localSettings, ['pintura']);
|
||||
|
||||
const [pinturaJsUrl] = getSettingValues<string>(localSettings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(localSettings, ['pintura_css_url']);
|
||||
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
}}
|
||||
);
|
||||
|
||||
const [
|
||||
@ -53,8 +51,8 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateSetting('twitter_image', imageUrl);
|
||||
} catch (err) {
|
||||
// TODO: handle error
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@ -86,7 +84,7 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
imageURL={twitterImage}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
isEnabled: editor.isEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: twitterImage || '',
|
||||
handleSave: async (file:File) => {
|
||||
|
@ -15,6 +15,7 @@ import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupConten
|
||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
||||
import clsx from 'clsx';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useFeatureFlag from '../../../hooks/useFeatureFlag';
|
||||
import usePinturaEditor from '../../../hooks/usePinturaEditor';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
@ -23,7 +24,7 @@ import validator from 'validator';
|
||||
import {DetailsInputs} from './DetailsInputs';
|
||||
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
|
||||
import {RoutingModalProps} from '../../providers/RoutingProvider';
|
||||
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '../../../api/users';
|
||||
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '../../../api/users';
|
||||
import {genStaffToken, getStaffToken} from '../../../api/staffToken';
|
||||
import {getImageUrl, useUploadImage} from '../../../api/images';
|
||||
import {getSettingValues} from '../../../api/settings';
|
||||
@ -177,6 +178,7 @@ const Details: React.FC<UserDetailProps> = ({errors, validators, user, setUserDa
|
||||
|
||||
const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}) => {
|
||||
const hasWebmentions = useFeatureFlag('webmentions');
|
||||
const hasRecommendations = useFeatureFlag('recommendations');
|
||||
const {currentUser} = useGlobalData();
|
||||
|
||||
return (
|
||||
@ -200,6 +202,15 @@ const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}
|
||||
setUserData?.({...user, mention_notifications: e.target.checked});
|
||||
}}
|
||||
/>}
|
||||
{hasRecommendations && <Toggle
|
||||
checked={user.recommendation_notifications}
|
||||
direction='rtl'
|
||||
hint='Every time another publisher recommends you to their audience'
|
||||
label='Recommendations'
|
||||
onChange={(e) => {
|
||||
setUserData?.({...user, recommendation_notifications: e.target.checked});
|
||||
}}
|
||||
/>}
|
||||
<Toggle
|
||||
checked={user.free_member_signup_notification}
|
||||
direction='rtl'
|
||||
@ -278,9 +289,13 @@ const StaffToken: React.FC<UserDetailProps> = () => {
|
||||
okLabel: 'Regenerate your Staff Access Token',
|
||||
okColor: 'red',
|
||||
onOk: async (modal) => {
|
||||
const newAPI = await newApiKey([]);
|
||||
setToken(newAPI?.apiKey?.secret || '');
|
||||
modal?.remove();
|
||||
try {
|
||||
const newAPI = await newApiKey([]);
|
||||
setToken(newAPI?.apiKey?.secret || '');
|
||||
modal?.remove();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -329,17 +344,14 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
|
||||
// Pintura integration
|
||||
const {settings} = useGlobalData();
|
||||
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
|
||||
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
}}
|
||||
);
|
||||
|
||||
const navigateOnClose = useCallback(() => {
|
||||
@ -395,13 +407,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
..._user,
|
||||
status: _user.status === 'inactive' ? 'active' : 'inactive'
|
||||
};
|
||||
await updateUser(updatedUserData);
|
||||
setUserData(updatedUserData);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
await updateUser(updatedUserData);
|
||||
setUserData(updatedUserData);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: _user.status === 'inactive' ? 'User un-suspended' : 'User suspended',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -418,13 +434,17 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
okLabel: 'Delete user',
|
||||
okColor: 'red',
|
||||
onOk: async (modal) => {
|
||||
await deleteUser(_user?.id);
|
||||
modal?.remove();
|
||||
mainModal?.remove();
|
||||
showToast({
|
||||
message: 'User deleted',
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
await deleteUser(_user?.id);
|
||||
modal?.remove();
|
||||
mainModal?.remove();
|
||||
showToast({
|
||||
message: 'User deleted',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -436,12 +456,16 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
okLabel: 'Yep — I\'m sure',
|
||||
okColor: 'red',
|
||||
onOk: async (modal) => {
|
||||
await makeOwner(user.id);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: 'Ownership transferred',
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
await makeOwner(user.id);
|
||||
modal?.remove();
|
||||
showToast({
|
||||
message: 'Ownership transferred',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -462,8 +486,8 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
// TODO: handle error
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@ -482,6 +506,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const showMenu = hasAdminAccess(currentUser) || (isEditorUser(currentUser) && isAuthorOrContributor(user));
|
||||
let menuItems: MenuItem[] = [];
|
||||
|
||||
if (isOwnerUser(currentUser) && isAdminUser(userData) && userData.status !== 'inactive') {
|
||||
@ -492,7 +517,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (userData.id !== currentUser.id) {
|
||||
if (userData.id !== currentUser.id && (
|
||||
(hasAdminAccess(currentUser) && !isOwnerUser(user)) ||
|
||||
(isEditorUser(currentUser) && isAuthorOrContributor(user))
|
||||
)) {
|
||||
let suspendUserLabel = userData.status === 'inactive' ? 'Un-suspend user' : 'Suspend user';
|
||||
|
||||
menuItems.push({
|
||||
@ -528,7 +556,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
}
|
||||
|
||||
const coverButtonContainerClassName = clsx(
|
||||
hasAdminAccess(currentUser) ? (
|
||||
showMenu ? (
|
||||
userData.cover_image ? 'relative ml-10 mr-[106px] flex translate-y-[-80px] gap-3 md:ml-0 md:justify-end' : 'relative -mb-8 ml-10 mr-[106px] flex translate-y-[358px] md:ml-0 md:translate-y-[268px] md:justify-end'
|
||||
) : (
|
||||
userData.cover_image ? 'relative ml-10 flex max-w-4xl translate-y-[-80px] gap-3 md:mx-auto md:justify-end' : 'relative -mb-8 ml-10 flex max-w-4xl translate-y-[358px] md:mx-auto md:translate-y-[268px] md:justify-end'
|
||||
@ -621,7 +649,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
imageURL={userData.cover_image || ''}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
isEnabled: editor.isEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: userData.cover_image || '',
|
||||
handleSave: async (file:File) => {
|
||||
@ -638,7 +666,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
handleImageUpload('cover_image', file);
|
||||
}}
|
||||
>Upload cover image</ImageUpload>
|
||||
{hasAdminAccess(currentUser) && <div className="absolute bottom-12 right-12 z-10">
|
||||
{showMenu && <div className="absolute bottom-12 right-12 z-10">
|
||||
<Menu items={menuItems} position='right' trigger={<UserMenuTrigger />}></Menu>
|
||||
</div>}
|
||||
<div className={`${!canAccessSettings(currentUser) ? 'mx-10 pl-0 md:max-w-[50%] min-[920px]:ml-[calc((100vw-920px)/2)] min-[920px]:max-w-[460px]' : 'max-w-[50%] pl-12'} relative flex flex-col items-start gap-4 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60`}>
|
||||
@ -653,7 +681,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
imageURL={userData.profile_image}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
isEnabled: editor.isEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: userData.profile_image || '',
|
||||
handleSave: async (file:File) => {
|
||||
@ -694,9 +722,15 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
};
|
||||
|
||||
const UserDetailModal: React.FC<RoutingModalProps> = ({params}) => {
|
||||
const {users} = useStaffUsers();
|
||||
const {users, hasNextPage, fetchNextPage} = useStaffUsers();
|
||||
const user = users.find(({slug}) => slug === params?.slug);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user && !hasNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, user]);
|
||||
|
||||
if (user) {
|
||||
return <UserDetailModalContent user={user} />;
|
||||
} else {
|
||||
|
@ -7,6 +7,7 @@ import React, {useState} from 'react';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import clsx from 'clsx';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
||||
import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users';
|
||||
@ -127,13 +128,18 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
||||
label={revokeActionLabel}
|
||||
link={true}
|
||||
onClick={async () => {
|
||||
setRevokeState('progress');
|
||||
await deleteInvite(invite.id);
|
||||
setRevokeState('');
|
||||
showToast({
|
||||
message: `Invitation revoked (${invite.email})`,
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
setRevokeState('progress');
|
||||
await deleteInvite(invite.id);
|
||||
showToast({
|
||||
message: `Invitation revoked (${invite.email})`,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setRevokeState('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@ -142,17 +148,22 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
||||
label={resendActionLabel}
|
||||
link={true}
|
||||
onClick={async () => {
|
||||
setResendState('progress');
|
||||
await deleteInvite(invite.id);
|
||||
await addInvite({
|
||||
email: invite.email,
|
||||
roleId: invite.role_id
|
||||
});
|
||||
setResendState('');
|
||||
showToast({
|
||||
message: `Invitation resent! (${invite.email})`,
|
||||
type: 'success'
|
||||
});
|
||||
try {
|
||||
setResendState('progress');
|
||||
await deleteInvite(invite.id);
|
||||
await addInvite({
|
||||
email: invite.email,
|
||||
roleId: invite.role_id
|
||||
});
|
||||
showToast({
|
||||
message: `Invitation resent! (${invite.email})`,
|
||||
type: 'success'
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setResendState('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -195,12 +206,16 @@ const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
|
||||
|
||||
const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, highlight = true}) => {
|
||||
const {
|
||||
totalUsers,
|
||||
users,
|
||||
ownerUser,
|
||||
adminUsers,
|
||||
editorUsers,
|
||||
authorUsers,
|
||||
contributorUsers,
|
||||
invites
|
||||
invites,
|
||||
hasNextPage,
|
||||
fetchNextPage
|
||||
} = useStaffUsers();
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
@ -255,6 +270,11 @@ const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords,
|
||||
>
|
||||
<Owner user={ownerUser} />
|
||||
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||
{hasNextPage && <Button
|
||||
label={`Load more (showing ${users.length}/${totalUsers} users)`}
|
||||
link
|
||||
onClick={() => fetchNextPage()}
|
||||
/>}
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
@ -2,6 +2,7 @@ import Button from '../../../../admin-x-ds/global/Button';
|
||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||
import SettingGroup from '../../../../admin-x-ds/settings/SettingGroup';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import {User, useUpdatePassword} from '../../../../api/users';
|
||||
import {ValidationError} from '../../../../utils/errors';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
@ -223,6 +224,7 @@ const ChangePasswordForm: React.FC<{user: User}> = ({user}) => {
|
||||
type: 'pageError',
|
||||
message: e instanceof ValidationError ? e.message : `Couldn't update password. Please try again.`
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -136,19 +136,19 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
<Select
|
||||
hint='Who should be able to subscribe to your site?'
|
||||
options={MEMBERS_SIGNUP_ACCESS_OPTIONS}
|
||||
selectedOption={membersSignupAccess}
|
||||
selectedOption={MEMBERS_SIGNUP_ACCESS_OPTIONS.find(option => option.value === membersSignupAccess)}
|
||||
title="Subscription access"
|
||||
onSelect={(value) => {
|
||||
updateSetting('members_signup_access', value || null);
|
||||
onSelect={(option) => {
|
||||
updateSetting('members_signup_access', option?.value || null);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
hint='When a new post is created, who should have access?'
|
||||
options={DEFAULT_CONTENT_VISIBILITY_OPTIONS}
|
||||
selectedOption={defaultContentVisibility}
|
||||
selectedOption={DEFAULT_CONTENT_VISIBILITY_OPTIONS.find(option => option.value === defaultContentVisibility)}
|
||||
title="Default post access"
|
||||
onSelect={(value) => {
|
||||
updateSetting('default_content_visibility', value || null);
|
||||
onSelect={(option) => {
|
||||
updateSetting('default_content_visibility', option?.value || null);
|
||||
}}
|
||||
/>
|
||||
{defaultContentVisibility === 'tiers' && (
|
||||
@ -164,10 +164,10 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
<Select
|
||||
hint='Who can comment on posts?'
|
||||
options={COMMENTS_ENABLED_OPTIONS}
|
||||
selectedOption={commentsEnabled}
|
||||
selectedOption={COMMENTS_ENABLED_OPTIONS.find(option => option.value === commentsEnabled)}
|
||||
title="Commenting"
|
||||
onSelect={(value) => {
|
||||
updateSetting('comments_enabled', value || null);
|
||||
onSelect={(option) => {
|
||||
updateSetting('comments_enabled', option?.value || null);
|
||||
}}
|
||||
/>
|
||||
</SettingGroupContent>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Button from '../../../admin-x-ds/global/Button';
|
||||
import React, {useState} from 'react';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import StripeButton from '../../../admin-x-ds/settings/StripeButton';
|
||||
@ -26,7 +27,7 @@ const StripeConnectedButton: React.FC<{className?: string; onClick: () => void;}
|
||||
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const [selectedTab, setSelectedTab] = useState('active-tiers');
|
||||
const {settings, config} = useGlobalData();
|
||||
const {data: {tiers} = {}} = useBrowseTiers();
|
||||
const {data: {tiers, meta, isEnd} = {}, fetchNextPage} = useBrowseTiers();
|
||||
const activeTiers = getActiveTiers(tiers || []);
|
||||
const archivedTiers = getArchivedTiers(tiers || []);
|
||||
const {updateRoute} = useRouting();
|
||||
@ -80,6 +81,11 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
</div>
|
||||
|
||||
{content}
|
||||
{isEnd === false && <Button
|
||||
label={`Load more (showing ${tiers?.length || 0}/${meta?.pagination.total || 0} tiers)`}
|
||||
link
|
||||
onClick={() => fetchNextPage()}
|
||||
/>}
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
@ -102,8 +102,8 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
containerClassName='w-14'
|
||||
fullWidth={false}
|
||||
options={currencySelectGroups()}
|
||||
selectedOption={donationsCurrency}
|
||||
onSelect={currency => updateSetting('donations_currency', currency || 'USD')}
|
||||
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === donationsCurrency)}
|
||||
onSelect={option => updateSetting('donations_currency', option?.value || 'USD')}
|
||||
/>
|
||||
)}
|
||||
title='Suggested amount'
|
||||
|
@ -8,7 +8,6 @@ import {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect'
|
||||
import {MultiValue} from 'react-select';
|
||||
import {generateCode} from '../../../../utils/generateEmbedCode';
|
||||
import {getSettingValues} from '../../../../api/settings';
|
||||
import {useBrowseLabels} from '../../../../api/labels';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
|
||||
@ -25,7 +24,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
|
||||
const {config} = useGlobalData();
|
||||
const {localSettings, siteData} = useSettingGroup();
|
||||
const [accentColor, title, description, locale, labs, icon] = getSettingValues<string>(localSettings, ['accent_color', 'title', 'description', 'locale', 'labs', 'icon']);
|
||||
const {data: labels} = useBrowseLabels();
|
||||
const [customColor, setCustomColor] = useState<{active: boolean}>({active: false});
|
||||
|
||||
if (labs) {
|
||||
@ -113,7 +111,6 @@ const EmbedSignupFormModal = NiceModal.create(() => {
|
||||
handleLabelClick={addSelectedLabel}
|
||||
handleLayoutSelect={setSelectedLayout}
|
||||
isCopied={isCopied}
|
||||
labels={labels?.labels || []}
|
||||
selectedColor={selectedColor}
|
||||
selectedLabels={selectedLabels}
|
||||
selectedLayout={selectedLayout}
|
||||
|
@ -3,13 +3,15 @@ import ColorIndicator from '../../../../admin-x-ds/global/form/ColorIndicator';
|
||||
import ColorPicker from '../../../../admin-x-ds/global/form/ColorPicker';
|
||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||
import MultiSelect, {MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect';
|
||||
import MultiSelect, {LoadOptions, MultiSelectOption} from '../../../../admin-x-ds/global/form/MultiSelect';
|
||||
import Radio from '../../../../admin-x-ds/global/form/Radio';
|
||||
import React from 'react';
|
||||
import StickyFooter from '../../../../admin-x-ds/global/StickyFooter';
|
||||
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||
import useFilterableApi from '../../../../hooks/useFilterableApi';
|
||||
import {Label} from '../../../../api/labels';
|
||||
import {MultiValue} from 'react-select';
|
||||
import {debounce} from '../../../../utils/debounce';
|
||||
|
||||
export type SelectedLabelTypes = {
|
||||
label: string;
|
||||
@ -20,7 +22,6 @@ type SidebarProps = {
|
||||
selectedColor?: string;
|
||||
accentColor?: string;
|
||||
handleColorToggle: (e: string) => void;
|
||||
labels?: Label[];
|
||||
handleLabelClick: (selected: MultiValue<MultiSelectOption>) => void;
|
||||
selectedLabels?: SelectedLabelTypes[];
|
||||
embedScript: string;
|
||||
@ -36,7 +37,6 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
|
||||
accentColor,
|
||||
handleColorToggle,
|
||||
selectedColor,
|
||||
labels,
|
||||
selectedLabels,
|
||||
handleLabelClick,
|
||||
embedScript,
|
||||
@ -45,12 +45,13 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
|
||||
customColor,
|
||||
setCustomColor,
|
||||
isCopied}) => {
|
||||
const labelOptions = labels ? labels.map((l) => {
|
||||
return {
|
||||
label: l?.name,
|
||||
value: l?.name
|
||||
};
|
||||
}).filter(Boolean) : [];
|
||||
const {loadData} = useFilterableApi<Label>({path: '/labels/', filterKey: 'name', responseKey: 'labels'});
|
||||
|
||||
const loadOptions: LoadOptions = async (input, callback) => {
|
||||
const labels = await loadData(input);
|
||||
callback(labels.map(label => ({label: label.name, value: label.name})));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-[calc(100vh-16vmin)] max-h-[645px] flex-col justify-between overflow-y-scroll border-l border-grey-200 p-6 pb-0 dark:border-grey-900'>
|
||||
<div>
|
||||
@ -125,12 +126,14 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
|
||||
}
|
||||
|
||||
<MultiSelect
|
||||
canCreate={true}
|
||||
hint='Will be applied to all members signing up via this form'
|
||||
options={labelOptions}
|
||||
loadOptions={debounce(loadOptions, 500)}
|
||||
placeholder='Pick one or more labels (optional)'
|
||||
title='Labels at signup'
|
||||
values={selectedLabels || []}
|
||||
async
|
||||
canCreate
|
||||
defaultOptions
|
||||
onChange={handleLabelClick}
|
||||
/>
|
||||
<TextArea
|
||||
|
@ -7,6 +7,7 @@ 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';
|
||||
import clsx from 'clsx';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import {ReactComponent as PortalIcon1} from '../../../../assets/icons/portal-icon-1.svg';
|
||||
import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg';
|
||||
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
|
||||
@ -52,9 +53,13 @@ const LookAndFeel: React.FC<{
|
||||
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateSetting('portal_button_icon', imageUrl);
|
||||
setUploadedIcon(imageUrl);
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateSetting('portal_button_icon', imageUrl);
|
||||
setUploadedIcon(imageUrl);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageDelete = () => {
|
||||
@ -62,6 +67,12 @@ const LookAndFeel: React.FC<{
|
||||
setUploadedIcon(undefined);
|
||||
};
|
||||
|
||||
const portalButtonOptions = [
|
||||
{value: 'icon-and-text', label: 'Icon and text'},
|
||||
{value: 'icon-only', label: 'Icon only'},
|
||||
{value: 'text-only', label: 'Text only'}
|
||||
];
|
||||
|
||||
return <div className='mt-7'><Form>
|
||||
<Toggle
|
||||
checked={Boolean(portalButton)}
|
||||
@ -70,14 +81,10 @@ const LookAndFeel: React.FC<{
|
||||
onChange={e => updateSetting('portal_button', e.target.checked)}
|
||||
/>
|
||||
<Select
|
||||
options={[
|
||||
{value: 'icon-and-text', label: 'Icon and text'},
|
||||
{value: 'icon-only', label: 'Icon only'},
|
||||
{value: 'text-only', label: 'Text only'}
|
||||
]}
|
||||
selectedOption={portalButtonStyle as string}
|
||||
options={portalButtonOptions}
|
||||
selectedOption={portalButtonOptions.find(option => option.value === portalButtonStyle)}
|
||||
title='Portal button style'
|
||||
onSelect={option => updateSetting('portal_button_style', option || null)}
|
||||
onSelect={option => updateSetting('portal_button_style', option?.value || null)}
|
||||
/>
|
||||
{portalButtonStyle?.toString()?.includes('icon') &&
|
||||
<div className='flex flex-col gap-2'>
|
||||
|
@ -81,10 +81,10 @@ const PortalLinks: React.FC = () => {
|
||||
<span className='inline-block w-[240px] shrink-0'>Tier</span>
|
||||
<Select
|
||||
options={tierOptions}
|
||||
selectedOption={selectedTier}
|
||||
onSelect={(value) => {
|
||||
if (value) {
|
||||
setSelectedTier(value);
|
||||
selectedOption={tierOptions.find(option => option.value === selectedTier)}
|
||||
onSelect={(option) => {
|
||||
if (option) {
|
||||
setSelectedTier(option?.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -6,14 +6,14 @@ import PortalPreview from './PortalPreview';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import SignupOptions from './SignupOptions';
|
||||
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useForm, {Dirtyable} from '../../../../hooks/useForm';
|
||||
import useQueryParams from '../../../../hooks/useQueryParams';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
|
||||
import {Setting, SettingValue, useEditSettings} from '../../../../api/settings';
|
||||
import {Setting, SettingValue, getSettingValues, useEditSettings} from '../../../../api/settings';
|
||||
import {Tier, getPaidActiveTiers, useBrowseTiers, useEditTier} from '../../../../api/tiers';
|
||||
import {fullEmailAddress} from '../../../../api/site';
|
||||
import {getSettingValues} from '../../../../api/settings';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
import {verifyEmailToken} from '../../../../api/emailVerification';
|
||||
|
||||
@ -106,6 +106,7 @@ const PortalModal: React.FC = () => {
|
||||
cancelLabel: '',
|
||||
onOk: confirmModal => confirmModal?.remove()
|
||||
});
|
||||
handleError(e, {withToast: false});
|
||||
}
|
||||
};
|
||||
if (verifyEmail) {
|
||||
@ -139,7 +140,9 @@ const PortalModal: React.FC = () => {
|
||||
onOk: confirmModal => confirmModal?.remove()
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onSaveError: handleError
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
|
@ -13,6 +13,7 @@ import StripeLogo from '../../../../assets/images/stripe-emblem.svg';
|
||||
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||
import {JSONError} from '../../../../utils/errors';
|
||||
@ -63,7 +64,7 @@ const Connect: React.FC = () => {
|
||||
|
||||
const saveTier = async () => {
|
||||
const {data} = await fetchActiveTiers();
|
||||
const tier = data?.tiers[0];
|
||||
const tier = data?.pages[0].tiers[0];
|
||||
|
||||
if (tier) {
|
||||
tier.monthly_price = 500;
|
||||
@ -86,7 +87,8 @@ const Connect: React.FC = () => {
|
||||
// no-op: will try saving again as stripe is not ready
|
||||
continue;
|
||||
} else {
|
||||
throw e;
|
||||
handleError(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -111,8 +113,10 @@ const Connect: React.FC = () => {
|
||||
if (e instanceof JSONError && e.data?.errors) {
|
||||
setError('Invalid secure key');
|
||||
return;
|
||||
} else {
|
||||
handleError(e);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
setError('Please enter a secure key');
|
||||
@ -169,9 +173,13 @@ const Connected: React.FC<{onClose?: () => void}> = ({onClose}) => {
|
||||
from this site. This will automatically turn off paid memberships on this site.</>),
|
||||
okLabel: hasActiveStripeSubscriptions ? '' : 'Disconnect',
|
||||
onOk: async (modal) => {
|
||||
await deleteStripeSettings(null);
|
||||
modal?.remove();
|
||||
onClose?.();
|
||||
try {
|
||||
await deleteStripeSettings(null);
|
||||
modal?.remove();
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -12,6 +12,8 @@ import SortableList from '../../../../admin-x-ds/global/SortableList';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import TierDetailPreview from './TierDetailPreview';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||
@ -69,7 +71,8 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
}
|
||||
|
||||
modal.remove();
|
||||
}
|
||||
},
|
||||
onSaveError: handleError
|
||||
});
|
||||
|
||||
const validators = {
|
||||
@ -220,9 +223,9 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
containerClassName='font-medium'
|
||||
controlClasses={{menu: 'w-14'}}
|
||||
options={currencySelectGroups()}
|
||||
selectedOption={formState.currency}
|
||||
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === formState.currency)}
|
||||
size='xs'
|
||||
onSelect={currency => updateForm(state => ({...state, currency}))}
|
||||
onSelect={option => updateForm(state => ({...state, currency: option?.value}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -269,7 +272,15 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextField hint='Redirect to this URL after signup for premium membership' placeholder={siteData?.url} title='Welcome page' />
|
||||
<URLTextField
|
||||
baseUrl={siteData?.url}
|
||||
hint='Redirect to this URL after signup for premium membership'
|
||||
placeholder={siteData?.url}
|
||||
title='Welcome page'
|
||||
value={formState.welcome_page_url || ''}
|
||||
transformPathWithoutSlash
|
||||
onChange={value => updateForm(state => ({...state, welcome_page_url: value || null}))}
|
||||
/>
|
||||
</>)}
|
||||
</Form>
|
||||
|
||||
@ -328,15 +339,21 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
};
|
||||
|
||||
const TierDetailModal: React.FC<RoutingModalProps> = ({params}) => {
|
||||
const {data: {tiers} = {}} = useBrowseTiers();
|
||||
const {data: {tiers, isEnd} = {}, fetchNextPage} = useBrowseTiers();
|
||||
|
||||
let tier: Tier | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (params?.id && !tier && !isEnd) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [fetchNextPage, isEnd, params?.id, tier]);
|
||||
|
||||
if (params?.id) {
|
||||
tier = tiers?.find(({id}) => id === params?.id);
|
||||
|
||||
if (!tier) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
|
||||
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
|
||||
import ThemePreview from './designAndBranding/ThemePreview';
|
||||
import ThemeSettings from './designAndBranding/ThemeSettings';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useForm from '../../../hooks/useForm';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import {CustomThemeSetting, useBrowseCustomThemeSettings, useEditCustomThemeSettings} from '../../../api/customThemeSettings';
|
||||
@ -116,7 +117,8 @@ const DesignModal: React.FC = () => {
|
||||
const {settings: newSettings} = await editSettings(formState.settings.filter(setting => setting.dirty));
|
||||
updateForm(state => ({...state, settings: newSettings}));
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveError: handleError
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -13,11 +13,11 @@ const DesignSetting: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
return (
|
||||
<SettingGroup
|
||||
customButtons={<Button color='green' label='Customize' link linkWithPadding onClick={openPreviewModal}/>}
|
||||
description="Customize the look and feel of your site"
|
||||
description="Customize the theme, colors, and layout of your site"
|
||||
keywords={keywords}
|
||||
navid='design'
|
||||
testId='design'
|
||||
title="Branding and design"
|
||||
title="Design & branding"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -1,29 +1,90 @@
|
||||
import Button from '../../../admin-x-ds/global/Button';
|
||||
import IncomingRecommendations from './recommendations/IncomingRecommendations';
|
||||
import Link from '../../../admin-x-ds/global/Link';
|
||||
import IncomingRecommendationList from './recommendations/IncomingRecommendationList';
|
||||
import React, {useState} from 'react';
|
||||
import RecommendationList from './recommendations/RecommendationList';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||
import {ShowMoreData} from '../../../admin-x-ds/global/Table';
|
||||
import {useBrowseMentions} from '../../../api/mentions';
|
||||
import {useBrowseRecommendations} from '../../../api/recommendations';
|
||||
import {useReferrerHistory} from '../../../api/referrers';
|
||||
import {withErrorBoundary} from '../../../admin-x-ds/global/ErrorBoundary';
|
||||
|
||||
const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const {
|
||||
saveState,
|
||||
siteData,
|
||||
handleSave
|
||||
} = useSettingGroup();
|
||||
|
||||
const {pagination, data: {recommendations} = {}, isLoading} = useBrowseRecommendations({
|
||||
// Fetch "Your recommendations"
|
||||
const {data: {meta: recommendationsMeta, recommendations} = {}, isLoading: areRecommendationsLoading, hasNextPage, fetchNextPage} = useBrowseRecommendations({
|
||||
searchParams: {
|
||||
include: 'count.clicks,count.subscribers'
|
||||
include: 'count.clicks,count.subscribers',
|
||||
limit: '5'
|
||||
},
|
||||
|
||||
// We first load 5, then load 100 at a time (= show all, but without using the dangerous 'all' limit)
|
||||
getNextPageParams: (lastPage, otherParams) => {
|
||||
if (!lastPage.meta) {
|
||||
return;
|
||||
}
|
||||
const {limit, page, pages} = lastPage.meta.pagination;
|
||||
if (page >= pages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newPage = limit < 100 ? 1 : (page + 1);
|
||||
|
||||
return {
|
||||
...otherParams,
|
||||
page: newPage.toString(),
|
||||
limit: '100'
|
||||
};
|
||||
},
|
||||
keepPreviousData: true
|
||||
});
|
||||
|
||||
const showMoreRecommendations: ShowMoreData = {
|
||||
hasMore: !!hasNextPage,
|
||||
loadMore: fetchNextPage
|
||||
};
|
||||
|
||||
// Fetch "Recommending you" (mentions & stats)
|
||||
const {data: {mentions} = {}, pagination: mentionsPagination, isLoading: areMentionsLoading} = useBrowseMentions({
|
||||
searchParams: {
|
||||
limit: '5',
|
||||
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
|
||||
order: 'created_at desc'
|
||||
}
|
||||
});
|
||||
|
||||
const {data: {stats: mentionsStats} = {}, isLoading: areSourcesLoading} = useReferrerHistory({});
|
||||
|
||||
// Select "Your recommendations" by default
|
||||
const [selectedTab, setSelectedTab] = useState('your-recommendations');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'your-recommendations',
|
||||
title: `Your recommendations`,
|
||||
counter: recommendationsMeta?.pagination?.total,
|
||||
contents: <RecommendationList isLoading={areRecommendationsLoading} recommendations={recommendations ?? []} showMore={showMoreRecommendations}/>
|
||||
},
|
||||
{
|
||||
id: 'recommending-you',
|
||||
title: `Recommending you`,
|
||||
counter: mentionsPagination?.total,
|
||||
contents: <IncomingRecommendationList isLoading={areMentionsLoading || areSourcesLoading} mentions={mentions ?? []} pagination={mentionsPagination} stats={mentionsStats ?? []}/>
|
||||
}
|
||||
];
|
||||
|
||||
const groupDescription = (
|
||||
<>Recommend any publication you think your audience will find valuable, and find out when others are recommending you.</>
|
||||
);
|
||||
|
||||
// Add a new recommendation
|
||||
const {updateRoute} = useRouting();
|
||||
const openAddNewRecommendationModal = () => {
|
||||
updateRoute('recommendations/add');
|
||||
@ -35,25 +96,6 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
}} />
|
||||
);
|
||||
|
||||
const recommendationsURL = `${siteData?.url.replace(/\/$/, '')}/#/portal/recommendations`;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'your-recommendations',
|
||||
title: 'Your recommendations',
|
||||
contents: (<RecommendationList isLoading={isLoading} pagination={pagination} recommendations={recommendations ?? []}/>)
|
||||
},
|
||||
{
|
||||
id: 'recommending-you',
|
||||
title: 'Recommending you',
|
||||
contents: (<IncomingRecommendations />)
|
||||
}
|
||||
];
|
||||
|
||||
const groupDescription = (
|
||||
<>Share favorite sites with your audience after they subscribe. {(pagination && pagination.total && pagination.total > 0) && <Link href={recommendationsURL} target='_blank'>Preview</Link>}</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingGroup
|
||||
customButtons={buttons}
|
||||
|
@ -12,9 +12,10 @@ import React, {useEffect, useState} from 'react';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import ThemeInstalledModal from './theme/ThemeInstalledModal';
|
||||
import ThemePreview from './theme/ThemePreview';
|
||||
import handleError from '../../../utils/api/handleError';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
|
||||
import {InstalledTheme, Theme, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
|
||||
import {InstalledTheme, Theme, ThemesInstallResponseType, useBrowseThemes, useInstallTheme, useUploadTheme} from '../../../api/themes';
|
||||
import {OfficialTheme} from '../../providers/ServiceProvider';
|
||||
|
||||
interface ThemeToolbarProps {
|
||||
@ -71,7 +72,18 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||
file: File;
|
||||
onActivate?: () => void
|
||||
}) => {
|
||||
const data = await uploadTheme({file});
|
||||
let data: ThemesInstallResponseType | undefined;
|
||||
|
||||
try {
|
||||
data = await uploadTheme({file});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedTheme = data.themes[0];
|
||||
|
||||
let title = 'Upload successful';
|
||||
@ -247,8 +259,18 @@ const ChangeThemeModal = () => {
|
||||
prompt = <>By clicking below, <strong>{selectedTheme.name}</strong> will automatically be activated as the theme for your site.</>;
|
||||
} else {
|
||||
setInstalling(true);
|
||||
const data = await installTheme(selectedTheme.ref);
|
||||
setInstalling(false);
|
||||
let data: ThemesInstallResponseType | undefined;
|
||||
try {
|
||||
data = await installTheme(selectedTheme.ref);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newlyInstalledTheme = data.themes[0];
|
||||
|
||||
|
@ -6,6 +6,7 @@ import React, {useRef, useState} from 'react';
|
||||
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||
import UnsplashSearchModal from '../../../../utils/unsplash/UnsplashSearchModal';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
|
||||
import {SettingValue, getSettingValues} from '../../../../api/settings';
|
||||
import {debounce} from '../../../../utils/debounce';
|
||||
@ -25,7 +26,6 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [siteDescription, setSiteDescription] = useState(values.description);
|
||||
const {settings} = useGlobalData();
|
||||
const [pintura] = getSettingValues<boolean>(settings, ['pintura']);
|
||||
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
|
||||
const [pinturaJsUrl] = getSettingValues<string>(settings, ['pintura_js_url']);
|
||||
const [pinturaCssUrl] = getSettingValues<string>(settings, ['pintura_css_url']);
|
||||
@ -38,18 +38,13 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
}, 500)
|
||||
);
|
||||
|
||||
const pinturaEnabled = Boolean(pintura) && Boolean(pinturaJsUrl) && Boolean(pinturaCssUrl);
|
||||
|
||||
const editor = usePinturaEditor(
|
||||
{config: {
|
||||
jsUrl: pinturaJsUrl || '',
|
||||
cssUrl: pinturaCssUrl || ''
|
||||
},
|
||||
disabled: !pinturaEnabled}
|
||||
}}
|
||||
);
|
||||
|
||||
// check if pintura !false and pintura_js_url and pintura_css_url are not '' or null or undefined
|
||||
|
||||
return (
|
||||
<div className='mt-7'>
|
||||
<SettingGroupContent>
|
||||
@ -91,7 +86,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
width={values.icon ? '66px' : '150px'}
|
||||
onDelete={() => updateSetting('icon', null)}
|
||||
onUpload={async (file) => {
|
||||
updateSetting('icon', getImageUrl(await uploadImage({file})));
|
||||
try {
|
||||
updateSetting('icon', getImageUrl(await uploadImage({file})));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload icon
|
||||
@ -109,7 +108,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
imageURL={values.logo || ''}
|
||||
onDelete={() => updateSetting('logo', null)}
|
||||
onUpload={async (file) => {
|
||||
updateSetting('logo', getImageUrl(await uploadImage({file})));
|
||||
try {
|
||||
updateSetting('logo', getImageUrl(await uploadImage({file})));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload logo
|
||||
@ -126,11 +129,15 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
openUnsplash={() => setShowUnsplash(true)}
|
||||
pintura={
|
||||
{
|
||||
isEnabled: pinturaEnabled,
|
||||
isEnabled: editor.isEnabled,
|
||||
openEditor: async () => editor.openEditor({
|
||||
image: values.coverImage || '',
|
||||
handleSave: async (file:File) => {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
try {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -139,7 +146,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
||||
unsplashEnabled={true}
|
||||
onDelete={() => updateSetting('cover_image', null)}
|
||||
onUpload={async (file) => {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
try {
|
||||
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Upload cover
|
||||
|
@ -7,6 +7,7 @@ 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 handleError from '../../../../utils/api/handleError';
|
||||
import {CustomThemeSetting} from '../../../../api/customThemeSettings';
|
||||
import {getImageUrl, useUploadImage} from '../../../../api/images';
|
||||
import {humanizeSettingKey} from '../../../../api/settings';
|
||||
@ -18,8 +19,12 @@ const ThemeSetting: React.FC<{
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
setSetting(imageUrl);
|
||||
try {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
setSetting(imageUrl);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
switch (setting.type) {
|
||||
@ -47,9 +52,9 @@ const ThemeSetting: React.FC<{
|
||||
<Select
|
||||
hint={setting.description}
|
||||
options={setting.options.map(option => ({label: option, value: option}))}
|
||||
selectedOption={setting.value}
|
||||
selectedOption={{label: setting.value, value: setting.value}}
|
||||
title={humanizeSettingKey(setting.key)}
|
||||
onSelect={value => setSetting(value || null)}
|
||||
onSelect={option => setSetting(option?.value || null)}
|
||||
/>
|
||||
);
|
||||
case 'color':
|
||||
|
@ -48,8 +48,15 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
throw new AlreadyExistsError('A recommendation with this URL already exists.');
|
||||
}
|
||||
|
||||
// Check if it s a Ghost site or not
|
||||
let externalGhostSite = validatedUrl.protocol === 'https:' ? (await queryExternalGhostSite('https://' + validatedUrl.host)) : null;
|
||||
// Check if it's a Ghost site or not:
|
||||
// 1. Check the full path first. This is the most common use case, and also helps to cover Ghost sites that are hosted on a subdirectory
|
||||
// 2. If needed, check the origin. This helps to cover cases where the recommendation URL is a subpage or a post URL of the Ghost site
|
||||
let externalGhostSite = null;
|
||||
externalGhostSite = await queryExternalGhostSite(validatedUrl.toString());
|
||||
|
||||
if (!externalGhostSite && validatedUrl.pathname !== '' && validatedUrl.pathname !== '/') {
|
||||
externalGhostSite = await queryExternalGhostSite(validatedUrl.origin);
|
||||
}
|
||||
|
||||
// Use the hostname as fallback title
|
||||
const defaultTitle = validatedUrl.hostname.replace('www.', '');
|
||||
|
@ -3,6 +3,7 @@ import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import RecommendationReasonForm from './RecommendationReasonForm';
|
||||
import handleError from '../../../../utils/api/handleError';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';
|
||||
@ -31,6 +32,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
|
||||
});
|
||||
updateRoute('recommendations');
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!formState.title) {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user