565 lines
19 KiB
TypeScript
565 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import React, {
|
|
FC,
|
|
ReactNode,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { AddEditModalProps } from '@gitroom/frontend/components/new-launch/add.edit.modal';
|
|
import clsx from 'clsx';
|
|
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
|
import { PicksSocialsComponent } from '@gitroom/frontend/components/new-launch/picks.socials.component';
|
|
import { EditorWrapper } from '@gitroom/frontend/components/new-launch/editor';
|
|
import { SelectCurrent } from '@gitroom/frontend/components/new-launch/select.current';
|
|
import { ShowAllProviders } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
|
|
import { useExistingData } from '@gitroom/frontend/components/launches/helpers/use.existing.data';
|
|
import { useLaunchStore } from '@gitroom/frontend/components/new-launch/store';
|
|
import { DatePicker } from '@gitroom/frontend/components/launches/helpers/date.picker';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { RepeatComponent } from '@gitroom/frontend/components/launches/repeat.component';
|
|
import { TagsComponent } from '@gitroom/frontend/components/launches/tags.component';
|
|
import { useToaster } from '@gitroom/react/toaster/toaster';
|
|
import { weightedLength } from '@gitroom/helpers/utils/count.length';
|
|
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
|
|
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
|
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
|
|
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
|
import { capitalize } from 'lodash';
|
|
import { SelectCustomer } from '@gitroom/frontend/components/launches/select.customer';
|
|
import { CopilotPopup } from '@copilotkit/react-ui';
|
|
import { DummyCodeComponent } from '@gitroom/frontend/components/new-launch/dummy.code.component';
|
|
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
|
|
import {
|
|
SettingsIcon,
|
|
ChevronDownIcon,
|
|
CloseIcon,
|
|
TrashIcon,
|
|
DropdownArrowSmallIcon,
|
|
} from '@gitroom/frontend/components/ui/icons';
|
|
import { useHasScroll } from '@gitroom/frontend/components/ui/is.scroll.hook';
|
|
|
|
function countCharacters(text: string, type: string): number {
|
|
if (type !== 'x') {
|
|
return text.length;
|
|
}
|
|
return weightedLength(text);
|
|
}
|
|
|
|
export const ManageModal: FC<AddEditModalProps> = (props) => {
|
|
const t = useT();
|
|
const fetch = useFetch();
|
|
const ref = useRef(null);
|
|
const existingData = useExistingData();
|
|
const [loading, setLoading] = useState(false);
|
|
const toaster = useToaster();
|
|
const modal = useModals();
|
|
const [showSettings, setShowSettings] = useState(false);
|
|
|
|
const { addEditSets, mutate, customClose, dummy } = props;
|
|
|
|
const {
|
|
selectedIntegrations,
|
|
hide,
|
|
date,
|
|
setDate,
|
|
repeater,
|
|
setRepeater,
|
|
tags,
|
|
setTags,
|
|
integrations,
|
|
setSelectedIntegrations,
|
|
locked,
|
|
current,
|
|
activateExitButton,
|
|
} = useLaunchStore(
|
|
useShallow((state) => ({
|
|
hide: state.hide,
|
|
date: state.date,
|
|
setDate: state.setDate,
|
|
current: state.current,
|
|
repeater: state.repeater,
|
|
setRepeater: state.setRepeater,
|
|
tags: state.tags,
|
|
setTags: state.setTags,
|
|
selectedIntegrations: state.selectedIntegrations,
|
|
integrations: state.integrations,
|
|
setSelectedIntegrations: state.setSelectedIntegrations,
|
|
locked: state.locked,
|
|
activateExitButton: state.activateExitButton,
|
|
}))
|
|
);
|
|
|
|
const currentIntegrationText = useMemo(() => {
|
|
if (current === 'global') {
|
|
return '';
|
|
}
|
|
|
|
const currentIntegration = integrations.find((p) => p.id === current)!;
|
|
|
|
return `${currentIntegration.name} (${capitalize(
|
|
currentIntegration.identifier.split('-').shift()
|
|
)})`;
|
|
}, [current]);
|
|
|
|
const changeCustomer = useCallback(
|
|
(customer: string) => {
|
|
const neededIntegrations = integrations.filter(
|
|
(p) => p?.customer?.id === customer
|
|
);
|
|
setSelectedIntegrations(
|
|
neededIntegrations.map((p) => ({
|
|
settings: {},
|
|
selectedIntegrations: p,
|
|
}))
|
|
);
|
|
},
|
|
[integrations]
|
|
);
|
|
|
|
const askClose = useCallback(async () => {
|
|
if (!activateExitButton || dummy) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
await deleteDialog(
|
|
t(
|
|
'are_you_sure_you_want_to_close_this_modal_all_data_will_be_lost',
|
|
'Are you sure you want to close this modal? (all data will be lost)'
|
|
),
|
|
t('yes_close_it', 'Yes, close it!')
|
|
)
|
|
) {
|
|
if (customClose) {
|
|
customClose();
|
|
return;
|
|
}
|
|
modal.closeAll();
|
|
}
|
|
}, [activateExitButton, dummy]);
|
|
|
|
const deletePost = useCallback(async () => {
|
|
setLoading(true);
|
|
if (
|
|
!(await deleteDialog(
|
|
'Are you sure you want to delete this post?',
|
|
'Yes, delete it!'
|
|
))
|
|
) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
await fetch(`/posts/${existingData.group}`, {
|
|
method: 'DELETE',
|
|
});
|
|
mutate();
|
|
modal.closeAll();
|
|
return;
|
|
}, [existingData, mutate, modal]);
|
|
|
|
const schedule = useCallback(
|
|
(type: 'draft' | 'now' | 'schedule') => async () => {
|
|
setLoading(true);
|
|
const checkAllValid = await ref.current.checkAllValid();
|
|
if (type !== 'draft') {
|
|
const notEnoughChars = checkAllValid.filter((p: any) => {
|
|
return p.values.some((a: any) => {
|
|
return (
|
|
countCharacters(
|
|
stripHtmlValidation('normal', a.content, true),
|
|
p?.integration?.identifier || ''
|
|
) === 0 && a.media?.length === 0
|
|
);
|
|
});
|
|
});
|
|
|
|
for (const item of notEnoughChars) {
|
|
toaster.show(
|
|
'' +
|
|
item.integration.name +
|
|
' Your post should have at least one character or one image.',
|
|
'warning'
|
|
);
|
|
setLoading(false);
|
|
item.preview();
|
|
return;
|
|
}
|
|
|
|
for (const item of checkAllValid) {
|
|
if (item.valid === false) {
|
|
toaster.show('Please fix your settings', 'warning');
|
|
item.fix();
|
|
setLoading(false);
|
|
setShowSettings(true);
|
|
return;
|
|
}
|
|
|
|
if (item.errors !== true) {
|
|
toaster.show(
|
|
`${capitalize(item.integration.identifier.split('-')[0])} (${
|
|
item.integration.name
|
|
}): ${item.errors}`,
|
|
'warning'
|
|
);
|
|
item.preview();
|
|
setLoading(false);
|
|
setShowSettings(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const sliceNeeded = checkAllValid.filter((p: any) => {
|
|
return p.values.some((a: any) => {
|
|
const strip = stripHtmlValidation('normal', a.content, true);
|
|
const weightedLength = countCharacters(
|
|
strip,
|
|
p?.integration?.identifier || ''
|
|
);
|
|
const totalCharacters =
|
|
weightedLength > strip.length ? weightedLength : strip.length;
|
|
|
|
return totalCharacters > (p.maximumCharacters || 1000000);
|
|
});
|
|
});
|
|
|
|
for (const item of sliceNeeded) {
|
|
toaster.show(
|
|
`${item?.integration?.name} (${item?.integration?.identifier}) post is too long, please fix it`,
|
|
'warning'
|
|
);
|
|
item.preview();
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const shortLinkUrl = dummy
|
|
? { ask: false }
|
|
: await (
|
|
await fetch('/posts/should-shortlink', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
messages: checkAllValid.flatMap((p: any) =>
|
|
p.values.flatMap((a: any) => a.content)
|
|
),
|
|
}),
|
|
})
|
|
).json();
|
|
|
|
const shortLink = !shortLinkUrl.ask
|
|
? false
|
|
: await deleteDialog(
|
|
'Do you want to shortlink the URLs? it will let you get statistics over clicks',
|
|
'Yes, shortlink it!'
|
|
);
|
|
|
|
const group = existingData.group || makeId(10);
|
|
const data = {
|
|
type,
|
|
...(repeater ? { inter: repeater } : {}),
|
|
tags,
|
|
shortLink,
|
|
date: date.utc().format('YYYY-MM-DDTHH:mm:ss'),
|
|
posts: checkAllValid.map((post: any) => ({
|
|
integration: {
|
|
id: post.integration.id,
|
|
},
|
|
group,
|
|
settings: { ...(post.settings || {}) },
|
|
value: post.values.map((value: any) => ({
|
|
...(value.id ? { id: value.id } : {}),
|
|
content: value.content,
|
|
image:
|
|
(value?.media || []).map(
|
|
({ id, path, alt, thumbnail, thumbnailTimestamp }: any) => ({
|
|
id,
|
|
path,
|
|
alt,
|
|
thumbnail,
|
|
thumbnailTimestamp,
|
|
})
|
|
) || [],
|
|
})),
|
|
})),
|
|
};
|
|
|
|
if (dummy) {
|
|
modal.openModal({
|
|
title: '',
|
|
children: <DummyCodeComponent code={data} />,
|
|
classNames: {
|
|
modal: 'w-[100%] bg-transparent text-textColor',
|
|
},
|
|
size: '100%',
|
|
withCloseButton: false,
|
|
closeOnEscape: true,
|
|
closeOnClickOutside: true,
|
|
});
|
|
|
|
setLoading(false);
|
|
}
|
|
|
|
if (!dummy) {
|
|
addEditSets
|
|
? addEditSets(data)
|
|
: await fetch('/posts', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
if (!addEditSets) {
|
|
mutate();
|
|
toaster.show(
|
|
!existingData.integration
|
|
? 'Added successfully'
|
|
: 'Updated successfully'
|
|
);
|
|
}
|
|
if (customClose) {
|
|
setTimeout(() => {
|
|
customClose();
|
|
}, 2000);
|
|
}
|
|
|
|
if (!addEditSets) {
|
|
modal.closeAll();
|
|
}
|
|
}
|
|
},
|
|
[ref, repeater, tags, date, addEditSets, dummy]
|
|
);
|
|
|
|
return (
|
|
<div className="w-full h-full flex-1 p-[40px] flex relative">
|
|
<div className="flex flex-1 bg-newBgColorInner rounded-[20px] flex-col">
|
|
<div className="flex-1 flex">
|
|
<div className="flex flex-col flex-1 border-r border-newBorder">
|
|
<div className="bg-newBgColor h-[65px] rounded-tl-[20px] flex items-center px-[20px] text-[20px] font-[600]">
|
|
Create Post
|
|
</div>
|
|
<div className="flex-1 flex flex-col gap-[16px]">
|
|
<div className="flex-1 relative">
|
|
<div
|
|
id="social-content"
|
|
className="gap-[32px] flex flex-col pr-[8px] pt-[20px] pl-[20px] absolute top-0 left-0 w-full h-full overflow-x-hidden overflow-y-scroll scrollbar scrollbar-thumb-newColColor scrollbar-track-newBgColorInner"
|
|
>
|
|
<div className="flex w-full">
|
|
<div className="flex flex-1">
|
|
<PicksSocialsComponent toolTip={true} />
|
|
</div>
|
|
<div>
|
|
{!dummy && (
|
|
<SelectCustomer
|
|
onChange={changeCustomer}
|
|
integrations={integrations}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-1 gap-[6px] flex-col">
|
|
<div>{!existingData.integration && <SelectCurrent />}</div>
|
|
<div className="flex-1 flex">
|
|
{!hide && <EditorWrapper totalPosts={1} value="" />}
|
|
</div>
|
|
<div
|
|
id="social-empty"
|
|
className={clsx(
|
|
'pb-[16px]',
|
|
current !== 'global' && 'hidden'
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="wrapper-settings"
|
|
className={clsx(
|
|
'pb-[20px] px-[20px] select-none',
|
|
current === 'global' && 'hidden'
|
|
)}
|
|
>
|
|
<div className="bg-newSettings flex flex-col rounded-[12px] gap-[12px]">
|
|
<div
|
|
onClick={() => setShowSettings(!showSettings)}
|
|
className={clsx(
|
|
'bg-[#612BD3] rounded-[12px] flex items-center gap-[8px] cursor-pointer p-[12px]',
|
|
showSettings ? '!rounded-b-none' : ''
|
|
)}
|
|
>
|
|
<div className="flex">
|
|
<SettingsIcon className="text-white" />
|
|
</div>
|
|
<div className="flex-1 text-[14px] font-[600] text-white">
|
|
{currentIntegrationText} Settings
|
|
</div>
|
|
<div>
|
|
<ChevronDownIcon
|
|
rotated={showSettings}
|
|
className="text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
id="social-settings"
|
|
className={clsx(
|
|
!showSettings && 'hidden',
|
|
'px-[12px] pb-[12px] text-[14px] text-textColor font-[500] max-h-[300px] overflow-x-hidden overflow-y-auto scrollbar scrollbar-thumb-newBgColorInner scrollbar-track-newColColor'
|
|
)}
|
|
/>
|
|
<style>
|
|
{`#social-settings [data-id="${current}"] {display: block !important;}`}
|
|
</style>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="w-[580px] flex flex-col">
|
|
<div className="bg-newBgColor h-[65px] rounded-tr-[20px] flex items-center px-[20px] text-[20px] font-[600]">
|
|
<div className="flex-1">Post Preview</div>
|
|
<div className="cursor-pointer">
|
|
<CloseIcon onClick={askClose} className="text-[#A3A3A3]" />
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 relative">
|
|
<Scrollable
|
|
scrollClasses="!pr-[20px]"
|
|
className="absolute top-0 p-[20px] pr-[8px] left-0 w-full h-full overflow-x-hidden overflow-y-scroll scrollbar scrollbar-thumb-newColColor scrollbar-track-newBgColorInner"
|
|
>
|
|
<ShowAllProviders ref={ref} />
|
|
</Scrollable>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="select-none h-[84px] py-[20px] border-t border-newBorder flex items-center">
|
|
<div className="flex-1 flex pl-[20px] gap-[8px]">
|
|
{!dummy && (
|
|
<TagsComponent
|
|
name="tags"
|
|
label={t('tags', 'Tags')}
|
|
initial={tags}
|
|
onChange={(e) => {
|
|
setTags(e.target.value);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!dummy && (
|
|
<RepeatComponent repeat={repeater} onChange={setRepeater} />
|
|
)}
|
|
</div>
|
|
<div className="pr-[20px] flex items-center justify-end gap-[8px]">
|
|
{existingData?.integration && (
|
|
<button
|
|
onClick={deletePost}
|
|
className="cursor-pointer flex text-[#FF3F3F] gap-[8px] items-center text-[15px] font-[600]"
|
|
>
|
|
<div>
|
|
<TrashIcon />
|
|
</div>
|
|
<div>Delete Post</div>
|
|
</button>
|
|
)}
|
|
<DatePicker onChange={setDate} date={date} />
|
|
{!addEditSets && (
|
|
<button
|
|
disabled={
|
|
selectedIntegrations.length === 0 || loading || locked
|
|
}
|
|
onClick={schedule('draft')}
|
|
className="cursor-pointer disabled:cursor-not-allowed px-[20px] h-[44px] bg-btnSimple justify-center items-center flex rounded-[8px] text-[15px] font-[600]"
|
|
>
|
|
Save as Draft
|
|
</button>
|
|
)}
|
|
{addEditSets && (
|
|
<button
|
|
className="text-white text-[15px] font-[600] min-w-[180px] btnSub disabled:cursor-not-allowed disabled:opacity-80 outline-none gap-[8px] flex justify-center items-center h-[44px] rounded-[8px] bg-[#612BD3] pl-[20px] pr-[16px]"
|
|
disabled={
|
|
selectedIntegrations.length === 0 || loading || locked
|
|
}
|
|
onClick={schedule('draft')}
|
|
>
|
|
Save Set
|
|
</button>
|
|
)}
|
|
{!addEditSets && (
|
|
<div className="group cursor-pointer relative">
|
|
<button
|
|
disabled={
|
|
selectedIntegrations.length === 0 || loading || locked
|
|
}
|
|
onClick={schedule('schedule')}
|
|
className="text-white min-w-[180px] btnSub disabled:cursor-not-allowed disabled:opacity-80 outline-none gap-[8px] flex justify-center items-center h-[44px] rounded-[8px] bg-[#612BD3] pl-[20px] pr-[16px]"
|
|
>
|
|
<div className="text-[15px] font-[600]">
|
|
{selectedIntegrations.length === 0
|
|
? 'Check the circles above'
|
|
: dummy
|
|
? 'Create output'
|
|
: !existingData?.integration
|
|
? t('add_to_calendar', 'Add to calendar')
|
|
: existingData?.posts?.[0]?.state === 'DRAFT'
|
|
? t('schedule', 'Schedule')
|
|
: t('update', 'Update')}
|
|
</div>
|
|
<div className="flex justify-center items-center h-[20px] w-[20px] pt-[4px] arrow-change">
|
|
<DropdownArrowSmallIcon className="group-hover:rotate-180 text-white" />
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
onClick={schedule('now')}
|
|
disabled={
|
|
selectedIntegrations.length === 0 || loading || locked
|
|
}
|
|
className="rounded-[8px] z-[300] disabled:cursor-not-allowed disabled:opacity-80 hidden group-hover:flex absolute bottom-[100%] -left-[12px] p-[12px] w-[206px] bg-newBgColorInner"
|
|
>
|
|
<div className="text-white rounded-[8px] bg-[#D82D7E] h-[44px] w-full flex justify-center items-center post-now">
|
|
Post Now
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<CopilotPopup
|
|
hitEscapeToClose={false}
|
|
clickOutsideToClose={true}
|
|
instructions={`
|
|
You are an assistant that help the user to schedule their social media posts,
|
|
Here are the things you can do:
|
|
- Add a new comment / post to the list of posts
|
|
- Delete a comment / post from the list of posts
|
|
- Add content to the comment / post
|
|
- Activate or deactivate the comment / post
|
|
|
|
Post content can be added using the addPostContentFor{num} function.
|
|
After using the addPostFor{num} it will create a new addPostContentFor{num+ 1} function.
|
|
`}
|
|
labels={{
|
|
title: 'Your Assistant',
|
|
initial: 'Hi! I can help you to refine your social media posts.',
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Scrollable: FC<{
|
|
className: string;
|
|
scrollClasses: string;
|
|
children: ReactNode;
|
|
}> = ({ className, scrollClasses, children }) => {
|
|
const ref = useRef();
|
|
const hasScroll = useHasScroll(ref);
|
|
return (
|
|
<div className={clsx(className, hasScroll && scrollClasses)} ref={ref}>
|
|
{children}
|
|
</div>
|
|
);
|
|
};
|