mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Wired up AdminX newsletter list and editing (#17556)
refs https://github.com/TryGhost/Product/issues/3601
This commit is contained in:
parent
4e0f52a7cb
commit
d42200d252
9 changed files with 411 additions and 229 deletions
|
@ -1,4 +1,4 @@
|
|||
import React, {useId} from 'react';
|
||||
import React, { useId } from 'react';
|
||||
|
||||
import Heading from '../Heading';
|
||||
import Hint from '../Hint';
|
||||
|
@ -7,6 +7,7 @@ import clsx from 'clsx';
|
|||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SelectOptionGroup {
|
||||
|
@ -97,7 +98,7 @@ const Select: React.FC<SelectProps> = ({
|
|||
{option.options.map(child => (
|
||||
<option
|
||||
key={child.value}
|
||||
className={optionClasses}
|
||||
className={clsx(optionClasses, child.className)}
|
||||
value={child.value}
|
||||
>
|
||||
{child.label}
|
||||
|
@ -106,7 +107,7 @@ const Select: React.FC<SelectProps> = ({
|
|||
</optgroup> :
|
||||
<option
|
||||
key={option.value}
|
||||
className={optionClasses}
|
||||
className={clsx(optionClasses, option.className)}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import Button from '../../../admin-x-ds/global/Button';
|
||||
import NewslettersList from './newsletters/NewslettersList';
|
||||
import React, {useState} from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import useRouting from '../../../hooks/useRouting';
|
||||
import { useBrowseNewsletters } from '../../../utils/api/newsletters';
|
||||
|
||||
const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
@ -11,6 +12,7 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
updateRoute('newsletters/add');
|
||||
};
|
||||
const [selectedTab, setSelectedTab] = useState('active-newsletters');
|
||||
const {data: {newsletters} = {}} = useBrowseNewsletters();
|
||||
|
||||
const buttons = (
|
||||
<Button color='green' label='Add newsletter' link={true} onClick={() => {
|
||||
|
@ -22,12 +24,12 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
{
|
||||
id: 'active-newsletters',
|
||||
title: 'Active',
|
||||
contents: (<NewslettersList tab='active-newsletters' />)
|
||||
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status === 'active') || []} tab='active-newsletters' />)
|
||||
},
|
||||
{
|
||||
id: 'archived-newsletters',
|
||||
title: 'Archived',
|
||||
contents: (<NewslettersList tab='archive-newsletters' />)
|
||||
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status !== 'active') || []} tab='archive-newsletters' />)
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -44,4 +46,4 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Newsletters;
|
||||
export default Newsletters;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||
|
||||
import ButtonGroup from '../../../../admin-x-ds/global/ButtonGroup';
|
||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||
|
@ -7,36 +7,43 @@ import Hint from '../../../../admin-x-ds/global/Hint';
|
|||
import Icon from '../../../../admin-x-ds/global/Icon';
|
||||
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
||||
import NewsletterPreview from './NewsletterPreview';
|
||||
import React, {useState} from 'react';
|
||||
import Select, {SelectOption} from '../../../../admin-x-ds/global/form/Select';
|
||||
import React, { useState } from 'react';
|
||||
import Select, { SelectOption } from '../../../../admin-x-ds/global/form/Select';
|
||||
import StickyFooter from '../../../../admin-x-ds/global/StickyFooter';
|
||||
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
|
||||
import TabView, { Tab } from '../../../../admin-x-ds/global/TabView';
|
||||
import 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 {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
|
||||
import useForm from '../../../../hooks/useForm';
|
||||
import useSettings from '../../../../hooks/useSettings';
|
||||
import { Newsletter } from '../../../../types/api';
|
||||
import { PreviewModalContent } from '../../../../admin-x-ds/global/modal/PreviewModal';
|
||||
import { fullEmailAddress, getSettingValues } from '../../../../utils/helpers';
|
||||
import { getImageUrl, useUploadImage } from '../../../../utils/api/images';
|
||||
import { useEditNewsletter } from '../../../../utils/api/newsletters';
|
||||
|
||||
// TODO: do we need this interface?
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface NewsletterDetailModalProps {
|
||||
|
||||
newsletter: Newsletter
|
||||
}
|
||||
|
||||
// const REPLY_TO_EMAILS = [
|
||||
// {label: 'Newsletter address (noreply@localhost)', value: 'noreply@localhost'},
|
||||
// {label: 'Support address (noreply@localhost)', value: 'noreply@localhost'}
|
||||
// ];
|
||||
|
||||
const selectOptions: SelectOption[] = [
|
||||
{value: 'option-1', label: 'Elegant serif'},
|
||||
{value: 'option-2', label: 'Modern sans-serif'}
|
||||
];
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const Sidebar: React.FC<{
|
||||
newsletter: Newsletter;
|
||||
updateNewsletter: (fields: Partial<Newsletter>) => void;
|
||||
}> = ({newsletter, updateNewsletter}) => {
|
||||
const {settings, siteData} = useSettings();
|
||||
const [membersSupportAddress] = getSettingValues<string>(settings, ['members_support_address']);
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [selectedTab, setSelectedTab] = useState('generalSettings');
|
||||
const values = {
|
||||
HEADER: ''
|
||||
};
|
||||
|
||||
const replyToEmails = [
|
||||
{label: `Newsletter address (${fullEmailAddress(newsletter.sender_email || 'noreply', siteData)})`, value: 'newsletter'},
|
||||
{label: `Support address (${fullEmailAddress(membersSupportAddress || 'noreply', siteData)})`, value: 'support'}
|
||||
];
|
||||
|
||||
const fontOptions: SelectOption[] = [
|
||||
{value: 'serif', label: 'Elegant serif', className: 'font-serif'},
|
||||
{value: 'sans_serif', label: 'Clean sans-serif'}
|
||||
];
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
|
@ -44,21 +51,21 @@ const Sidebar: React.FC = () => {
|
|||
title: 'General',
|
||||
contents: <Form gap="sm" marginTop>
|
||||
<Heading className="mt-5" level={5}>Name and description</Heading>
|
||||
<TextField placeholder="Weekly Roundup" title="Name"></TextField>
|
||||
<TextArea clearBg={false} rows={2} title="Description"></TextArea>
|
||||
<TextField placeholder="Weekly Roundup" title="Name" value={newsletter.name || ''} onChange={e => updateNewsletter({name: e.target.value})} />
|
||||
<TextArea clearBg={false} rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
|
||||
|
||||
<Heading className="mt-5" level={5}>Email addresses</Heading>
|
||||
<TextField placeholder="Ghost" title="Sender name"></TextField>
|
||||
<TextField placeholder="noreply@localhost" title="Sender email address"></TextField>
|
||||
<Select options={selectOptions} title="Reply-to email" onSelect={(value: string) => {
|
||||
alert(value);
|
||||
}}/>
|
||||
<TextField placeholder="Ghost" title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
|
||||
<TextField placeholder="noreply@localhost" title="Sender email address" value={newsletter.sender_email || ''} onChange={e => updateNewsletter({sender_email: e.target.value})} />
|
||||
<Select options={replyToEmails} selectedOption={newsletter.sender_reply_to} title="Reply-to email" onSelect={value => updateNewsletter({sender_reply_to: value})}/>
|
||||
|
||||
<Heading className="mt-5" level={5}>Member settings</Heading>
|
||||
<Toggle
|
||||
checked={newsletter.subscribe_on_signup}
|
||||
direction='rtl'
|
||||
label='Subscribe new members on signup'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({subscribe_on_signup: e.target.checked})}
|
||||
/>
|
||||
</Form>
|
||||
},
|
||||
|
@ -74,14 +81,15 @@ const Sidebar: React.FC = () => {
|
|||
<div className='flex-column flex gap-1'>
|
||||
<ImageUpload
|
||||
deleteButtonClassName='!top-1 !right-1'
|
||||
height={values.HEADER ? '66px' : '64px'}
|
||||
height={newsletter.header_image ? '66px' : '64px'}
|
||||
id='logo'
|
||||
imageURL={values.HEADER || ''}
|
||||
imageURL={newsletter.header_image || undefined}
|
||||
onDelete={() => {
|
||||
alert();
|
||||
updateNewsletter({header_image: null});
|
||||
}}
|
||||
onUpload={() => {
|
||||
alert();
|
||||
onUpload={async (file) => {
|
||||
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||
updateNewsletter({header_image: imageUrl});
|
||||
}}
|
||||
>
|
||||
Upload header image
|
||||
|
@ -90,30 +98,35 @@ const Sidebar: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={newsletter.show_header_title}
|
||||
direction="rtl"
|
||||
label='Publication title'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_header_title: e.target.checked})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_header_name}
|
||||
direction="rtl"
|
||||
label='Newsletter name'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_header_name: e.target.checked})}
|
||||
/>
|
||||
|
||||
<Heading className="mt-5" level={5}>Body</Heading>
|
||||
<Toggle
|
||||
checked={newsletter.show_post_title_section}
|
||||
direction="rtl"
|
||||
label='Title'
|
||||
label='Post title'
|
||||
labelStyle='heading'
|
||||
onChange={e => updateNewsletter({show_post_title_section: e.target.checked})}
|
||||
/>
|
||||
<Select containerClassName="-mt-[16px]" options={selectOptions} onSelect={(value: string) => {
|
||||
alert(value);
|
||||
}}/>
|
||||
<div className="flex items-end">
|
||||
<div className="mt-[-16px] flex items-end">
|
||||
<div className="w-full pr-4">
|
||||
<Select containerClassName="" options={selectOptions} title="Body style" onSelect={(value: string) => {
|
||||
alert(value);
|
||||
}}/>
|
||||
<Select
|
||||
options={fontOptions}
|
||||
selectedOption={newsletter.title_font_category}
|
||||
onSelect={value => updateNewsletter({title_font_category: value})}
|
||||
/>
|
||||
</div>
|
||||
<ButtonGroup buttons={[
|
||||
{
|
||||
|
@ -122,7 +135,9 @@ const Sidebar: React.FC = () => {
|
|||
hideLabel: true,
|
||||
link: false,
|
||||
size: 'sm',
|
||||
iconColorClass: 'text-grey-500'
|
||||
color: newsletter.title_alignment === 'left' ? 'green' : 'clear',
|
||||
iconColorClass: newsletter.title_alignment === 'left' ? 'text-grey-900' : 'text-grey-500',
|
||||
onClick: () => updateNewsletter({title_alignment: 'left'})
|
||||
},
|
||||
{
|
||||
icon: 'align-center',
|
||||
|
@ -130,40 +145,65 @@ const Sidebar: React.FC = () => {
|
|||
hideLabel: true,
|
||||
link: false,
|
||||
size: 'sm',
|
||||
iconColorClass: 'text-grey-900'
|
||||
color: newsletter.title_alignment === 'center' ? 'green' : 'clear',
|
||||
iconColorClass: newsletter.title_alignment === 'center' ? 'text-grey-900' : 'text-grey-500',
|
||||
onClick: () => updateNewsletter({title_alignment: 'center'})
|
||||
}
|
||||
]}
|
||||
className="mb-1 !gap-0"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
options={fontOptions}
|
||||
selectedOption={newsletter.body_font_category}
|
||||
title='Body style'
|
||||
onSelect={value => updateNewsletter({body_font_category: value})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_feature_image}
|
||||
direction="rtl"
|
||||
label='Feature image'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_feature_image: e.target.checked})}
|
||||
/>
|
||||
|
||||
<Heading className="mt-5" level={5}>Footer</Heading>
|
||||
<Toggle
|
||||
checked={newsletter.feedback_enabled}
|
||||
direction="rtl"
|
||||
label='Ask your readers for feedback'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({feedback_enabled: e.target.checked})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_comment_cta}
|
||||
direction="rtl"
|
||||
label='Add a link to your comments'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_comment_cta: e.target.checked})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_latest_posts}
|
||||
direction="rtl"
|
||||
label='Share your latest posts'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_latest_posts: e.target.checked})}
|
||||
/>
|
||||
<Toggle
|
||||
checked={newsletter.show_subscription_details}
|
||||
direction="rtl"
|
||||
label='Show subscription details'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_subscription_details: e.target.checked})}
|
||||
/>
|
||||
<TextArea
|
||||
clearBg={false}
|
||||
hint="Any extra information or legal text"
|
||||
rows={2}
|
||||
title="Email footer"
|
||||
value={newsletter.footer_content || ''}
|
||||
onChange={e => updateNewsletter({footer_content: e.target.value})}
|
||||
/>
|
||||
<TextArea clearBg={false} hint="Any extra information or legal text" rows={2} title="Email footer"></TextArea>
|
||||
</Form>
|
||||
}
|
||||
];
|
||||
|
@ -184,11 +224,12 @@ const Sidebar: React.FC = () => {
|
|||
</span>
|
||||
<Form marginBottom={false}>
|
||||
<Toggle
|
||||
checked={true}
|
||||
checked={newsletter.show_badge}
|
||||
direction='rtl'
|
||||
hint='Show you’re a part of the indie publishing movement with a small badge in the footer'
|
||||
label='Promote independent publishing'
|
||||
labelStyle='value'
|
||||
onChange={e => updateNewsletter({show_badge: e.target.checked})}
|
||||
/>
|
||||
</Form>
|
||||
</div>
|
||||
|
@ -197,14 +238,28 @@ const Sidebar: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const preview = <NewsletterPreview/>;
|
||||
const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter}) => {
|
||||
const modal = useModal();
|
||||
const {mutateAsync: editNewsletter} = useEditNewsletter();
|
||||
|
||||
const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = () => {
|
||||
const sidebar = <Sidebar/>;
|
||||
const {formState, updateForm, handleSave} = useForm({
|
||||
initialState: newsletter,
|
||||
onSave: async () => {
|
||||
await editNewsletter(formState);
|
||||
modal.remove();
|
||||
}
|
||||
})
|
||||
|
||||
const updateNewsletter = (fields: Partial<Newsletter>) => {
|
||||
updateForm(state => ({...state, ...fields}));
|
||||
};
|
||||
|
||||
const preview = <NewsletterPreview newsletter={formState} />;
|
||||
const sidebar = <Sidebar newsletter={formState} updateNewsletter={updateNewsletter} />;
|
||||
|
||||
return <PreviewModalContent
|
||||
deviceSelector={false}
|
||||
okLabel={'Save & close'}
|
||||
okLabel='Save & close'
|
||||
preview={preview}
|
||||
previewBgColor={'grey'}
|
||||
previewToolbar={false}
|
||||
|
@ -212,6 +267,7 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = () => {
|
|||
sidebarPadding={false}
|
||||
testId='newsletter-modal'
|
||||
title='Newsletter'
|
||||
onOk={handleSave}
|
||||
/>;
|
||||
};
|
||||
|
||||
|
|
|
@ -4,134 +4,205 @@ import LatestPosts1 from '../../../../assets/images/latest-posts-1.png';
|
|||
import LatestPosts2 from '../../../../assets/images/latest-posts-2.png';
|
||||
import LatestPosts3 from '../../../../assets/images/latest-posts-3.png';
|
||||
import React from 'react';
|
||||
import {ReactComponent as GhostOrb} from '../../../../admin-x-ds/assets/images/ghost-orb.svg';
|
||||
import clsx from 'clsx';
|
||||
import useSettings from '../../../../hooks/useSettings';
|
||||
import { ReactComponent as GhostOrb } from '../../../../admin-x-ds/assets/images/ghost-orb.svg';
|
||||
import { Newsletter } from '../../../../types/api';
|
||||
import { fullEmailAddress, getSettingValues } from '../../../../utils/helpers';
|
||||
import { useGlobalData } from '../../../providers/DataProvider';
|
||||
|
||||
const NewsletterPreview: React.FC<{newsletter: Newsletter}> = ({newsletter}) => {
|
||||
const {currentUser} = useGlobalData();
|
||||
const {settings, siteData, config} = useSettings();
|
||||
const [title, icon, commentsEnabled] = getSettingValues<string>(settings, ['title', 'icon', 'comments_enabled']);
|
||||
|
||||
let headerTitle: string | null = null;
|
||||
if (newsletter.show_header_title) {
|
||||
headerTitle = title || null;
|
||||
} else if (newsletter.show_header_name) {
|
||||
headerTitle = newsletter.name;
|
||||
}
|
||||
|
||||
const headerSubtitle = (newsletter.show_header_title && newsletter.show_header_name) && newsletter.name;
|
||||
|
||||
const showHeader = (newsletter.show_header_icon && icon) || headerTitle;
|
||||
|
||||
const currentDate = new Date().toLocaleDateString('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const showCommentCta = newsletter.show_comment_cta && commentsEnabled !== 'off';
|
||||
const showFeedback = newsletter.feedback_enabled && config.labs.audienceFeedback
|
||||
|
||||
const NewsletterPreview: React.FC = () => {
|
||||
return (
|
||||
<div className="relative flex grow flex-col">
|
||||
<div className="GIGI absolute inset-0 m-5">
|
||||
<div className="mx-auto my-0 flex h-full w-full max-w-[700px] flex-col overflow-hidden rounded-[4px] text-black shadow-sm">
|
||||
|
||||
{/* Email header */}
|
||||
<div className="flex-column flex min-h-[77px] justify-center rounded-t-sm border-b border-grey-200 bg-grey-100 px-6 text-sm text-grey-700">
|
||||
<p className="leading-normal"><span className="font-semibold text-grey-900">Ghost</span><span> noreply@localhost</span></p>
|
||||
<p className="leading-normal"><span className="font-semibold text-grey-900">{newsletter.sender_name || title}</span><span> {fullEmailAddress(newsletter.sender_email || 'noreply', siteData)}</span></p>
|
||||
<p className="leading-normal"><span className="font-semibold text-grey-900">To:</span> Jamie Larson jamie@example.com</p>
|
||||
</div>
|
||||
|
||||
{/* Email content */}
|
||||
<div className="overflow-y-auto bg-white px-20 text-sm">
|
||||
<div>
|
||||
<img alt="" className="mt-6 block" src="https://images.unsplash.com/photo-1681898190846-0a133b5b7fe0?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2340&q=80"/>
|
||||
</div>
|
||||
<div className="border-b border-grey-200 py-12">
|
||||
<h4 className="mb-1 text-center text-[1.6rem] font-bold uppercase leading-tight tracking-tight text-grey-900">My cool publication</h4>
|
||||
<h5 className="mb-1 text-center text-[1.4rem] font-normal leading-tight text-grey-600">My cute newsletter</h5>
|
||||
</div>
|
||||
<div className="flex flex-col items-center pb-10 pt-12">
|
||||
<h2 className="pb-4 text-center text-5xl font-bold leading-supertight text-black">Your email newsletter</h2>
|
||||
<div className="flex w-full flex-col justify-between text-center text-sm leading-none tracking-[0.1px] text-grey-600">
|
||||
<p className="pb-2">By Djordje Vlaisavljevic<span className="before:pl-0.5 before:pr-1 before:content-['•']">17 Jul 2023</span><span className="before:pl-0.5 before:pr-1 before:content-['•']"><Icon className="mt-[-2px] inline-block" colorClass="text-grey-600" name="comment" size="sm"/></span></p>
|
||||
<p className="pb-2 underline"><span>View in browser</span></p>
|
||||
{newsletter.header_image && (
|
||||
<div>
|
||||
<img alt="" className="mt-6 block" src={newsletter.header_image} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showHeader && (
|
||||
<div className="border-b border-grey-200 py-12">
|
||||
{(newsletter.show_header_icon && icon) && <img alt="" className="mx-auto mb-2 h-10 w-10" role="presentation" src={icon} />}
|
||||
{headerTitle && <h4 className="mb-1 text-center text-[1.6rem] font-bold uppercase leading-tight tracking-tight text-grey-900">{headerTitle}</h4>}
|
||||
{headerSubtitle && <h5 className="mb-1 text-center text-[1.4rem] font-normal leading-tight text-grey-600">{headerSubtitle}</h5>}
|
||||
</div>
|
||||
)}
|
||||
{newsletter.show_post_title_section && (
|
||||
<div className={clsx('flex flex-col pb-10 pt-12', newsletter.title_alignment === 'center' ? 'items-center' : 'items-start')}>
|
||||
<h2 className={clsx(
|
||||
'pb-4 text-5xl font-bold leading-supertight text-black',
|
||||
newsletter.title_font_category === 'serif' && 'font-serif',
|
||||
newsletter.title_alignment === 'center' ? 'text-center' : 'text-left'
|
||||
)}>
|
||||
Your email newsletter
|
||||
</h2>
|
||||
<div className={clsx(
|
||||
'flex w-full justify-between text-center text-sm leading-none tracking-[0.1px] text-grey-600',
|
||||
newsletter.title_alignment === 'center' ? 'flex-col' : 'flex-row'
|
||||
)}>
|
||||
<p className="pb-2">
|
||||
By {currentUser.name || currentUser.email}
|
||||
<span className="before:pl-0.5 before:pr-1 before:content-['•']">{currentDate}</span>
|
||||
{showCommentCta && (
|
||||
<span className="before:pl-0.5 before:pr-1 before:content-['•']">
|
||||
<Icon className="mt-[-2px] inline-block" colorClass="text-grey-600" name="comment" size="sm"/>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="pb-2 underline"><span>View in browser</span></p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature image */}
|
||||
<div className="h-[300px] w-full max-w-[600px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Feature" className='min-h-full min-w-full shrink-0' src={CoverImage} />
|
||||
</div>
|
||||
<div className="mt-1 w-full max-w-[600px] pb-[30px] text-center text-[1.3rem] text-grey-600">Feature image caption</div>
|
||||
{newsletter.show_feature_image && (
|
||||
<>
|
||||
<div className="h-[300px] w-full max-w-[600px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Feature" className='min-h-full min-w-full shrink-0' src={CoverImage} />
|
||||
</div>
|
||||
<div className="mt-1 w-full max-w-[600px] pb-[30px] text-center text-[1.3rem] text-grey-600">Feature image caption</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="max-w-[600px] border-b border-grey-200 py-5 text-[1.6rem] leading-[1.7] text-black">
|
||||
<div className={clsx('max-w-[600px] border-b border-grey-200 py-5 text-[1.6rem] leading-[1.7] text-black', newsletter.body_font_category === 'serif' && 'font-serif')}>
|
||||
<p className="mb-5">This is what your content will look like when you send one of your posts as an email newsletter to your subscribers.</p>
|
||||
<p className="mb-5">Over there on the left you'll see some settings that allow you to customize the look and feel of this template to make it perfectly suited to your brand. Email templates are exceptionally finnicky to make, but we've spent a long time optimising this one to make it work beautifully across devices, email clients and content types.</p>
|
||||
<p className="mb-5">So, you can trust that every email you send with Ghost will look great and work well. Just like the rest of your site.</p>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="grid gap-5 border-b border-grey-200 px-6 py-5">
|
||||
<div className="flex justify-center gap-3">
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="thumbs-up" size="md" />
|
||||
<span>More like this</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="thumbs-down" />
|
||||
<span>Less like this</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="comment" />
|
||||
<span>Comment</span>
|
||||
</span>
|
||||
</button>
|
||||
{(showFeedback || showCommentCta) && (
|
||||
<div className="grid gap-5 border-b border-grey-200 px-6 py-5">
|
||||
<div className="flex justify-center gap-3">
|
||||
{showFeedback && (
|
||||
<>
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="thumbs-up" size="md" />
|
||||
<span>More like this</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="thumbs-down" />
|
||||
<span>Less like this</span>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showCommentCta && (
|
||||
<button className="pointer-events-none cursor-default whitespace-nowrap rounded-[2.2rem] bg-transparent font-semibold" type="button">
|
||||
<span className="inline-flex items-center gap-2 px-[18px] py-[7px]">
|
||||
<Icon name="comment" />
|
||||
<span>Comment</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest posts */}
|
||||
<div className="border-b border-grey-200 py-6">
|
||||
<h3 className="mb-4 mt-2 pb-1 text-[1.2rem] font-semibold uppercase tracking-wide">Keep reading</h3>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">The three latest posts published on your site</h4>
|
||||
<p className="m-0 text-base text-grey-600">Posts sent as an email only will never be shown here.</p>
|
||||
{newsletter.show_latest_posts && (
|
||||
<div className="border-b border-grey-200 py-6">
|
||||
<h3 className="mb-4 mt-2 pb-1 text-[1.2rem] font-semibold uppercase tracking-wide">Keep reading</h3>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">The three latest posts published on your site</h4>
|
||||
<p className="m-0 text-base text-grey-600">Posts sent as an email only will never be shown here.</p>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts1} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts1} />
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">Displayed at the bottom of each newsletter</h4>
|
||||
<p className="m-0 text-base text-grey-600">Giving your readers one more place to discover your stories.</p>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">To keep your work front and center</h4>
|
||||
<p className="m-0 text-base text-grey-600">Making sure that your audience stays engaged.</p>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">Displayed at the bottom of each newsletter</h4>
|
||||
<p className="m-0 text-base text-grey-600">Giving your readers one more place to discover your stories.</p>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts2} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 py-2">
|
||||
<div>
|
||||
<h4 className="mb-1 mt-0.5 text-[1.9rem]">To keep your work front and center</h4>
|
||||
<p className="m-0 text-base text-grey-600">Making sure that your audience stays engaged.</p>
|
||||
</div>
|
||||
<div className="aspect-square h-auto w-full max-w-[100px] bg-grey-200 bg-cover bg-no-repeat">
|
||||
<img alt="Latest post" src={LatestPosts3} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription details */}
|
||||
<div className="border-b border-grey-200 py-8">
|
||||
<h4 className="mb-3 text-[1.2rem] uppercase tracking-wide">Subscription details</h4>
|
||||
<p className="m-0 mb-4 text-base">You are receiving this because you are a paid subscriber to The Local Host. Your subscription will renew on 17 Jul 2024.</p>
|
||||
<div className="flex">
|
||||
<div className="shrink-0 text-base">
|
||||
<p>Name: Jamie Larson</p>
|
||||
<p>Email: jamie@example.com</p>
|
||||
<p>Member since: 17 July 2023</p>
|
||||
{newsletter.show_subscription_details && (
|
||||
<div className="border-b border-grey-200 py-8">
|
||||
<h4 className="mb-3 text-[1.2rem] uppercase tracking-wide">Subscription details</h4>
|
||||
<p className="m-0 mb-4 text-base">You are receiving this because you are a paid subscriber to The Local Host. Your subscription will renew on 17 Jul 2024.</p>
|
||||
<div className="flex">
|
||||
<div className="shrink-0 text-base">
|
||||
<p>Name: Jamie Larson</p>
|
||||
<p>Email: jamie@example.com</p>
|
||||
<p>Member since: 17 July 2023</p>
|
||||
</div>
|
||||
<span className="w-full self-end whitespace-nowrap text-right text-base font-semibold text-pink">Manage subscription →</span>
|
||||
</div>
|
||||
<span className="w-full self-end whitespace-nowrap text-right text-base font-semibold text-pink">Manage subscription →</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col items-center pt-10">
|
||||
<div className="text break-words px-8 py-3 text-center text-[1.3rem] leading-base text-grey-600">This is custom email footer text.</div>
|
||||
<div dangerouslySetInnerHTML={{__html: newsletter.footer_content || ''}} className="text break-words px-8 py-3 text-center text-[1.3rem] leading-base text-grey-600" />
|
||||
|
||||
<div className="px-8 pb-14 pt-3 text-center text-[1.3rem] text-grey-600">
|
||||
<span>Ghost © 2023 — </span>
|
||||
<span>{title} © {currentYear} — </span>
|
||||
<span className="pointer-events-none cursor-auto underline">Unsubscribe</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center pb-[40px] pt-[10px]">
|
||||
<a className="pointer-events-none inline-flex cursor-auto items-center px-2 py-1 text-[1.25rem] font-semibold tracking-tight text-grey-900" href="https://ghost.org">
|
||||
<GhostOrb className="mr-[6px] h-4 w-4"/>
|
||||
<span>Powered by Ghost</span>
|
||||
</a>
|
||||
</div>
|
||||
{newsletter.show_badge && (
|
||||
<div className="flex flex-col items-center pb-[40px] pt-[10px]">
|
||||
<a className="pointer-events-none inline-flex cursor-auto items-center px-2 py-1 text-[1.25rem] font-semibold tracking-tight text-grey-900" href="https://ghost.org">
|
||||
<GhostOrb className="mr-[6px] h-4 w-4"/>
|
||||
<span>Powered by Ghost</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
import Button from '../../../../admin-x-ds/global/Button';
|
||||
// import List from '../../../../admin-x-ds/global/List';
|
||||
// import ListItem from '../../../../admin-x-ds/global/ListItem';
|
||||
import NewsletterDetailModal from './NewsletterDetailModal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import Table from '../../../../admin-x-ds/global/Table';
|
||||
import TableCell from '../../../../admin-x-ds/global/TableCell';
|
||||
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||
import { Newsletter } from '../../../../types/api';
|
||||
|
||||
interface NewslettersListProps {
|
||||
tab?: string;
|
||||
newsletters: Newsletter[]
|
||||
}
|
||||
|
||||
// We should create a NewsletterItem component based on TableRow and then loop through newsletters
|
||||
|
@ -60,7 +60,8 @@ interface NewslettersListProps {
|
|||
// };
|
||||
|
||||
const NewslettersList: React.FC<NewslettersListProps> = ({
|
||||
tab
|
||||
tab,
|
||||
newsletters
|
||||
}) => {
|
||||
const action = tab === 'active-newsletters' ? (
|
||||
<Button color='green' label='Archive' link />
|
||||
|
@ -70,81 +71,35 @@ const NewslettersList: React.FC<NewslettersListProps> = ({
|
|||
|
||||
return (
|
||||
<Table>
|
||||
<TableRow
|
||||
action={action}
|
||||
hideActions
|
||||
onClick={() => {
|
||||
NiceModal.show(NewsletterDetailModal);
|
||||
}}>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span className='font-medium'>Amazing newsletter</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>This one is pretty good</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>259</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>14</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Emails sent</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
action={action}
|
||||
hideActions
|
||||
onClick={() => {
|
||||
NiceModal.show(NewsletterDetailModal);
|
||||
}}>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span className='line-clamp-1 font-medium'>Crappy newsletter</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>This one is just spam</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>145</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>754</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Emails sent</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{newsletters.map(newsletter => (
|
||||
<TableRow
|
||||
action={action}
|
||||
hideActions
|
||||
onClick={() => {
|
||||
NiceModal.show(NewsletterDetailModal, {newsletter});
|
||||
}}>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span className='font-medium'>{newsletter.name}</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>{newsletter.description || 'No description'}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>{newsletter.count?.active_members}</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`flex grow flex-col`}>
|
||||
<span>{newsletter.count?.posts}</span>
|
||||
<span className='whitespace-nowrap text-xs text-grey-700'>Posts sent</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
|
||||
// Newsletter list previously used the List component, can be removed
|
||||
//
|
||||
// <List>
|
||||
// <ListItem
|
||||
// action={action}
|
||||
// detail='This one is pretty good'
|
||||
// title='Amazing newsletter'
|
||||
// hideActions
|
||||
// onClick={() => {
|
||||
// NiceModal.show(NewsletterDetailModal);
|
||||
// }}
|
||||
// />
|
||||
// <ListItem
|
||||
// action={action}
|
||||
// detail='This one is just spam'
|
||||
// title='Awful newsletter'
|
||||
// hideActions
|
||||
// onClick={() => {
|
||||
// NiceModal.show(NewsletterDetailModal);
|
||||
// }}
|
||||
// />
|
||||
// </List>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewslettersList;
|
||||
export default NewslettersList;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
|
||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||
import HtmlField, { EditorConfig } from '../../../../admin-x-ds/global/form/HtmlField';
|
||||
import HtmlField from '../../../../admin-x-ds/global/form/HtmlField';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||
import { CheckboxProps } from '../../../../admin-x-ds/global/form/Checkbox';
|
||||
|
@ -119,7 +119,7 @@ const SignupOptions: React.FC<{
|
|||
)}
|
||||
|
||||
<HtmlField
|
||||
config={config as EditorConfig}
|
||||
config={config}
|
||||
error={Boolean(errors.portal_signup_terms_html)}
|
||||
hint={errors.portal_signup_terms_html || <>Recommended: <strong>115</strong> characters. You've used <strong className="text-green">{signupTermsLength}</strong></>}
|
||||
nodes='MINIMAL_NODES'
|
||||
|
|
|
@ -9,7 +9,19 @@ export type Setting = {
|
|||
value: SettingValue;
|
||||
}
|
||||
|
||||
export type Config = JSONObject;
|
||||
export type Config = {
|
||||
version: string;
|
||||
environment: string;
|
||||
editor: {
|
||||
url: string
|
||||
version: string
|
||||
};
|
||||
labs: Record<string, boolean>;
|
||||
stripeDirect: boolean;
|
||||
|
||||
// Config is relatively fluid, so we only type used properties above and still support arbitrary property access when needed
|
||||
[key: string]: JSONValue;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
|
@ -166,3 +178,42 @@ export type ThemeProblem<Level extends string = 'error' | 'warning'> = {
|
|||
level: Level
|
||||
rule: string
|
||||
}
|
||||
|
||||
export type Newsletter = {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
feedback_enabled: boolean;
|
||||
slug: string;
|
||||
sender_name: string | null;
|
||||
sender_email: string | null;
|
||||
sender_reply_to: string;
|
||||
status: string;
|
||||
visibility: string;
|
||||
subscribe_on_signup: boolean;
|
||||
sort_order: number;
|
||||
header_image: string | null;
|
||||
show_header_icon: boolean;
|
||||
show_header_title: boolean;
|
||||
title_font_category: string;
|
||||
title_alignment: string;
|
||||
show_feature_image: boolean;
|
||||
body_font_category: string;
|
||||
footer_content: string | null;
|
||||
show_badge: boolean;
|
||||
show_header_name: boolean;
|
||||
show_post_title_section: boolean;
|
||||
show_comment_cta: boolean;
|
||||
show_subscription_details: boolean;
|
||||
show_latest_posts: boolean;
|
||||
background_color: string;
|
||||
border_color: string | null;
|
||||
title_color: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
count?: {
|
||||
posts?: number;
|
||||
active_members?: number;
|
||||
}
|
||||
}
|
||||
|
|
45
apps/admin-x-settings/src/utils/api/newsletters.ts
Normal file
45
apps/admin-x-settings/src/utils/api/newsletters.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { Meta, createMutation, createQuery } from '../apiRequests';
|
||||
import { Newsletter } from '../../types/api';
|
||||
|
||||
export interface NewslettersResponseType {
|
||||
meta?: Meta
|
||||
newsletters: Newsletter[]
|
||||
}
|
||||
|
||||
const dataType = 'NewslettersResponseType';
|
||||
|
||||
export const useBrowseNewsletters = createQuery<NewslettersResponseType>({
|
||||
dataType,
|
||||
path: '/newsletters/',
|
||||
defaultSearchParams: {include: 'count.active_members,count.posts', limit: 'all'}
|
||||
});
|
||||
|
||||
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter>>({
|
||||
method: 'POST',
|
||||
path: () => '/newsletters/',
|
||||
body: newsletter => ({newsletters: [newsletter]}),
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => ({
|
||||
...(currentData as NewslettersResponseType),
|
||||
newsletters: (currentData as NewslettersResponseType).newsletters.concat(newData.newsletters)
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
export const useEditNewsletter = createMutation<NewslettersResponseType, Newsletter>({
|
||||
method: 'PUT',
|
||||
path: newsletter => `/newsletters/${newsletter.id}/`,
|
||||
body: newsletter => ({newsletters: [newsletter]}),
|
||||
defaultSearchParams: {include: 'count.active_members,count.posts', limit: 'all'},
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (newData, currentData) => ({
|
||||
...(currentData as NewslettersResponseType),
|
||||
newsletters: (currentData as NewslettersResponseType).newsletters.map((newsletter) => {
|
||||
const newNewsletter = newData.newsletters.find(({id}) => id === newsletter.id);
|
||||
return newNewsletter || newsletter;
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
|
@ -47,13 +47,14 @@ export const useFetchApi = () => {
|
|||
|
||||
return async (endpoint: string | URL, options: RequestOptions = {}) => {
|
||||
// By default, we set the Content-Type header to application/json
|
||||
const defaultHeaders = {
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
'app-pragma': 'no-cache',
|
||||
'x-ghost-version': ghostVersion
|
||||
};
|
||||
const headers = options?.headers || {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
if (typeof options.body === 'string') {
|
||||
defaultHeaders['content-type'] = 'application/json';
|
||||
}
|
||||
const headers = options?.headers || {};
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
|
|
Loading…
Add table
Reference in a new issue