feat: onboarding

This commit is contained in:
Nevo David 2026-01-23 16:20:28 +07:00
parent 5cb69667b3
commit ba7fc7a8e8
6 changed files with 312 additions and 65 deletions

View file

@ -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')

View file

@ -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}

View file

@ -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]);

View file

@ -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(

View 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>
);
};

View file

@ -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;
};