feat: onboarding
This commit is contained in:
parent
5cb69667b3
commit
ba7fc7a8e8
6 changed files with 312 additions and 65 deletions
|
|
@ -195,7 +195,8 @@ export class IntegrationsController {
|
|||
async getIntegrationUrl(
|
||||
@Param('integration') integration: string,
|
||||
@Query('refresh') refresh: string,
|
||||
@Query('externalUrl') externalUrl: string
|
||||
@Query('externalUrl') externalUrl: string,
|
||||
@Query('onboarding') onboarding: string
|
||||
) {
|
||||
if (
|
||||
!this._integrationManager
|
||||
|
|
@ -227,6 +228,10 @@ export class IntegrationsController {
|
|||
await ioRedis.set(`refresh:${state}`, refresh, 'EX', 300);
|
||||
}
|
||||
|
||||
if (onboarding === 'true') {
|
||||
await ioRedis.set(`onboarding:${state}`, 'true', 'EX', 300);
|
||||
}
|
||||
|
||||
await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300);
|
||||
await ioRedis.set(
|
||||
`external:${state}`,
|
||||
|
|
@ -409,6 +414,11 @@ export class IntegrationsController {
|
|||
await ioRedis.del(`refresh:${body.state}`);
|
||||
}
|
||||
|
||||
const onboarding = await ioRedis.get(`onboarding:${body.state}`);
|
||||
if (onboarding) {
|
||||
await ioRedis.del(`onboarding:${body.state}`);
|
||||
}
|
||||
|
||||
const {
|
||||
error,
|
||||
accessToken,
|
||||
|
|
@ -520,7 +530,10 @@ export class IntegrationsController {
|
|||
console.log(err);
|
||||
});
|
||||
|
||||
return createUpdate;
|
||||
return {
|
||||
...createUpdate,
|
||||
onboarding: onboarding === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/disable')
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
||||
import React, { FC, useCallback, useEffect, useMemo } from 'react';
|
||||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import { Input } from '@gitroom/react/form/input';
|
||||
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
|
||||
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
|
||||
import { useVariables } from '@gitroom/react/helpers/variable.context';
|
||||
import { useToaster } from '@gitroom/react/toaster/toaster';
|
||||
|
|
@ -35,16 +35,9 @@ export const AddProviderButton: FC<{
|
|||
update?: () => void;
|
||||
}> = (props) => {
|
||||
const { update } = props;
|
||||
const query = useSearchParams();
|
||||
const add = useAddProvider(update);
|
||||
const t = useT();
|
||||
|
||||
useEffect(() => {
|
||||
if (query.get('onboarding')) {
|
||||
add();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="text-btnText bg-btnSimple h-[44px] pt-[12px] pb-[14px] ps-[16px] pe-[20px] justify-center items-center flex rounded-[8px] gap-[8px]"
|
||||
|
|
@ -222,8 +215,9 @@ export const CustomVariables: FC<{
|
|||
close?: () => void;
|
||||
identifier: string;
|
||||
gotoUrl(url: string): void;
|
||||
onboarding?: boolean;
|
||||
}> = (props) => {
|
||||
const { close, gotoUrl, identifier, variables } = props;
|
||||
const { close, gotoUrl, identifier, variables, onboarding } = props;
|
||||
const modals = useModals();
|
||||
const schema = useMemo(() => {
|
||||
return object({
|
||||
|
|
@ -263,10 +257,10 @@ export const CustomVariables: FC<{
|
|||
gotoUrl(
|
||||
`/integrations/social/${identifier}?state=nostate&code=${Buffer.from(
|
||||
JSON.stringify(data)
|
||||
).toString('base64')}`
|
||||
).toString('base64')}${onboarding ? '&onboarding=true' : ''}`
|
||||
);
|
||||
},
|
||||
[variables]
|
||||
[variables, onboarding]
|
||||
);
|
||||
|
||||
const t = useT();
|
||||
|
|
@ -314,8 +308,9 @@ export const AddProviderComponent: FC<{
|
|||
name: string;
|
||||
}>;
|
||||
update?: () => void;
|
||||
onboarding?: boolean;
|
||||
}> = (props) => {
|
||||
const { update, social, article } = props;
|
||||
const { update, social, article, onboarding } = props;
|
||||
const { isGeneral } = useVariables();
|
||||
const toaster = useToaster();
|
||||
const router = useRouter();
|
||||
|
|
@ -335,12 +330,13 @@ export const AddProviderComponent: FC<{
|
|||
}>
|
||||
) =>
|
||||
async () => {
|
||||
const onboardingParam = onboarding ? 'onboarding=true' : '';
|
||||
const openWeb3 = async () => {
|
||||
const { component: Web3Providers } = web3List.find(
|
||||
(item) => item.identifier === identifier
|
||||
)!;
|
||||
const { url } = await (
|
||||
await fetch(`/integrations/social/${identifier}`)
|
||||
await fetch(`/integrations/social/${identifier}${onboarding ? '?onboarding=true' : ''}`)
|
||||
).json();
|
||||
modal.openModal({
|
||||
title: t('web3_provider', 'Web3 provider'),
|
||||
|
|
@ -351,7 +347,7 @@ export const AddProviderComponent: FC<{
|
|||
children: (
|
||||
<Web3Providers
|
||||
onComplete={(code, newState) => {
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}`;
|
||||
window.location.href = `/integrations/social/${identifier}?code=${code}&state=${newState}${onboarding ? '&onboarding=true' : ''}`;
|
||||
}}
|
||||
nonce={url}
|
||||
/>
|
||||
|
|
@ -360,11 +356,13 @@ export const AddProviderComponent: FC<{
|
|||
return;
|
||||
};
|
||||
const gotoIntegration = async (externalUrl?: string) => {
|
||||
const params = [
|
||||
externalUrl ? `externalUrl=${externalUrl}` : '',
|
||||
onboardingParam,
|
||||
].filter(Boolean).join('&');
|
||||
const { url, err } = await (
|
||||
await fetch(
|
||||
`/integrations/social/${identifier}${
|
||||
externalUrl ? `?externalUrl=${externalUrl}` : ``
|
||||
}`
|
||||
`/integrations/social/${identifier}${params ? `?${params}` : ''}`
|
||||
)
|
||||
).json();
|
||||
if (err) {
|
||||
|
|
@ -378,10 +376,9 @@ export const AddProviderComponent: FC<{
|
|||
return;
|
||||
}
|
||||
if (isExternal) {
|
||||
modal.closeAll();
|
||||
modal.openModal({
|
||||
title: 'URL',
|
||||
withCloseButton: false,
|
||||
withCloseButton: true,
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
|
|
@ -390,10 +387,9 @@ export const AddProviderComponent: FC<{
|
|||
return;
|
||||
}
|
||||
if (customFields) {
|
||||
modal.closeAll();
|
||||
modal.openModal({
|
||||
title: t('add_provider_title', 'Add Provider'),
|
||||
withCloseButton: false,
|
||||
withCloseButton: true,
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
|
|
@ -402,6 +398,7 @@ export const AddProviderComponent: FC<{
|
|||
identifier={identifier}
|
||||
gotoUrl={(url: string) => router.push(url)}
|
||||
variables={customFields}
|
||||
onboarding={onboarding}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
|
@ -409,14 +406,14 @@ export const AddProviderComponent: FC<{
|
|||
}
|
||||
await gotoIntegration();
|
||||
},
|
||||
[]
|
||||
[onboarding]
|
||||
);
|
||||
|
||||
const showApiButton = useCallback(
|
||||
(identifier: string, name: string) => async () => {
|
||||
modal.openModal({
|
||||
title: '',
|
||||
withCloseButton: false,
|
||||
withCloseButton: true,
|
||||
classNames: {
|
||||
modal: 'bg-transparent text-textColor',
|
||||
},
|
||||
|
|
@ -431,9 +428,9 @@ export const AddProviderComponent: FC<{
|
|||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-[20px] rounded-[4px] relative">
|
||||
<div className="w-full flex flex-col gap-[20px] rounded-[4px] relative]">
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-5 gap-[10px] justify-items-center justify-center">
|
||||
<div className={clsx("grid grid-cols-5 gap-[10px] justify-items-center justify-center", onboarding ? 'grid-cols-8' : 'grid-cols-5')}>
|
||||
{social.map((item) => (
|
||||
<div
|
||||
key={item.identifier}
|
||||
|
|
|
|||
|
|
@ -62,12 +62,13 @@ export const ContinueIntegration: FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
const { inBetweenSteps, id } = await data.json();
|
||||
const { inBetweenSteps, id, onboarding: resOnboarding } = await data.json();
|
||||
const onboarding = resOnboarding || searchParams.onboarding === 'true';
|
||||
if (inBetweenSteps && !searchParams.refresh) {
|
||||
push(`/launches?added=${provider}&continue=${id}`);
|
||||
push(`/launches?added=${provider}&continue=${id}${onboarding ? '&onboarding=true' : ''}`);
|
||||
return;
|
||||
}
|
||||
push(`/launches?added=${provider}&msg=Channel Updated`);
|
||||
push(`/launches?added=${provider}&msg=Channel Updated${onboarding ? '&onboarding=true' : ''}`);
|
||||
})();
|
||||
}, [provider, searchParams]);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { NewPost } from '@gitroom/frontend/components/launches/new.post';
|
|||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { useIntegrationList } from '@gitroom/frontend/components/launches/helpers/use.integration.list';
|
||||
import useCookie from 'react-use-cookie';
|
||||
import { Onboarding } from '@gitroom/frontend/components/onboarding/onboarding';
|
||||
|
||||
export const SVGLine = () => {
|
||||
return (
|
||||
|
|
@ -490,6 +491,7 @@ export const LaunchesComponent = () => {
|
|||
// @ts-ignore
|
||||
return (
|
||||
<DNDProvider>
|
||||
<Onboarding />
|
||||
<CalendarWeekProvider integrations={sortedIntegrations}>
|
||||
<div
|
||||
className={clsx(
|
||||
|
|
|
|||
245
apps/frontend/src/components/onboarding/onboarding.modal.tsx
Normal file
245
apps/frontend/src/components/onboarding/onboarding.modal.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
'use client';
|
||||
|
||||
import React, { FC, useCallback, useMemo, useState } from 'react';
|
||||
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
|
||||
import useSWR from 'swr';
|
||||
import { orderBy } from 'lodash';
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { AddProviderComponent } from '@gitroom/frontend/components/launches/add.provider.component';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
|
||||
interface OnboardingModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const OnboardingModal: FC<OnboardingModalProps> = ({ onClose }) => {
|
||||
const [step, setStep] = useState(1);
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-full flex-1 p-[40px] flex relative">
|
||||
<div className="flex flex-1 bg-newBgColorInner rounded-[20px] flex-col">
|
||||
<div className="flex-1 flex p-[40px]">
|
||||
<div className="flex flex-col gap-[24px] flex-1">
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-center gap-[16px]">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-[32px] h-[32px] rounded-full flex items-center justify-center text-[14px] font-semibold transition-colors',
|
||||
step === 1
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-customColor47 text-customColor18'
|
||||
)}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-[14px]',
|
||||
step === 1 ? 'text-white font-medium' : 'text-customColor18'
|
||||
)}
|
||||
>
|
||||
{t('connect_channels', 'Connect Channels')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-[40px] h-[2px] bg-customColor47" />
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-[32px] h-[32px] rounded-full flex items-center justify-center text-[14px] font-semibold transition-colors',
|
||||
step === 2
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-customColor47 text-customColor18'
|
||||
)}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-[14px]',
|
||||
step === 2 ? 'text-white font-medium' : 'text-customColor18'
|
||||
)}
|
||||
>
|
||||
{t('watch_tutorial', 'Watch Tutorial')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
{step === 1 && (
|
||||
<OnboardingStep1
|
||||
onNext={() => setStep(2)}
|
||||
onSkip={() => setStep(2)}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<OnboardingStep2 onBack={() => setStep(1)} onFinish={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OnboardingStep1: FC<{ onNext: () => void; onSkip: () => void }> = ({
|
||||
onNext,
|
||||
onSkip,
|
||||
}) => {
|
||||
const fetch = useFetch();
|
||||
const t = useT();
|
||||
|
||||
const getIntegrations = useCallback(async () => {
|
||||
return (await fetch('/integrations')).json();
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (path: string) => {
|
||||
const list = (await (await fetch(path)).json()).integrations;
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const { data: integrations } = useSWR('/integrations/list', load, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnMount: true,
|
||||
refreshWhenHidden: false,
|
||||
refreshWhenOffline: false,
|
||||
fallbackData: [],
|
||||
});
|
||||
|
||||
const sortedIntegrations = useMemo(() => {
|
||||
return orderBy(
|
||||
integrations,
|
||||
['type', 'disabled', 'identifier'],
|
||||
['desc', 'asc', 'asc']
|
||||
);
|
||||
}, [integrations]);
|
||||
|
||||
const { data } = useSWR('get-all-integrations-onboarding', getIntegrations);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[24px]">
|
||||
<div className="flex gap-[4px] flex-col text-center">
|
||||
<div className="text-[24px] font-semibold">
|
||||
{t('connect_your_channels', 'Connect Your Channels')}
|
||||
</div>
|
||||
<div className="text-[14px] text-customColor18">
|
||||
{t(
|
||||
'connect_social_media_to_start',
|
||||
'Connect your social media accounts to start scheduling posts'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected channels */}
|
||||
{sortedIntegrations.length > 0 && (
|
||||
<div className="border border-customColor47 rounded-[8px] p-[16px]">
|
||||
<div className="text-[14px] font-medium mb-[12px]">
|
||||
{t('connected_channels', 'Connected Channels')} (
|
||||
{sortedIntegrations.length})
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-[12px]">
|
||||
{sortedIntegrations.map((integration: any) => (
|
||||
<div
|
||||
key={integration.id}
|
||||
className="flex items-center gap-[8px] bg-customColor47/30 rounded-[8px] px-[12px] py-[8px]"
|
||||
>
|
||||
<div className="relative w-[28px] h-[28px]">
|
||||
<Image
|
||||
src={integration.picture}
|
||||
className="rounded-full"
|
||||
alt={integration.identifier}
|
||||
width={28}
|
||||
height={28}
|
||||
/>
|
||||
<Image
|
||||
src={`/icons/platforms/${integration.identifier}.png`}
|
||||
className="rounded-full absolute -bottom-[3px] -end-[3px] border border-fifth"
|
||||
alt={integration.identifier}
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[13px]">{integration.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available platforms - using AddProviderComponent */}
|
||||
<div className="flex flex-col gap-[12px]">
|
||||
<div className="text-[14px] font-medium">
|
||||
{t('add_more_channels', 'Add More Channels')}
|
||||
</div>
|
||||
{data && (
|
||||
<AddProviderComponent
|
||||
social={data.social || []}
|
||||
article={data.article || []}
|
||||
onboarding={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end pt-[16px]">
|
||||
<Button onClick={onNext}>
|
||||
{sortedIntegrations.length > 0
|
||||
? t('continue', 'Continue')
|
||||
: t('continue_without_channels', 'Continue without channels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const OnboardingStep2: FC<{ onBack: () => void; onFinish: () => void }> = ({
|
||||
onBack,
|
||||
onFinish,
|
||||
}) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[24px] flex-1">
|
||||
<div className="flex gap-[4px] flex-col text-center">
|
||||
<div className="text-[24px] font-semibold">
|
||||
{t('watch_tutorial_title', 'Learn How to Use Postiz')}
|
||||
</div>
|
||||
<div className="text-[14px] text-customColor18">
|
||||
{t(
|
||||
'watch_tutorial_description',
|
||||
'Watch this short video to learn how to get the most out of Postiz'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YouTube Video Embed */}
|
||||
<div className="relative flex-1 rounded-[12px] overflow-hidden">
|
||||
<div className="absolute left-0 top-0 w-full h-full flex justify-center">
|
||||
<iframe
|
||||
className="h-full aspect-video"
|
||||
src="https://www.youtube.com/embed/BdsCVvEYgHU?si=vvhaZJ8I5oXXvVJS?autoplay=1"
|
||||
title="Postiz Tutorial"
|
||||
allow="autoplay"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-between pt-[16px]">
|
||||
<Button
|
||||
className="bg-transparent border border-customColor47 hover:bg-customColor47/30"
|
||||
onClick={onBack}
|
||||
>
|
||||
{t('back', 'Back')}
|
||||
</Button>
|
||||
<Button onClick={onFinish}>{t('get_started', 'Get Started')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,57 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FC, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
|
||||
import { Button } from '@gitroom/react/form/button';
|
||||
import { ConnectChannels } from '@gitroom/frontend/components/onboarding/connect.channels';
|
||||
import { useVariables } from '@gitroom/react/helpers/variable.context';
|
||||
import { useT } from '@gitroom/react/translation/get.transation.service.client';
|
||||
import { OnboardingModal } from '@gitroom/frontend/components/onboarding/onboarding.modal';
|
||||
|
||||
const Welcome: FC = () => {
|
||||
const { isGeneral } = useVariables();
|
||||
const [step, setStep] = useState(1);
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
|
||||
const goToLaunches = useCallback(() => {
|
||||
router.push('/launches');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{step === 2 - (isGeneral ? 1 : 0) && (
|
||||
<div>
|
||||
<ConnectChannels />
|
||||
<div className="flex justify-end gap-[8px]">
|
||||
<Button onClick={goToLaunches}>{t('close', 'Close')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const Onboarding: FC = () => {
|
||||
const query = useSearchParams();
|
||||
const modal = useModals();
|
||||
const router = useRouter();
|
||||
const modalOpen = useRef(false);
|
||||
const t = useT();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
modal.closeAll();
|
||||
router.push('/launches');
|
||||
}, [modal, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const onboarding = query.get('onboarding');
|
||||
if (!onboarding) {
|
||||
modalOpen.current = false;
|
||||
modal.closeAll();
|
||||
if (modalOpen.current) {
|
||||
modalOpen.current = false;
|
||||
modal.closeAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (modalOpen.current) {
|
||||
return;
|
||||
}
|
||||
modalOpen.current = true;
|
||||
modal.openModal({
|
||||
title: t('onboarding', 'Onboarding'),
|
||||
withCloseButton: false,
|
||||
// title: t('onboarding', 'Welcome to Postiz'),
|
||||
withCloseButton: true,
|
||||
closeOnEscape: false,
|
||||
size: '900px',
|
||||
children: <Welcome />,
|
||||
removeLayout: true,
|
||||
fullScreen: true,
|
||||
onClose: handleClose,
|
||||
children: <OnboardingModal onClose={handleClose} />,
|
||||
});
|
||||
}, [query]);
|
||||
}, [query, handleClose, t]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue