diff --git a/.cspell.json b/.cspell.json index 93c9b7b81ff..2eb0f457291 100644 --- a/.cspell.json +++ b/.cspell.json @@ -708,7 +708,10 @@ "rstrip", "truncatewords", "xmlschema", - "jsonify" + "jsonify", + "touchpoint", + "Angularjs", + "navigatable" ], "flagWords": [], "patterns": [ diff --git a/apps/api/package.json b/apps/api/package.json index ad6acc52d24..e71ab926c85 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@novu/api", - "version": "2.1.0", + "version": "2.1.1", "description": "description", "author": "", "private": "true", diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index c642de2b7e4..4e82600e495 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -81,8 +81,6 @@ API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER= API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION= API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= -PR_PREVIEW_ROOT_URL=dev-web-novu.netlify.app - HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID= HUBSPOT_PRIVATE_APP_ACCESS_TOKEN= diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f53e8aab558..585fbd3626c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -160,7 +160,9 @@ modules.push( client: new Client({ secretKey: process.env.NOVU_INTERNAL_SECRET_KEY, strictAuthentication: - process.env.NODE_ENV === 'production' || process.env.NOVU_STRICT_AUTHENTICATION_ENABLED === 'true', + process.env.NODE_ENV === 'production' || + process.env.NODE_ENV === 'dev' || + process.env.NOVU_STRICT_AUTHENTICATION_ENABLED === 'true', }), controllerDecorators: [ApiExcludeController()], workflows: [usageLimitsWorkflow], diff --git a/apps/api/src/app/environments-v1/novu-bridge-client.ts b/apps/api/src/app/environments-v1/novu-bridge-client.ts index 058c0124b4e..94f1b062cd6 100644 --- a/apps/api/src/app/environments-v1/novu-bridge-client.ts +++ b/apps/api/src/app/environments-v1/novu-bridge-client.ts @@ -51,6 +51,7 @@ export class NovuBridgeClient { workflows.push(programmaticallyConstructedWorkflow); } + this.novuRequestHandler = new NovuRequestHandler({ frameworkName, workflows, diff --git a/apps/api/src/app/inbox/inbox.controller.ts b/apps/api/src/app/inbox/inbox.controller.ts index 28eb25c1a1c..0e717e355c2 100644 --- a/apps/api/src/app/inbox/inbox.controller.ts +++ b/apps/api/src/app/inbox/inbox.controller.ts @@ -1,4 +1,16 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, + Headers, +} from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; import { SubscriberEntity } from '@novu/dal'; @@ -51,12 +63,16 @@ export class InboxController { ) {} @Post('/session') - async sessionInitialize(@Body() body: SubscriberSessionRequestDto): Promise { + async sessionInitialize( + @Body() body: SubscriberSessionRequestDto, + @Headers('origin') origin: string + ): Promise { return await this.initializeSessionUsecase.execute( SessionCommand.create({ subscriberId: body.subscriberId, applicationIdentifier: body.applicationIdentifier, subscriberHash: body.subscriberHash, + origin, }) ); } diff --git a/apps/api/src/app/inbox/usecases/session/session.command.ts b/apps/api/src/app/inbox/usecases/session/session.command.ts index 76df74e2d1c..720c8bd9e88 100644 --- a/apps/api/src/app/inbox/usecases/session/session.command.ts +++ b/apps/api/src/app/inbox/usecases/session/session.command.ts @@ -13,4 +13,8 @@ export class SessionCommand extends BaseCommand { @IsDefined() @IsString() readonly subscriberId: string; + + @IsOptional() + @IsString() + readonly origin?: string; } diff --git a/apps/api/src/app/inbox/usecases/session/session.spec.ts b/apps/api/src/app/inbox/usecases/session/session.spec.ts index 60508cbfd71..f2d94280baf 100644 --- a/apps/api/src/app/inbox/usecases/session/session.spec.ts +++ b/apps/api/src/app/inbox/usecases/session/session.spec.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import { expect } from 'chai'; import { NotFoundException } from '@nestjs/common'; -import { EnvironmentRepository } from '@novu/dal'; +import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; import { AnalyticsService, CreateSubscriber, SelectIntegration, AuthService } from '@novu/application-generic'; import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared'; @@ -39,7 +39,7 @@ describe('Session', () => { let selectIntegration: sinon.SinonStubbedInstance; let analyticsService: sinon.SinonStubbedInstance; let notificationsCount: sinon.SinonStubbedInstance; - + let integrationRepository: sinon.SinonStubbedInstance; beforeEach(() => { environmentRepository = sinon.createStubInstance(EnvironmentRepository); createSubscriber = sinon.createStubInstance(CreateSubscriber); @@ -47,6 +47,7 @@ describe('Session', () => { selectIntegration = sinon.createStubInstance(SelectIntegration); analyticsService = sinon.createStubInstance(AnalyticsService); notificationsCount = sinon.createStubInstance(NotificationsCount); + integrationRepository = sinon.createStubInstance(IntegrationRepository); session = new Session( environmentRepository as any, @@ -54,7 +55,8 @@ describe('Session', () => { authService as any, selectIntegration as any, analyticsService as any, - notificationsCount as any + notificationsCount as any, + integrationRepository as any ); }); @@ -192,7 +194,7 @@ describe('Session', () => { expect(response.token).to.equal(token); expect(response.totalUnreadCount).to.equal(notificationCount.data[0].count); expect( - analyticsService.mixpanelTrack.calledOnceWith(AnalyticsEventsEnum.SESSION_INITIALIZED, '', { + analyticsService.mixpanelTrack.calledWith(AnalyticsEventsEnum.SESSION_INITIALIZED, '', { _organization: environment._organizationId, environmentName: environment.name, _subscriber: subscriber._id, diff --git a/apps/api/src/app/inbox/usecases/session/session.usecase.ts b/apps/api/src/app/inbox/usecases/session/session.usecase.ts index 49029fbab44..5804dfd3602 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { EnvironmentRepository } from '@novu/dal'; +import { EnvironmentRepository, IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared'; import { AnalyticsService, @@ -27,7 +27,8 @@ export class Session { private authService: AuthService, private selectIntegration: SelectIntegration, private analyticsService: AnalyticsService, - private notificationsCount: NotificationsCount + private notificationsCount: NotificationsCount, + private integrationRepository: IntegrationRepository ) {} @LogDecorator() @@ -88,6 +89,35 @@ export class Session { const removeNovuBranding = inAppIntegration.removeNovuBranding || false; + /** + * We want to prevent the playground inbox demo from marking the integration as connected + * And only treat the real customer domain or local environment as valid origins + */ + const isOriginFromNovu = + command.origin && + ((process.env.DASHBOARD_V2_BASE_URL && command.origin?.includes(process.env.DASHBOARD_V2_BASE_URL as string)) || + (process.env.FRONT_BASE_URL && command.origin?.includes(process.env.FRONT_BASE_URL as string))); + + if (!isOriginFromNovu && !inAppIntegration.connected) { + this.analyticsService.mixpanelTrack(AnalyticsEventsEnum.INBOX_CONNECTED, '', { + _organization: environment._organizationId, + environmentName: environment.name, + }); + + await this.integrationRepository.updateOne( + { + _id: inAppIntegration._id, + _organizationId: environment._organizationId, + _environmentId: environment._id, + }, + { + $set: { + connected: true, + }, + } + ); + } + return { token, totalUnreadCount, diff --git a/apps/api/src/app/inbox/utils/analytics.ts b/apps/api/src/app/inbox/utils/analytics.ts index 1333e0934cb..2cd399e73b8 100644 --- a/apps/api/src/app/inbox/utils/analytics.ts +++ b/apps/api/src/app/inbox/utils/analytics.ts @@ -1,5 +1,6 @@ export enum AnalyticsEventsEnum { SESSION_INITIALIZED = 'Session Initialized - [Inbox]', + INBOX_CONNECTED = 'Inbox Connected - [Inbox]', FETCH_NOTIFICATIONS = 'Fetch Notifications - [Inbox]', MARK_NOTIFICATION_AS = 'Mark Notification As - [Inbox]', UPDATE_NOTIFICATION_ACTION = 'Update Notification Action - [Inbox]', diff --git a/apps/api/src/config/cors.config.spec.ts b/apps/api/src/config/cors.config.spec.ts index 6691d7e4e72..d77d334028b 100644 --- a/apps/api/src/config/cors.config.spec.ts +++ b/apps/api/src/config/cors.config.spec.ts @@ -1,6 +1,6 @@ import { spy } from 'sinon'; import { expect } from 'chai'; -import { corsOptionsDelegate, isPermittedDeployPreviewOrigin } from './cors.config'; +import { corsOptionsDelegate } from './cors.config'; describe('CORS Configuration', () => { describe('Local Environment', () => { @@ -32,7 +32,6 @@ describe('CORS Configuration', () => { process.env.FRONT_BASE_URL = 'https://test.com'; process.env.LEGACY_STAGING_DASHBOARD_URL = 'https://test-legacy-staging-dashboard.com'; process.env.WIDGET_BASE_URL = 'https://widget.com'; - process.env.PR_PREVIEW_ROOT_URL = 'https://pr-preview.com'; }); afterEach(() => { @@ -43,14 +42,26 @@ describe('CORS Configuration', () => { const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly - corsOptionsDelegate({ url: '/v1/test' }, callbackSpy); + corsOptionsDelegate( + { + url: '/v1/test', + headers: { + origin: 'https://test.novu.com', + }, + }, + callbackSpy + ); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; - expect(callbackSpy.firstCall.lastArg.origin.length).to.equal(3); + expect(callbackSpy.firstCall.lastArg.origin.length).to.equal(environment === 'dev' ? 4 : 3); expect(callbackSpy.firstCall.lastArg.origin[0]).to.equal(process.env.FRONT_BASE_URL); expect(callbackSpy.firstCall.lastArg.origin[1]).to.equal(process.env.LEGACY_STAGING_DASHBOARD_URL); expect(callbackSpy.firstCall.lastArg.origin[2]).to.equal(process.env.WIDGET_BASE_URL); + + if (environment === 'dev') { + expect(callbackSpy.firstCall.lastArg.origin[3]).to.equal('https://test.novu.com'); + } }); it('widget routes should be wildcarded', () => { @@ -74,56 +85,6 @@ describe('CORS Configuration', () => { expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin).to.equal('*'); }); - - if (environment === 'dev') { - it('should allow all origins for dev environment from pr preview', () => { - const callbackSpy = spy(); - - // @ts-expect-error - corsOptionsDelegate is not typed correctly - corsOptionsDelegate( - { - url: '/v1/test', - headers: { - origin: `https://test--${process.env.PR_PREVIEW_ROOT_URL}`, - }, - }, - callbackSpy - ); - - expect(callbackSpy.calledOnce).to.be.ok; - expect(callbackSpy.firstCall.firstArg).to.be.null; - expect(callbackSpy.firstCall.lastArg.origin).to.equal('*'); - }); - } - }); - }); - - describe('isPermittedDeployPreviewOrigin', () => { - afterEach(() => { - process.env.NODE_ENV = 'test'; - }); - - it('should return false when NODE_ENV is not dev', () => { - process.env.NODE_ENV = 'production'; - expect(isPermittedDeployPreviewOrigin('https://someorigin.com')).to.be.false; - }); - - it('should return false when PR_PREVIEW_ROOT_URL is not set', () => { - process.env.NODE_ENV = 'dev'; - delete process.env.PR_PREVIEW_ROOT_URL; - expect(isPermittedDeployPreviewOrigin('https://someorigin.com')).to.be.false; - }); - - it('should return false for origins not matching PR_PREVIEW_ROOT_URL (string)', () => { - process.env.NODE_ENV = 'dev'; - process.env.PR_PREVIEW_ROOT_URL = 'https://pr-preview.com'; - expect(isPermittedDeployPreviewOrigin('https://anotherorigin.com')).to.be.false; - }); - - it('should return true for origin matching PR_PREVIEW_ROOT_URL', () => { - process.env.NODE_ENV = 'dev'; - process.env.PR_PREVIEW_ROOT_URL = 'https://pr-preview.com'; - expect(isPermittedDeployPreviewOrigin('https://netlify-https://pr-preview.com')).to.be.true; }); }); }); diff --git a/apps/api/src/config/cors.config.ts b/apps/api/src/config/cors.config.ts index 2445e3c690a..338326e6679 100644 --- a/apps/api/src/config/cors.config.ts +++ b/apps/api/src/config/cors.config.ts @@ -10,8 +10,6 @@ export const corsOptionsDelegate: Parameters[0] methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], }; - const origin = extractOrigin(req); - if (enableWildcard(req)) { corsOptions.origin = '*'; } else { @@ -29,27 +27,17 @@ export const corsOptionsDelegate: Parameters[0] if (process.env.WIDGET_BASE_URL) { corsOptions.origin.push(process.env.WIDGET_BASE_URL); } + // Enable preview deployments in staging environment for Netlify and Vercel + if (process.env.NODE_ENV === 'dev') { + corsOptions.origin.push(origin(req)); + } } - const shouldDisableCorsForPreviewUrls = isPermittedDeployPreviewOrigin(origin); - - Logger.verbose(`Should allow deploy preview? ${shouldDisableCorsForPreviewUrls ? 'Yes' : 'No'}.`, { - curEnv: process.env.NODE_ENV, - previewUrlRoot: process.env.PR_PREVIEW_ROOT_URL, - origin, - }); - callback(null as unknown as Error, corsOptions); }; function enableWildcard(req: Request): boolean { - return ( - isSandboxEnvironment() || - isWidgetRoute(req.url) || - isInboxRoute(req.url) || - isBlueprintRoute(req.url) || - isPermittedDeployPreviewOrigin(extractOrigin(req)) - ); + return isSandboxEnvironment() || isWidgetRoute(req.url) || isInboxRoute(req.url) || isBlueprintRoute(req.url); } function isWidgetRoute(url: string): boolean { @@ -68,14 +56,6 @@ function isSandboxEnvironment(): boolean { return ['test', 'local'].includes(process.env.NODE_ENV); } -export function isPermittedDeployPreviewOrigin(origin: string | string[]): boolean { - if (!process.env.PR_PREVIEW_ROOT_URL || process.env.NODE_ENV !== 'dev') { - return false; - } - - return origin.includes(process.env.PR_PREVIEW_ROOT_URL); -} - -function extractOrigin(req: Request): string { +function origin(req: Request): string { return (req.headers as any)?.origin || ''; } diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 52b8c6f9d8c..f7d41360738 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -53,9 +53,9 @@ "@segment/analytics-next": "^1.73.0", "@sentry/react": "^8.35.0", "@tanstack/react-query": "^5.59.6", - "launchdarkly-react-client-sdk": "^3.3.2", "@types/js-cookie": "^3.0.6", "@uiw/codemirror-extensions-langs": "^4.23.6", + "@uiw/codemirror-theme-material": "^4.23.6", "@uiw/codemirror-theme-white": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6", "@uiw/react-codemirror": "^4.23.6", @@ -67,12 +67,15 @@ "flat": "^6.0.1", "motion": "^11.12.0", "js-cookie": "^3.0.5", + "launchdarkly-react-client-sdk": "^3.3.2", "lodash.debounce": "^4.0.8", "lodash.merge": "^4.6.2", "lucide-react": "^0.439.0", "mixpanel-browser": "^2.52.0", "next-themes": "^0.3.0", "react": "^18.3.1", + "react-colorful": "^5.6.1", + "react-confetti": "^6.1.0", "react-dom": "^18.3.1", "react-helmet-async": "^1.3.0", "react-hook-form": "7.53.2", diff --git a/apps/dashboard/public/images/auth/full-width-layout.svg b/apps/dashboard/public/images/auth/full-width-layout.svg new file mode 100644 index 00000000000..bab714f5ee0 --- /dev/null +++ b/apps/dashboard/public/images/auth/full-width-layout.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/auth/popover-layout.svg b/apps/dashboard/public/images/auth/popover-layout.svg new file mode 100644 index 00000000000..e206814aa28 --- /dev/null +++ b/apps/dashboard/public/images/auth/popover-layout.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/auth/sidebar-layout.svg b/apps/dashboard/public/images/auth/sidebar-layout.svg new file mode 100644 index 00000000000..9e0f01c26b4 --- /dev/null +++ b/apps/dashboard/public/images/auth/sidebar-layout.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/auth/success-usecase-hint.svg b/apps/dashboard/public/images/auth/success-usecase-hint.svg new file mode 100644 index 00000000000..0a61a6e4afe --- /dev/null +++ b/apps/dashboard/public/images/auth/success-usecase-hint.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/calendar_schedule.png b/apps/dashboard/public/images/welcome/calendar_schedule.png new file mode 100644 index 00000000000..7cdc9ef53d3 Binary files /dev/null and b/apps/dashboard/public/images/welcome/calendar_schedule.png differ diff --git a/apps/dashboard/public/images/welcome/compliance.png b/apps/dashboard/public/images/welcome/compliance.png new file mode 100644 index 00000000000..9062c7872ea Binary files /dev/null and b/apps/dashboard/public/images/welcome/compliance.png differ diff --git a/apps/dashboard/public/images/welcome/illustrations/blog.svg b/apps/dashboard/public/images/welcome/illustrations/blog.svg new file mode 100644 index 00000000000..5dbd1789e5a --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/blog.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/code-first-1.svg b/apps/dashboard/public/images/welcome/illustrations/code-first-1.svg new file mode 100644 index 00000000000..7f10772cb50 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/code-first-1.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/code-first-2.svg b/apps/dashboard/public/images/welcome/illustrations/code-first-2.svg new file mode 100644 index 00000000000..171fce5f28d --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/code-first-2.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/code-first.svg b/apps/dashboard/public/images/welcome/illustrations/code-first.svg new file mode 100644 index 00000000000..a3085d8fa4e --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/code-first.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/cover.svg b/apps/dashboard/public/images/welcome/illustrations/cover.svg new file mode 100644 index 00000000000..3853d34fd5a --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/cover.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/digest engine-1.svg b/apps/dashboard/public/images/welcome/illustrations/digest engine-1.svg new file mode 100644 index 00000000000..1ec5d83104e --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/digest engine-1.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/digest-engine.svg b/apps/dashboard/public/images/welcome/illustrations/digest-engine.svg new file mode 100644 index 00000000000..25d19d6765a --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/digest-engine.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/discord.svg b/apps/dashboard/public/images/welcome/illustrations/discord.svg new file mode 100644 index 00000000000..6d4a65037ee --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/discord.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/git.svg b/apps/dashboard/public/images/welcome/illustrations/git.svg new file mode 100644 index 00000000000..b5a16bb2236 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/git.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/github.svg b/apps/dashboard/public/images/welcome/illustrations/github.svg new file mode 100644 index 00000000000..b5a16bb2236 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/github.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/meet.svg b/apps/dashboard/public/images/welcome/illustrations/meet.svg new file mode 100644 index 00000000000..d5c55dbbdfe --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/meet.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/security.svg b/apps/dashboard/public/images/welcome/illustrations/security.svg new file mode 100644 index 00000000000..52dadd85860 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/security.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/subs-folder.svg b/apps/dashboard/public/images/welcome/illustrations/subs-folder.svg new file mode 100644 index 00000000000..5c5f7d5bfd9 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/subs-folder.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/subs.svg b/apps/dashboard/public/images/welcome/illustrations/subs.svg new file mode 100644 index 00000000000..249a6cdbd4a --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/subs.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/subscriber.svg b/apps/dashboard/public/images/welcome/illustrations/subscriber.svg new file mode 100644 index 00000000000..1e4dff7874a --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/subscriber.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/subscribers-1.svg b/apps/dashboard/public/images/welcome/illustrations/subscribers-1.svg new file mode 100644 index 00000000000..499cb9c9d47 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/subscribers-1.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/subscribers.svg b/apps/dashboard/public/images/welcome/illustrations/subscribers.svg new file mode 100644 index 00000000000..949f55c4321 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/subscribers.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/topics-1.svg b/apps/dashboard/public/images/welcome/illustrations/topics-1.svg new file mode 100644 index 00000000000..a16e050b731 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/topics-1.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/illustrations/topics.svg b/apps/dashboard/public/images/welcome/illustrations/topics.svg new file mode 100644 index 00000000000..7b5c3f5b4b6 --- /dev/null +++ b/apps/dashboard/public/images/welcome/illustrations/topics.svg @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/public/images/welcome/view_code.png b/apps/dashboard/public/images/welcome/view_code.png new file mode 100644 index 00000000000..52337c24902 Binary files /dev/null and b/apps/dashboard/public/images/welcome/view_code.png differ diff --git a/apps/dashboard/src/api/integrations.ts b/apps/dashboard/src/api/integrations.ts new file mode 100644 index 00000000000..1e922d274b9 --- /dev/null +++ b/apps/dashboard/src/api/integrations.ts @@ -0,0 +1,8 @@ +import { IIntegration } from '@novu/shared'; +import { get } from './api.client'; + +export async function getIntegrations() { + const { data } = await get<{ data: IIntegration[] }>('/integrations'); + + return data; +} diff --git a/apps/dashboard/src/components/auth-layout.tsx b/apps/dashboard/src/components/auth-layout.tsx index d03447b651b..3f00819d601 100644 --- a/apps/dashboard/src/components/auth-layout.tsx +++ b/apps/dashboard/src/components/auth-layout.tsx @@ -1,9 +1,12 @@ import { ReactNode } from 'react'; +import { Toaster } from './primitives/sonner'; export const AuthLayout = ({ children }: { children: ReactNode }) => { return ( -
-
{children}
+
+ + +
{children}
); }; diff --git a/apps/dashboard/src/components/auth/auth-card.tsx b/apps/dashboard/src/components/auth/auth-card.tsx index 743519d3ff1..cf68b55a40a 100644 --- a/apps/dashboard/src/components/auth/auth-card.tsx +++ b/apps/dashboard/src/components/auth/auth-card.tsx @@ -1,5 +1,6 @@ +import { cn } from '../../utils/ui'; import { Card } from '../primitives/card'; -export function AuthCard({ children }: { children: React.ReactNode }) { - return {children}; +export function AuthCard({ children, className }: { children: React.ReactNode; className?: string }) { + return {children}; } diff --git a/apps/dashboard/src/components/auth/customize-inbox-playground.tsx b/apps/dashboard/src/components/auth/customize-inbox-playground.tsx new file mode 100644 index 00000000000..2e2a83ba67c --- /dev/null +++ b/apps/dashboard/src/components/auth/customize-inbox-playground.tsx @@ -0,0 +1,160 @@ +import { Info } from 'lucide-react'; +import { RiInputField, RiLayoutLine } from 'react-icons/ri'; +import { FormProvider, UseFormReturn } from 'react-hook-form'; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../primitives/accordion'; +import { ColorPicker } from '../primitives/color-picker'; +import { getComponentByType } from '../workflow-editor/steps/component-utils'; +import { UiComponentEnum } from '@novu/shared'; +import type { InboxPlaygroundFormData } from './inbox-playground'; + +interface PreviewStyle { + id: string; + label: string; + image: string; +} + +interface CustomizeInboxProps { + form: UseFormReturn; +} + +const previewStyles: PreviewStyle[] = [ + { id: 'popover', label: 'Popover', image: '/images/auth/popover-layout.svg' }, + { id: 'sidebar', label: 'Side Menu', image: '/images/auth/sidebar-layout.svg' }, + { id: 'full-width', label: 'Full Width', image: '/images/auth/full-width-layout.svg' }, +]; + +export function CustomizeInbox({ form }: CustomizeInboxProps) { + const selectedStyle = form.watch('selectedStyle'); + const openAccordion = form.watch('openAccordion'); + + const handleAccordionChange = (value: string | undefined) => { + form.setValue('openAccordion', value); + }; + + return ( +
+ + + +
+ + Customize Inbox +
+
+ +
+ {previewStyles.map((style) => ( + form.setValue('selectedStyle', style.id)} + /> + ))} +
+ + +
+
+
+ + + + + +
+ + Configure notification +
+
+ + + +
+
+
+
+ ); +} + +function StylePreviewCard({ + style, + isSelected, + onSelect, +}: { + style: PreviewStyle; + isSelected: boolean; + onSelect: () => void; +}) { + return ( +
+
+ {style.label} +
+
+ ); +} + +function ColorPickerSection({ form }: { form: UseFormReturn }) { + return ( +
+
+
+
+ Primary + form.setValue('primaryColor', color)} + /> +
+
+ +
+
+ Foreground + form.setValue('foregroundColor', color)} + /> +
+
+
+ +
+ +

+ The Inbox is completely customizable, using the{' '} + + appearance prop + +

+
+
+ ); +} + +function NotificationConfigSection() { + return ( +
+
{getComponentByType({ component: UiComponentEnum.IN_APP_SUBJECT })}
+ {getComponentByType({ component: UiComponentEnum.IN_APP_BODY })} + {getComponentByType({ component: UiComponentEnum.IN_APP_BUTTON_DROPDOWN })} +
+ ); +} diff --git a/apps/dashboard/src/components/auth/inbox-playground.tsx b/apps/dashboard/src/components/auth/inbox-playground.tsx new file mode 100644 index 00000000000..7a7bfde5549 --- /dev/null +++ b/apps/dashboard/src/components/auth/inbox-playground.tsx @@ -0,0 +1,271 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Button } from '../primitives/button'; +import { RiNotification2Fill } from 'react-icons/ri'; +import { InboxPreviewContent } from './inbox-preview-content'; +import { useTriggerWorkflow } from '@/hooks/use-trigger-workflow'; +import { useAuth } from '../../context/auth/hooks'; +import { StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { createWorkflow } from '../../api/workflows'; +import { useWorkflows } from '../../hooks/use-workflows'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers'; +import { ROUTES } from '../../utils/routes'; +import { useNavigate } from 'react-router-dom'; +import { InlineToast } from '../primitives/inline-toast'; +import { UsecasePlaygroundHeader } from '../usecase-playground-header'; +import { CustomizeInbox } from './customize-inbox-playground'; +import { useTelemetry } from '../../hooks/use-telemetry'; +import { TelemetryEvent } from '../../utils/telemetry'; +import { ONBOARDING_DEMO_WORKFLOW_ID } from '../../config'; + +export interface ActionConfig { + label: string; + redirect: { + target: string; + url: string; + }; +} + +export interface InboxPlaygroundFormData { + subject: string; + body: string; + primaryColor: string; + foregroundColor: string; + selectedStyle: string; + openAccordion?: string; + primaryAction: ActionConfig; + secondaryAction: ActionConfig | null; +} + +const formSchema = z.object({ + subject: z.string().optional(), + body: z.string(), + primaryColor: z.string(), + foregroundColor: z.string(), + selectedStyle: z.string(), + openAccordion: z.string().optional(), + primaryAction: z.object({ + label: z.string(), + redirect: z.object({ + target: z.string(), + url: z.string(), + }), + }), + secondaryAction: z + .object({ + label: z.string(), + redirect: z.object({ + target: z.string(), + url: z.string(), + }), + }) + .nullable(), +}); + +const defaultFormValues: InboxPlaygroundFormData = { + subject: '**Welcome to Inbox!**', + body: 'This is your first notification. Customize and explore more features.', + primaryColor: '#DD2450', + foregroundColor: '#0E121B', + selectedStyle: 'popover', + openAccordion: 'layout', + primaryAction: { + label: 'Add to your app', + redirect: { + target: '_self', + url: '/', + }, + }, + secondaryAction: null, +}; + +export function InboxPlayground() { + const form = useForm({ + mode: 'onSubmit', + resolver: zodResolver(formSchema), + defaultValues: defaultFormValues, + shouldFocusError: true, + }); + + const { triggerWorkflow, isPending } = useTriggerWorkflow(); + const { data } = useWorkflows({ query: ONBOARDING_DEMO_WORKFLOW_ID }); + const auth = useAuth(); + const [hasNotificationBeenSent, setHasNotificationBeenSent] = useState(false); + const navigate = useNavigate(); + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry(TelemetryEvent.INBOX_USECASE_PAGE_VIEWED); + }, [telemetry]); + + useEffect(() => { + if (!data) return; + + /** + * We only want to create the demo workflow if it doesn't exist yet. + * This workflow will be used by the inbox preview examples + */ + const initializeDemoWorkflow = async () => { + const workflow = data?.workflows.find((workflow) => workflow.workflowId === ONBOARDING_DEMO_WORKFLOW_ID); + if (!workflow) { + await createDemoWorkflow(); + } + }; + + initializeDemoWorkflow(); + }, [data]); + + const handleSendNotification = async () => { + try { + const formValues = form.getValues(); + + await triggerWorkflow({ + name: ONBOARDING_DEMO_WORKFLOW_ID, + to: auth.currentUser?._id, + payload: { + subject: formValues.subject, + body: formValues.body, + primaryActionLabel: formValues.primaryAction?.label || '', + secondaryActionLabel: formValues.secondaryAction?.label || '', + }, + }); + + telemetry(TelemetryEvent.INBOX_NOTIFICATION_SENT, { + subject: formValues.subject, + hasSecondaryAction: !!formValues.secondaryAction, + }); + + setHasNotificationBeenSent(true); + showSuccessToast('Notification sent successfully!'); + } catch (error) { + showErrorToast('Failed to send notification'); + } + }; + + const handleImplementClick = () => { + const { primaryColor, foregroundColor } = form.getValues(); + telemetry(TelemetryEvent.INBOX_IMPLEMENTATION_CLICKED, { + primaryColor, + foregroundColor, + }); + const queryParams = new URLSearchParams({ primaryColor, foregroundColor }).toString(); + navigate(`${ROUTES.INBOX_EMBED}?${queryParams}`); + }; + + useEffect(() => { + const subscription = form.watch((value, { name }) => { + if (name === 'selectedStyle') { + telemetry(TelemetryEvent.INBOX_PREVIEW_STYLE_CHANGED, { + style: value.selectedStyle, + }); + } + + if (['primaryColor', 'foregroundColor', 'subject', 'body'].includes(name || '')) { + telemetry(TelemetryEvent.INBOX_CUSTOMIZATION_CHANGED, { + field: name, + }); + } + }); + + return () => subscription.unsubscribe(); + }, [form, telemetry]); + + return ( +
+ + telemetry(TelemetryEvent.SKIP_ONBOARDING_CLICKED, { + skippedFrom: 'inbox-playground', + }) + } + /> + +
+
+ + + {hasNotificationBeenSent && ( +
+ +
+ )} + +
+
+ {!hasNotificationBeenSent ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+
+
+ ); +} + +async function createDemoWorkflow() { + await createWorkflow({ + name: 'Onboarding Demo Workflow', + description: 'A demo workflow to showcase the Inbox component', + workflowId: ONBOARDING_DEMO_WORKFLOW_ID, + steps: [ + { + name: 'Inbox', + type: StepTypeEnum.IN_APP, + controlValues: { + subject: '{{payload.subject}}', + body: '{{payload.body}}', + avatar: window.location.origin + '/images/novu.svg', + primaryAction: { + label: '{{payload.primaryActionLabel}}', + redirect: { + target: '_self', + url: '', + }, + }, + secondaryAction: { + label: '{{payload.secondaryActionLabel}}', + redirect: { + target: '_self', + url: '', + }, + }, + }, + }, + ], + __source: WorkflowCreationSourceEnum.DASHBOARD, + }); +} diff --git a/apps/dashboard/src/components/auth/inbox-preview-content.tsx b/apps/dashboard/src/components/auth/inbox-preview-content.tsx new file mode 100644 index 00000000000..cdb692c1b63 --- /dev/null +++ b/apps/dashboard/src/components/auth/inbox-preview-content.tsx @@ -0,0 +1,108 @@ +import { Inbox, InboxContent, InboxProps } from '@novu/react'; +import { SVGProps } from 'react'; +import { useFetchEnvironments } from '../../context/environment/hooks'; +import { useUser } from '@clerk/clerk-react'; +import { useAuth } from '../../context/auth/hooks'; +import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '../../config'; + +interface InboxPreviewContentProps { + selectedStyle: string; + hideHint?: boolean; + primaryColor: string; + foregroundColor: string; +} + +export function InboxPreviewContent({ + selectedStyle, + hideHint, + primaryColor, + foregroundColor, +}: InboxPreviewContentProps) { + const auth = useAuth(); + const { user } = useUser(); + const { environments } = useFetchEnvironments({ organizationId: auth?.currentOrganization?._id }); + const currentEnvironment = environments?.find((env) => !env._parentId); + + if (!currentEnvironment || !user) { + return null; + } + + const configuration: InboxProps = { + applicationIdentifier: currentEnvironment?.identifier, + subscriberId: user?.externalId as string, + backendUrl: API_HOSTNAME ?? 'https://api.novu.co', + socketUrl: WEBSOCKET_HOSTNAME ?? 'https://ws.novu.co', + localization: { + 'notifications.emptyNotice': 'Click Send Notification to see your first notification', + }, + appearance: { + variables: { + colorPrimary: primaryColor, + colorForeground: foregroundColor, + }, + elements: { + inbox__popoverContent: { + maxHeight: '440px', + }, + button: { + fontSize: '12px', + lineHeight: '24px', + height: '24px', + borderRadius: '6px', + }, + notificationBody: { + colorForeground: `color-mix(in srgb, ${foregroundColor} 70%, white)`, + }, + notification: { + paddingRight: '12px', + }, + }, + }, + }; + + return ( + <> + {selectedStyle === 'popover' && ( +
+
+ +
+ {!hideHint && ( +
+ +

Hit send, to get an notification!

+
+ )} +
+ )} + {selectedStyle === 'sidebar' && ( +
+
+ + + +
+
+ )} + {selectedStyle === 'full-width' && ( +
+ + + +
+ )} + + ); +} + +function SendNotificationArrow(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/auth/questionnaire-form.tsx b/apps/dashboard/src/components/auth/questionnaire-form.tsx index 2e4744213db..e65fb341a22 100644 --- a/apps/dashboard/src/components/auth/questionnaire-form.tsx +++ b/apps/dashboard/src/components/auth/questionnaire-form.tsx @@ -2,6 +2,7 @@ import { Button } from '@/components/primitives/button'; import { CardDescription, CardTitle } from '@/components/primitives/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import React from 'react'; +import { motion, AnimatePresence } from 'motion/react'; import { StepIndicator } from './shared'; import { JobTitleEnum, jobTitleToLabelMapper, OrganizationTypeEnum, CompanySizeEnum } from '@novu/shared'; import { useForm, Controller } from 'react-hook-form'; @@ -103,74 +104,117 @@ export function QuestionnaireForm() { />
- {selectedJobTitle && ( -
- -
- ( - <> - {Object.values(OrganizationTypeEnum).map((type) => ( - - ))} - - )} - /> -
-
- )} + + {selectedJobTitle && ( + + +
+ ( + <> + {Object.values(OrganizationTypeEnum).map((type) => ( + + ))} + + )} + /> +
+
+ )} - {shouldShowCompanySize && ( -
- -
- ( - <> - {Object.values(CompanySizeEnum).map((size) => ( - - ))} - - )} - /> -
-
- )} + {shouldShowCompanySize && ( + + +
+ ( + <> + {Object.values(CompanySizeEnum).map((size) => ( + + ))} + + )} + /> +
+
+ )} +
- {isFormValid && ( -
- -
- )} + + {isFormValid && ( + + + + )} + diff --git a/apps/dashboard/src/components/icons/flags/eu.tsx b/apps/dashboard/src/components/icons/flags/eu.tsx index 82f0243a6cf..396c7579f6c 100644 --- a/apps/dashboard/src/components/icons/flags/eu.tsx +++ b/apps/dashboard/src/components/icons/flags/eu.tsx @@ -1,7 +1,7 @@ export function EuFlag(props: React.SVGProps) { return ( - + ) { id="Vector 5" d="M65 25C50.2472 14.5237 39.9203 8.99515 21.7996 12.3333C17.4016 13.1435 10.1759 13.9168 6.64552 17C4.64024 18.7513 1.53255 18.9147 5.43925 20.4074C8.65651 21.6367 13.3217 23.6667 16.8237 23.6667C18.0884 23.6667 5.70543 20.4243 1.89576 19.5926C-2.97483 18.5292 13.4803 3.61815 16.1451 0.999999" stroke="#1FC16B" - stroke-width="1.3" - stroke-linecap="round" + strokeWidth="1.3" + strokeLinecap="round" /> ); diff --git a/apps/dashboard/src/components/icons/opt-in-arrow.tsx b/apps/dashboard/src/components/icons/opt-in-arrow.tsx index b0a78a72334..76c5dd3635f 100644 --- a/apps/dashboard/src/components/icons/opt-in-arrow.tsx +++ b/apps/dashboard/src/components/icons/opt-in-arrow.tsx @@ -6,7 +6,7 @@ export const OptInArrow = (props: HTMLAttributes) => { ); diff --git a/apps/dashboard/src/components/icons/plug.tsx b/apps/dashboard/src/components/icons/plug.tsx index f91d1f6702f..8136ad86f1f 100644 --- a/apps/dashboard/src/components/icons/plug.tsx +++ b/apps/dashboard/src/components/icons/plug.tsx @@ -7,49 +7,49 @@ export function Plug(props: React.ComponentPropsWithoutRef<'svg'>) { id="Vector" d="M1 24.9906L3.95687 22.0337" stroke="#DD2450" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" /> diff --git a/apps/dashboard/src/components/icons/shield-zap.tsx b/apps/dashboard/src/components/icons/shield-zap.tsx index 4c9cb20f2a0..9267a4b6fda 100644 --- a/apps/dashboard/src/components/icons/shield-zap.tsx +++ b/apps/dashboard/src/components/icons/shield-zap.tsx @@ -13,9 +13,9 @@ export function ShieldZap(props: React.ComponentPropsWithoutRef<'svg'>) { id="Icon" d="M14.083 8.11513L10.833 11.3651L15.1663 13.5318L11.9163 16.7818M21.6663 12.9901C21.6663 18.3076 15.8662 22.1751 13.7558 23.4063C13.516 23.5462 13.3961 23.6161 13.2268 23.6524C13.0955 23.6806 12.9039 23.6806 12.7725 23.6524C12.6033 23.6161 12.4834 23.5462 12.2435 23.4063C10.1331 22.1751 4.33301 18.3076 4.33301 12.9901V7.8092C4.33301 6.94306 4.33301 6.51 4.47466 6.13773C4.59981 5.80887 4.80316 5.51544 5.06714 5.28279C5.36596 5.01944 5.77146 4.86738 6.58245 4.56326L12.3911 2.38503C12.6163 2.30057 12.7289 2.25835 12.8447 2.24161C12.9475 2.22676 13.0519 2.22676 13.1546 2.24161C13.2705 2.25835 13.3831 2.30057 13.6083 2.38503L19.4169 4.56326C20.2279 4.86738 20.6334 5.01944 20.9322 5.28279C21.1962 5.51544 21.3995 5.80887 21.5247 6.13773C21.6663 6.51 21.6663 6.94306 21.6663 7.8092V12.9901Z" stroke="#DD2450" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" /> diff --git a/apps/dashboard/src/components/onboarding/animated-page.tsx b/apps/dashboard/src/components/onboarding/animated-page.tsx new file mode 100644 index 00000000000..ba871a0917d --- /dev/null +++ b/apps/dashboard/src/components/onboarding/animated-page.tsx @@ -0,0 +1,22 @@ +import { motion } from 'motion/react'; +import { ReactNode } from 'react'; +import { cn } from '../../utils/ui'; + +interface AnimatedPageProps { + children: ReactNode; + className?: string; +} + +export function AnimatedPage({ children, className }: AnimatedPageProps) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/primitives/badge.tsx b/apps/dashboard/src/components/primitives/badge.tsx index d8e4b050482..c7b269096b3 100644 --- a/apps/dashboard/src/components/primitives/badge.tsx +++ b/apps/dashboard/src/components/primitives/badge.tsx @@ -3,26 +3,31 @@ import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; const badgeVariants = cva( - 'inline-flex items-center [&>svg]:shrink-0 gap-1 h-fit border text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center [&>svg]:shrink-0 gap-1 h-fit border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', { variants: { variant: { - neutral: 'border-neutral-500 bg-neutral-500', + neutral: 'border-neutral-100 bg-neutral-100 text-neutral-500', destructive: 'border-transparent bg-destructive/10 text-destructive', success: 'border-transparent bg-success/10 text-success', warning: 'border-transparent bg-warning/10 text-warning', soft: 'bg-neutral-alpha-200 text-foreground-500 border-transparent', outline: 'border-neutral-alpha-200 bg-transparent font-normal text-foreground-600 shadow-sm', }, - size: { + kind: { default: 'rounded-md px-2 py-1', pill: 'rounded-full px-2', 'pill-stroke': 'rounded-full px-2', tag: 'rounded-md py-0.5 px-2', }, + size: { + default: 'text-xs', + '2xs': 'text-[10px] leading-[14px] font-medium', + }, }, defaultVariants: { variant: 'neutral', + kind: 'default', size: 'default', }, } @@ -30,8 +35,8 @@ const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, size, ...props }: BadgeProps) { - return
; +function Badge({ className, variant, kind, size, ...props }: BadgeProps) { + return
; } export { Badge }; diff --git a/apps/dashboard/src/components/primitives/code-block.tsx b/apps/dashboard/src/components/primitives/code-block.tsx new file mode 100644 index 00000000000..f87760ca371 --- /dev/null +++ b/apps/dashboard/src/components/primitives/code-block.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import CodeMirror from '@uiw/react-codemirror'; +import { materialDark } from '@uiw/codemirror-theme-material'; +import { Check, Eye, EyeOff } from 'lucide-react'; +import { RiFileCopyLine } from 'react-icons/ri'; +import { cn } from '../../utils/ui'; +import { langs, loadLanguage } from '@uiw/codemirror-extensions-langs'; + +loadLanguage('tsx'); +loadLanguage('json'); +loadLanguage('shell'); +loadLanguage('typescript'); + +const languageMap = { + typescript: langs.typescript, + tsx: langs.tsx, + json: langs.json, + shell: langs.shell, +} as const; + +export type Language = keyof typeof languageMap; + +interface CodeBlockProps { + code: string; + language?: Language; + title?: string; + className?: string; + secretMask?: { + line: number; + maskStart?: number; + maskEnd?: number; + }[]; +} + +/** + * A code block component that supports syntax highlighting and secret masking. + * + * @example + * // Example 1: Basic usage with syntax highlighting + * + * + * @example + * // Example 2: Mask entire lines + * + * + * @example + * // Example 3: Mask specific parts of lines + * + */ +export function CodeBlock({ code, language = 'typescript', title, className, secretMask = [] }: CodeBlockProps) { + const [isCopied, setIsCopied] = useState(false); + const [showSecrets, setShowSecrets] = useState(false); + + const copyToClipboard = async () => { + await navigator.clipboard.writeText(code); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + const hasSecrets = secretMask.length > 0; + + const getMaskedCode = () => { + if (!hasSecrets || showSecrets) return code; + + const lines = code.split('\n'); + + secretMask.forEach(({ line, maskStart, maskEnd }) => { + if (line > lines.length) return; + + const lineIndex = line - 1; + const lineContent = lines[lineIndex]; + + if (maskStart !== undefined && maskEnd !== undefined) { + // Mask only part of the line + lines[lineIndex] = + lineContent.substring(0, maskStart) + '•'.repeat(maskEnd - maskStart) + lineContent.substring(maskEnd); + } else { + // Mask the entire line + lines[lineIndex] = '•'.repeat(lineContent.length); + } + }); + + return lines.join('\n'); + }; + + return ( +
+
+ {title && {title}} +
+ {hasSecrets && ( + + )} + +
+
+ +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/color-picker.tsx b/apps/dashboard/src/components/primitives/color-picker.tsx new file mode 100644 index 00000000000..4f851b8778a --- /dev/null +++ b/apps/dashboard/src/components/primitives/color-picker.tsx @@ -0,0 +1,34 @@ +import { HexColorPicker } from 'react-colorful'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; +import { Input } from './input'; +import { cn } from '../../utils/ui'; + +interface ColorPickerProps { + value: string; + onChange: (color: string) => void; + className?: string; +} + +export function ColorPicker({ value, onChange, className }: ColorPickerProps) { + return ( +
+ onChange(e.target.value)} + /> + + +
+ + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/inline-toast.tsx b/apps/dashboard/src/components/primitives/inline-toast.tsx new file mode 100644 index 00000000000..c7dcf04d16f --- /dev/null +++ b/apps/dashboard/src/components/primitives/inline-toast.tsx @@ -0,0 +1,85 @@ +import { cn } from '@/utils/ui'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { Button } from './button'; +import { Loader2 } from 'lucide-react'; + +const inlineToastVariants = cva('flex items-center justify-between gap-3 rounded-lg border px-2 py-1.5', { + variants: { + variant: { + tip: 'border-neutral-100 bg-neutral-50', + warning: 'border-warning/20 bg-warning/10', + success: 'border-success/20 bg-success/10', + error: 'border-destructive/20 bg-destructive/10', + info: 'border-information/20 bg-information/10', + }, + }, + defaultVariants: { + variant: 'tip', + }, +}); + +const VARIANT_COLORS = { + tip: 'bg-[#717784]', + warning: 'bg-warning', + success: 'bg-success', + error: 'bg-destructive', + info: 'bg-information', +} as const; + +const BUTTON_COLORS = { + tip: 'text-[#DD2450]', + warning: 'text-warning', + success: 'text-success', + error: 'text-destructive', + info: 'text-information', +} as const; + +export interface InlineToastProps + extends React.HTMLAttributes, + VariantProps { + title?: string; + description?: string | React.ReactNode; + ctaLabel?: string; + onCtaClick?: () => void; + isCtaLoading?: boolean; +} + +export function InlineToast({ + className, + variant = 'tip', + title, + description, + ctaLabel, + onCtaClick, + isCtaLoading, + ...props +}: InlineToastProps) { + const barColorClass = VARIANT_COLORS[variant || 'tip']; + const buttonColorClass = BUTTON_COLORS[variant || 'tip']; + + return ( +
+
+
+
+ {title && {title}} + {title && description && ' '} + {description} +
+
+ {ctaLabel && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/scroll-area.tsx b/apps/dashboard/src/components/primitives/scroll-area.tsx new file mode 100644 index 00000000000..f30c02dadd4 --- /dev/null +++ b/apps/dashboard/src/components/primitives/scroll-area.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from '@/utils/ui'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/apps/dashboard/src/components/primitives/sonner-helpers.tsx b/apps/dashboard/src/components/primitives/sonner-helpers.tsx index 20d3b52674e..be01ec71108 100644 --- a/apps/dashboard/src/components/primitives/sonner-helpers.tsx +++ b/apps/dashboard/src/components/primitives/sonner-helpers.tsx @@ -1,5 +1,5 @@ import { ExternalToast, toast } from 'sonner'; -import { Toast, ToastProps } from './sonner'; +import { Toast, ToastIcon, ToastProps } from './sonner'; import { ReactNode } from 'react'; export const showToast = ({ @@ -17,3 +17,27 @@ export const showToast = ({ ...options, }); }; + +export const showSuccessToast = (message: string, position: 'bottom-center' | 'top-center' = 'bottom-center') => { + showToast({ + children: () => ( + <> + + {message} + + ), + options: { position }, + }); +}; + +export const showErrorToast = (message: string, position: 'bottom-center' | 'top-center' = 'bottom-center') => { + showToast({ + children: () => ( + <> + + {message} + + ), + options: { position }, + }); +}; diff --git a/apps/dashboard/src/components/primitives/tag-input.tsx b/apps/dashboard/src/components/primitives/tag-input.tsx index e71877e1c36..f604b6a44b1 100644 --- a/apps/dashboard/src/components/primitives/tag-input.tsx +++ b/apps/dashboard/src/components/primitives/tag-input.tsx @@ -77,7 +77,7 @@ const TagInput = forwardRef((props, ref) => {
{tags.map((tag, index) => ( - + {tag} + + This will hide the Getting Started page + + + )} + + + ); +} diff --git a/apps/dashboard/src/components/side-navigation/navigation-link.tsx b/apps/dashboard/src/components/side-navigation/navigation-link.tsx new file mode 100644 index 00000000000..5646e5b44cc --- /dev/null +++ b/apps/dashboard/src/components/side-navigation/navigation-link.tsx @@ -0,0 +1,56 @@ +import { Link as RouterLink, useLocation } from 'react-router-dom'; +import { cva } from 'class-variance-authority'; +import { cn } from '@/utils/ui'; + +const linkVariants = cva( + `flex items-center gap-2 text-sm py-1.5 px-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer`, + { + variants: { + variant: { + default: 'text-foreground-600/95 transition ease-out duration-300 hover:bg-accent', + selected: 'text-foreground-950 bg-neutral-alpha-100 transition ease-out duration-300 hover:bg-accent', + disabled: 'text-foreground-300 cursor-help', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +interface NavLinkProps { + to?: string; + isExternal?: boolean; + className?: string; + children: React.ReactNode; +} + +export function NavigationLink({ to, isExternal, className, children }: NavLinkProps) { + const { pathname } = useLocation(); + const isSelected = pathname === to; + const variant = isSelected ? 'selected' : 'default'; + const classNames = cn(linkVariants({ variant, className })); + + if (!to) { + return {children}; + } + + if (isExternal) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 37ce6903e1c..2a039f99622 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -1,6 +1,4 @@ -import React, { ReactNode, useMemo } from 'react'; -import { Link as RouterLink, useLocation } from 'react-router-dom'; -import { cva } from 'class-variance-authority'; +import { ReactNode, useMemo } from 'react'; import { RiBarChartBoxLine, RiGroup2Line, @@ -10,68 +8,17 @@ import { RiStore3Line, RiUserAddLine, } from 'react-icons/ri'; -import { cn } from '@/utils/ui'; -import { EnvironmentDropdown } from './environment-dropdown'; import { useEnvironment } from '@/context/environment/hooks'; -import { OrganizationDropdown } from './organization-dropdown'; -import { FreeTrialCard } from './free-trial-card'; import { buildRoute, LEGACY_ROUTES, ROUTES } from '@/utils/routes'; -import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; import { TelemetryEvent } from '@/utils/telemetry'; import { useTelemetry } from '@/hooks/use-telemetry'; +import { EnvironmentDropdown } from './environment-dropdown'; +import { OrganizationDropdown } from './organization-dropdown'; +import { FreeTrialCard } from './free-trial-card'; +import { SubscribersStayTunedModal } from './subscribers-stay-tuned-modal'; import { SidebarContent } from '@/components/side-navigation/sidebar'; - -const linkVariants = cva( - `flex items-center gap-2 text-sm py-1.5 px-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring cursor-pointer`, - { - variants: { - variant: { - default: 'text-foreground-600/95 transition ease-out duration-300 hover:bg-accent', - selected: 'text-foreground-950 bg-neutral-alpha-100 transition ease-out duration-300 hover:bg-accent', - disabled: 'text-foreground-300 cursor-help', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); - -type NavLinkProps = { - to?: string; - isExternal?: boolean; - className?: string; - children: React.ReactNode; -}; - -const NavigationLink = ({ to, isExternal, className, children }: NavLinkProps) => { - const { pathname } = useLocation(); - const isSelected = pathname === to; - const variant = isSelected ? 'selected' : 'default'; - - const classNames = cn(linkVariants({ variant, className })); - if (!to) { - return {children}; - } - - if (isExternal) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); -}; +import { NavigationLink } from './navigation-link'; +import { GettingStartedMenuItem } from './getting-started-menu-item'; const NavigationGroup = ({ children, label }: { children: ReactNode; label?: string }) => { return ( @@ -92,54 +39,61 @@ export const SideNavigation = () => { }; return ( -
)} - {body && getComponentByType({ component: body.component })} + {body && ( + <> + {getComponentByType({ component: body.component })} + {`Type {{ for variables, or wrap text in ** for bold.`} + + )} {(primaryAction || secondaryAction) && getComponentByType({ component: primaryAction.component || secondaryAction.component, diff --git a/apps/dashboard/src/components/workflow-editor/steps/step-provider.tsx b/apps/dashboard/src/components/workflow-editor/steps/step-provider.tsx index e9428a20f2d..27ea449b85c 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/step-provider.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/step-provider.tsx @@ -1,7 +1,6 @@ import { createContext, useMemo, type ReactNode } from 'react'; import { useParams } from 'react-router-dom'; -import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { useFetchStep } from '@/hooks/use-fetch-step'; import { StepDataDto, StepTypeEnum } from '@novu/shared'; import { QueryObserverResult, RefetchOptions } from '@tanstack/react-query'; @@ -12,22 +11,25 @@ export type StepEditorContextType = { isPending: boolean; step?: StepDataDto; refetch: (options?: RefetchOptions) => Promise>; + updateStepCache: (step: Partial) => void; }; export const StepContext = createContext({} as StepEditorContextType); export const StepProvider = ({ children }: { children: ReactNode }) => { - const { workflow } = useWorkflow(); - const { stepSlug = '' } = useParams<{ + const { stepSlug = '', workflowSlug = '' } = useParams<{ workflowSlug: string; stepSlug: string; }>(); - const { step, isPending, refetch } = useFetchStep({ - workflowSlug: workflow?.slug, + const { step, isPending, refetch, updateStepCache } = useFetchStep({ + workflowSlug, stepSlug, }); - const value = useMemo(() => ({ isPending, step, refetch }), [isPending, step, refetch]); + const value = useMemo( + () => ({ isPending, step, refetch, updateStepCache }), + [isPending, step, refetch, updateStepCache] + ); return {children}; }; diff --git a/apps/dashboard/src/components/workflow-editor/test-workflow/snippet-editor.tsx b/apps/dashboard/src/components/workflow-editor/test-workflow/snippet-editor.tsx index e13dd13ead9..d7184effbe5 100644 --- a/apps/dashboard/src/components/workflow-editor/test-workflow/snippet-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/test-workflow/snippet-editor.tsx @@ -4,7 +4,15 @@ import { loadLanguage, LanguageName } from '@uiw/codemirror-extensions-langs'; import { Editor } from '@/components/primitives/editor'; import type { SnippetLanguage } from './types'; -export const SnippetEditor = ({ language, value }: { language: SnippetLanguage; value: string }) => { +export const SnippetEditor = ({ + language, + value, + readOnly = false, +}: { + language: SnippetLanguage; + value: string; + readOnly?: boolean; +}) => { const editorLanguage: LanguageName = language === 'framework' ? 'typescript' : language; const extensions = useMemo(() => { @@ -18,6 +26,7 @@ export const SnippetEditor = ({ language, value }: { language: SnippetLanguage; return ( { - const { currentEnvironment } = useEnvironment(); +export function WorkflowList() { const [searchParams] = useSearchParams(); const location = useLocation(); @@ -33,21 +28,15 @@ export const WorkflowList = () => { const offset = parseInt(searchParams.get('offset') || '0'); const limit = parseInt(searchParams.get('limit') || '12'); - const workflowsQuery = useQuery({ - queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset }], - queryFn: async () => { - const { data } = await getV2<{ data: ListWorkflowResponse }>(`/workflows?limit=${limit}&offset=${offset}`); - return data; - }, - placeholderData: keepPreviousData, + + const { data, isPending, isError, currentPage, totalPages } = useWorkflows({ + limit, + offset, }); - const currentPage = Math.floor(offset / limit) + 1; - if (workflowsQuery.isError) { - return null; - } + if (isError) return null; - if (!workflowsQuery.isPending && workflowsQuery.data.totalCount === 0) { + if (!isPending && data.totalCount === 0) { return ; } @@ -65,7 +54,7 @@ export const WorkflowList = () => { - {workflowsQuery.isPending ? ( + {isPending ? ( <> {new Array(limit).fill(0).map((_, index) => ( @@ -93,28 +82,28 @@ export const WorkflowList = () => { ) : ( <> - {workflowsQuery.data.workflows.map((workflow) => ( + {data.workflows.map((workflow) => ( ))} )} - {workflowsQuery.data && limit < workflowsQuery.data.totalCount && ( + {data && limit < data.totalCount && (
- {workflowsQuery.data ? ( + {data ? ( - Page {currentPage} of {Math.ceil(workflowsQuery.data.totalCount / limit)} + Page {currentPage} of {totalPages} ) : ( )} - {workflowsQuery.data ? ( + {data ? ( @@ -130,4 +119,4 @@ export const WorkflowList = () => {
); -}; +} diff --git a/apps/dashboard/src/components/workflow-row.tsx b/apps/dashboard/src/components/workflow-row.tsx index 4f4ca82441b..68ce9d852b6 100644 --- a/apps/dashboard/src/components/workflow-row.tsx +++ b/apps/dashboard/src/components/workflow-row.tsx @@ -163,7 +163,7 @@ export const WorkflowRow = ({ workflow }: WorkflowRowProps) => {
{workflow.origin === WorkflowOriginEnum.EXTERNAL && ( - + )} diff --git a/apps/dashboard/src/config/index.ts b/apps/dashboard/src/config/index.ts index 67bb8f04ff9..e2b737e6e49 100644 --- a/apps/dashboard/src/config/index.ts +++ b/apps/dashboard/src/config/index.ts @@ -31,3 +31,5 @@ export const LEGACY_DASHBOARD_URL = import.meta.env.VITE_LEGACY_DASHBOARD_URL; export const NEW_DASHBOARD_FEEDBACK_FORM_URL = import.meta.env.VITE_NEW_DASHBOARD_FEEDBACK_FORM_URL; export const PLAIN_SUPPORT_CHAT_APP_ID = import.meta.env.VITE_PLAIN_SUPPORT_CHAT_APP_ID; + +export const ONBOARDING_DEMO_WORKFLOW_ID = 'onboarding-demo-workflow'; diff --git a/apps/dashboard/src/hooks/use-fetch-step.tsx b/apps/dashboard/src/hooks/use-fetch-step.tsx index 01859963a57..14c0cab48cb 100644 --- a/apps/dashboard/src/hooks/use-fetch-step.tsx +++ b/apps/dashboard/src/hooks/use-fetch-step.tsx @@ -1,29 +1,43 @@ -import { useQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { StepDataDto } from '@novu/shared'; + import { QueryKeys } from '@/utils/query-keys'; import { useEnvironment } from '@/context/environment/hooks'; import { fetchStep } from '@/api/steps'; -import { getEncodedId, STEP_DIVIDER } from '@/utils/step'; +import { getEncodedId, STEP_DIVIDER, WORKFLOW_DIVIDER } from '@/utils/step'; -export const useFetchStep = ({ workflowSlug, stepSlug }: { workflowSlug?: string; stepSlug?: string }) => { +export const useFetchStep = ({ workflowSlug, stepSlug }: { workflowSlug: string; stepSlug: string }) => { + const client = useQueryClient(); const { currentEnvironment } = useEnvironment(); - - const { data, isPending, isRefetching, error, refetch } = useQuery({ - queryKey: [ + const queryKey = useMemo( + () => [ QueryKeys.fetchWorkflow, currentEnvironment?._id, - workflowSlug, - getEncodedId({ slug: stepSlug!, divider: STEP_DIVIDER }), + getEncodedId({ slug: workflowSlug, divider: WORKFLOW_DIVIDER }), + getEncodedId({ slug: stepSlug, divider: STEP_DIVIDER }), ], - queryFn: () => fetchStep({ workflowSlug: workflowSlug!, stepSlug: stepSlug! }), + [currentEnvironment?._id, workflowSlug, stepSlug] + ); + + const { data, isPending, isRefetching, error, refetch } = useQuery({ + queryKey, + queryFn: () => fetchStep({ workflowSlug: workflowSlug, stepSlug: stepSlug }), enabled: !!currentEnvironment?._id && !!stepSlug && !!workflowSlug, }); + const updateStepCache = useCallback( + (newStep: Partial) => + client.setQueryData(queryKey, (oldData: StepDataDto | undefined) => ({ ...oldData, ...newStep })), + [client, queryKey] + ); + return { step: data, isPending, isRefetching, error, refetch, + updateStepCache, }; }; diff --git a/apps/dashboard/src/hooks/use-integrations.ts b/apps/dashboard/src/hooks/use-integrations.ts new file mode 100644 index 00000000000..64fe2692f88 --- /dev/null +++ b/apps/dashboard/src/hooks/use-integrations.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getIntegrations } from '@/api/integrations'; +import { IIntegration } from '@novu/shared'; +import { useEnvironment } from '../context/environment/hooks'; +import { QueryKeys } from '../utils/query-keys'; + +export function useIntegrations({ + refetchInterval, + refetchOnWindowFocus, +}: { refetchInterval?: number; refetchOnWindowFocus?: boolean } = {}) { + const { currentEnvironment } = useEnvironment(); + + const { data: integrations, ...rest } = useQuery({ + queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id], + queryFn: getIntegrations, + refetchInterval, + refetchOnWindowFocus, + }); + + return { + integrations, + ...rest, + }; +} diff --git a/apps/dashboard/src/hooks/use-onboarding-steps.ts b/apps/dashboard/src/hooks/use-onboarding-steps.ts new file mode 100644 index 00000000000..7dbbefad814 --- /dev/null +++ b/apps/dashboard/src/hooks/use-onboarding-steps.ts @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { useWorkflows } from './use-workflows'; +import { useOrganization } from '@clerk/clerk-react'; +import { ChannelTypeEnum, IIntegration } from '@novu/shared'; +import { useIntegrations } from './use-integrations'; +import { ONBOARDING_DEMO_WORKFLOW_ID } from '../config'; + +export enum StepIdEnum { + ACCOUNT_CREATION = 'account-creation', + CREATE_A_WORKFLOW = 'create-a-workflow', + INVITE_TEAM_MEMBER = 'invite-team-member', + SYNC_TO_PRODUCTION = 'sync-to-production', + CONNECT_EMAIL_PROVIDER = 'connect-email-provider', + CONNECT_IN_APP_PROVIDER = 'connect-in_app-provider', + CONNECT_PUSH_PROVIDER = 'connect-push-provider', + CONNECT_CHAT_PROVIDER = 'connect-chat-provider', + CONNECT_SMS_PROVIDER = 'connect-sms-provider', +} + +export type StepStatus = 'completed' | 'in-progress' | 'pending'; + +export interface Step { + id: StepIdEnum; + title: string; + description: string; + status: StepStatus; +} + +interface OrganizationMetadata { + useCases?: ChannelTypeEnum[]; + [key: string]: unknown; +} + +interface OnboardingStepsResult { + steps: Step[]; + providerType: ChannelTypeEnum; + totalSteps: number; + completedSteps: number; +} + +const DEFAULT_USE_CASES: ChannelTypeEnum[] = [ChannelTypeEnum.IN_APP]; +const PROVIDER_TYPE_PRIORITIES: ChannelTypeEnum[] = [ChannelTypeEnum.IN_APP, ChannelTypeEnum.EMAIL]; + +function getProviderTitle(providerType: ChannelTypeEnum): string { + return providerType === ChannelTypeEnum.IN_APP ? 'Add an Inbox to your app' : `Connect your ${providerType} provider`; +} + +function getProviderDescription(providerType: ChannelTypeEnum): string { + return providerType === ChannelTypeEnum.IN_APP + ? 'Embed a full-featured Inbox in your app in minutes' + : `Connect your provider to send ${providerType} notifications with Novu.`; +} + +function isActiveIntegration(integration: IIntegration, providerType: ChannelTypeEnum): boolean { + const isMatchingChannel = integration.channel === providerType; + const isNotNovuProvider = !integration.providerId.startsWith('novu-'); + const isConnected = providerType === ChannelTypeEnum.IN_APP ? !!integration.connected : true; + + return isMatchingChannel && isNotNovuProvider && isConnected; +} + +export function useOnboardingSteps(): OnboardingStepsResult { + const workflows = useWorkflows(); + const { organization } = useOrganization(); + const { integrations } = useIntegrations(); + + const hasInvitedTeamMember = useMemo(() => { + return (organization?.membersCount ?? 0) > 1; + }, [organization?.membersCount]); + + const hasCreatedWorkflow = useMemo(() => { + return ( + (workflows?.data?.workflows ?? []).filter((workflow) => workflow.workflowId !== ONBOARDING_DEMO_WORKFLOW_ID) + .length > 0 + ); + }, [workflows?.data?.workflows]); + + const providerType = useMemo(() => { + const metadata = organization?.publicMetadata as OrganizationMetadata; + const useCases = metadata?.useCases ?? DEFAULT_USE_CASES; + + return PROVIDER_TYPE_PRIORITIES.find((type) => useCases.includes(type)) ?? useCases[0]; + }, [organization?.publicMetadata]); + + const steps = useMemo( + (): Step[] => [ + { + id: StepIdEnum.ACCOUNT_CREATION, + title: 'Account creation', + description: "We know it's not always easy — take a moment to celebrate!", + status: 'completed', + }, + { + id: StepIdEnum.CREATE_A_WORKFLOW, + title: 'Create a workflow', + description: 'Workflows in Novu, orchestrate notifications across channels.', + status: hasCreatedWorkflow ? 'completed' : 'in-progress', + }, + { + id: `connect-${providerType}-provider` as StepIdEnum, + title: getProviderTitle(providerType), + description: getProviderDescription(providerType), + status: integrations?.some((integration) => isActiveIntegration(integration, providerType)) + ? 'completed' + : 'pending', + }, + { + id: StepIdEnum.INVITE_TEAM_MEMBER, + title: 'Invite a team member', + description: 'Collaborate with your team to manage notifications', + status: hasInvitedTeamMember ? 'completed' : 'pending', + }, + ], + [hasInvitedTeamMember, providerType, integrations, hasCreatedWorkflow] + ); + + return { + steps, + providerType, + totalSteps: steps.length, + completedSteps: steps.filter((step) => step.status === 'completed').length, + }; +} diff --git a/apps/dashboard/src/hooks/use-workflows.ts b/apps/dashboard/src/hooks/use-workflows.ts new file mode 100644 index 00000000000..75028765a74 --- /dev/null +++ b/apps/dashboard/src/hooks/use-workflows.ts @@ -0,0 +1,35 @@ +import { ListWorkflowResponse } from '@novu/shared'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { getV2 } from '@/api/api.client'; +import { QueryKeys } from '@/utils/query-keys'; +import { useEnvironment } from '../context/environment/hooks'; + +interface UseWorkflowsParams { + limit?: number; + offset?: number; + query?: string; +} + +export function useWorkflows({ limit = 12, offset = 0, query = '' }: UseWorkflowsParams = {}) { + const { currentEnvironment } = useEnvironment(); + + const workflowsQuery = useQuery({ + queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset, query }], + queryFn: async () => { + const { data } = await getV2<{ data: ListWorkflowResponse }>( + `/workflows?limit=${limit}&offset=${offset}&query=${query}` + ); + return data; + }, + placeholderData: keepPreviousData, + }); + + const currentPage = Math.floor(offset / limit) + 1; + const totalPages = workflowsQuery.data ? Math.ceil(workflowsQuery.data.totalCount / limit) : 0; + + return { + ...workflowsQuery, + currentPage, + totalPages, + }; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index ad17b5dc93d..9ae8ec6a49c 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -3,6 +3,7 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { createRoot } from 'react-dom/client'; import ErrorPage from '@/components/error-page'; import { RootRoute, AuthRoute, DashboardRoute, CatchAllRoute } from './routes'; +import { OnboardingParentRoute } from './routes/onboarding'; import { WorkflowsPage, SignInPage, @@ -10,6 +11,7 @@ import { OrganizationListPage, QuestionnairePage, UsecaseSelectPage, + WelcomePage, } from '@/pages'; import './index.css'; import { ROUTES } from './utils/routes'; @@ -17,10 +19,13 @@ import { EditWorkflowPage } from './pages/edit-workflow'; import { TestWorkflowPage } from './pages/test-workflow'; import { initializeSentry } from './utils/sentry'; import { overrideZodErrorMap } from './utils/validation'; +import { InboxUsecasePage } from './pages/inbox-usecase-page'; +import { InboxEmbedPage } from './pages/inbox-embed-page'; import { FeatureFlagsProvider } from '@/context/feature-flags-provider'; import { EditStepTemplate } from '@/components/workflow-editor/steps/edit-step-template'; import { ConfigureWorkflow } from '@/components/workflow-editor/configure-workflow'; import { EditStep } from '@/components/workflow-editor/steps/edit-step'; +import { InboxEmbedSuccessPage } from './pages/inbox-embed-success-page'; initializeSentry(); overrideZodErrorMap(); @@ -49,10 +54,28 @@ const router = createBrowserRouter([ path: ROUTES.SIGNUP_QUESTIONNAIRE, element: , }, + ], + }, + { + path: '/onboarding', + element: , + children: [ { path: ROUTES.USECASE_SELECT, element: , }, + { + path: ROUTES.INBOX_USECASE, + element: , + }, + { + path: ROUTES.INBOX_EMBED, + element: , + }, + { + path: ROUTES.INBOX_EMBED_SUCCESS, + element: , + }, ], }, { @@ -62,6 +85,10 @@ const router = createBrowserRouter([ { path: ROUTES.ENV, children: [ + { + path: ROUTES.WELCOME, + element: , + }, { path: ROUTES.WORKFLOWS, element: , diff --git a/apps/dashboard/src/pages/inbox-embed-page.tsx b/apps/dashboard/src/pages/inbox-embed-page.tsx new file mode 100644 index 00000000000..0f9d8a3b644 --- /dev/null +++ b/apps/dashboard/src/pages/inbox-embed-page.tsx @@ -0,0 +1,41 @@ +import { AuthCard } from '../components/auth/auth-card'; +import { ROUTES } from '../utils/routes'; +import { InboxEmbed } from '../components/welcome/inbox-embed'; +import { UsecasePlaygroundHeader } from '../components/usecase-playground-header'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; +import { useEffect } from 'react'; +import { AnimatedPage } from '@/components/onboarding/animated-page'; + +export function InboxEmbedPage() { + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry(TelemetryEvent.INBOX_EMBED_PAGE_VIEWED); + }, [telemetry]); + + return ( + + +
+
+ + telemetry(TelemetryEvent.SKIP_ONBOARDING_CLICKED, { + skippedFrom: 'inbox-embed', + }) + } + /> +
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/pages/inbox-embed-success-page.tsx b/apps/dashboard/src/pages/inbox-embed-success-page.tsx new file mode 100644 index 00000000000..50a48316526 --- /dev/null +++ b/apps/dashboard/src/pages/inbox-embed-success-page.tsx @@ -0,0 +1,56 @@ +import { AuthCard } from '../components/auth/auth-card'; +import { Button } from '../components/primitives/button'; +import { useNavigate } from 'react-router-dom'; +import { ROUTES } from '../utils/routes'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; +import { useEffect } from 'react'; +import { AnimatedPage } from '@/components/onboarding/animated-page'; + +export function InboxEmbedSuccessPage() { + const navigate = useNavigate(); + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry(TelemetryEvent.INBOX_EMBED_SUCCESS_PAGE_VIEWED); + }, [telemetry]); + + function handleNavigateToDashboard() { + navigate(ROUTES.WELCOME); + } + + return ( + + +
+
+ Onboarding succcess hint to look for inbox +
+ +
+
+ Novu Logo + +
+

See how simple that was?

+

+ Robust and flexible building blocks for application notifications. +

+
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/pages/inbox-usecase-page.tsx b/apps/dashboard/src/pages/inbox-usecase-page.tsx new file mode 100644 index 00000000000..a8a41b89bf3 --- /dev/null +++ b/apps/dashboard/src/pages/inbox-usecase-page.tsx @@ -0,0 +1,24 @@ +import { AuthCard } from '../components/auth/auth-card'; +import { PageMeta } from '../components/page-meta'; +import { InboxPlayground } from '../components/auth/inbox-playground'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; +import { useEffect } from 'react'; +import { AnimatedPage } from '@/components/onboarding/animated-page'; + +export function InboxUsecasePage() { + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry(TelemetryEvent.INBOX_USECASE_PAGE_VIEWED); + }, [telemetry]); + + return ( + + + + + + + ); +} diff --git a/apps/dashboard/src/pages/index.ts b/apps/dashboard/src/pages/index.ts index 0c500c8d250..c9184036d71 100644 --- a/apps/dashboard/src/pages/index.ts +++ b/apps/dashboard/src/pages/index.ts @@ -4,3 +4,4 @@ export * from './sign-up'; export * from './organization-list'; export * from './questionnaire-page'; export * from './usecase-select-page'; +export * from './welcome-page'; diff --git a/apps/dashboard/src/pages/sign-in.tsx b/apps/dashboard/src/pages/sign-in.tsx index d28c9f8d210..2f20317ad85 100644 --- a/apps/dashboard/src/pages/sign-in.tsx +++ b/apps/dashboard/src/pages/sign-in.tsx @@ -7,7 +7,7 @@ import { clerkSignupAppearance } from '@/utils/clerk-appearance'; export const SignInPage = () => { return ( - <> +
@@ -16,6 +16,6 @@ export const SignInPage = () => {
- +
); }; diff --git a/apps/dashboard/src/pages/sign-up.tsx b/apps/dashboard/src/pages/sign-up.tsx index 2e01d974181..6febac2b13b 100644 --- a/apps/dashboard/src/pages/sign-up.tsx +++ b/apps/dashboard/src/pages/sign-up.tsx @@ -7,7 +7,7 @@ import { clerkSignupAppearance } from '@/utils/clerk-appearance'; export const SignUpPage = () => { return ( - <> +
@@ -21,6 +21,6 @@ export const SignUpPage = () => {
- +
); }; diff --git a/apps/dashboard/src/pages/usecase-select-page.tsx b/apps/dashboard/src/pages/usecase-select-page.tsx index baf52cd7d45..58aa77a94f3 100644 --- a/apps/dashboard/src/pages/usecase-select-page.tsx +++ b/apps/dashboard/src/pages/usecase-select-page.tsx @@ -1,6 +1,6 @@ import { UsecaseSelectOnboarding } from '../components/auth/usecase-selector'; import { AuthCard } from '../components/auth/auth-card'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'motion/react'; import { Button } from '../components/primitives/button'; import { ROUTES } from '../utils/routes'; @@ -14,13 +14,44 @@ import { TelemetryEvent } from '../utils/telemetry'; import { channelOptions } from '../components/auth/usecases-list.utils'; import { useMutation } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; +import { useOrganization } from '@clerk/clerk-react'; +import { AnimatedPage } from '@/components/onboarding/animated-page'; + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + duration: 0.6, + ease: [0.22, 1, 0.36, 1], + staggerChildren: 0.1, + }, + }, +}; + +const itemVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, +}; export function UsecaseSelectPage() { + const { organization } = useOrganization(); const navigate = useNavigate(); const track = useTelemetry(); const [selectedUseCases, setSelectedUseCases] = useState([]); const [hoveredUseCase, setHoveredUseCase] = useState(null); + useEffect(() => { + track(TelemetryEvent.USECASE_SELECT_PAGE_VIEWED); + }, [track]); + + useEffect(() => { + console.log('organization', organization?.publicMetadata); + if (organization?.publicMetadata?.useCases) { + setSelectedUseCases(organization.publicMetadata.useCases as ChannelTypeEnum[]); + } + }, [organization]); + const displayedUseCase = hoveredUseCase || (selectedUseCases.length > 0 ? selectedUseCases[selectedUseCases.length - 1] : null); @@ -29,12 +60,18 @@ export function UsecaseSelectPage() { await updateClerkOrgMetadata({ useCases: selectedUseCases, }); + await organization?.reload(); }, onSuccess: () => { track(TelemetryEvent.USE_CASE_SELECTED, { useCases: selectedUseCases, }); - navigate(ROUTES.WORKFLOWS); + + if (selectedUseCases.includes(ChannelTypeEnum.IN_APP)) { + navigate(ROUTES.INBOX_USECASE); + } else { + navigate(ROUTES.WELCOME); + } }, onError: (error) => { console.error('Failed to update use cases:', error); @@ -45,7 +82,7 @@ export function UsecaseSelectPage() { function handleSkip() { track(TelemetryEvent.USE_CASE_SKIPPED); - navigate(ROUTES.WORKFLOWS); + navigate(ROUTES.WELCOME); } function handleSelectUseCase(useCase: ChannelTypeEnum) { @@ -65,52 +102,68 @@ export function UsecaseSelectPage() { return ( <> - - -
-
-
- setHoveredUseCase(id)} - onClick={(id) => handleSelectUseCase(id)} - /> - -
- - -
-
-
-
- -
- - {displayedUseCase && ( - option.id === displayedUseCase)?.image}`} - alt={`${displayedUseCase}-usecase-illustration`} - className="h-auto max-h-[500px] w-full object-contain" - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - exit={{ opacity: 0 }} - transition={{ - duration: 0.2, - ease: 'easeInOut', - }} - /> - )} - - {!displayedUseCase && } - -
-
+ + + + +
+
+ setHoveredUseCase(id)} + onClick={(id) => handleSelectUseCase(id)} + /> + + + + + +
+
+
+ + + + {displayedUseCase && ( + option.id === displayedUseCase)?.image}`} + alt={`${displayedUseCase}-usecase-illustration`} + className="h-auto max-h-[500px] w-full object-contain" + initial={{ opacity: 0, scale: 0.95 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + transition={{ + duration: 0.2, + ease: [0.22, 1, 0.36, 1], + }} + /> + )} + + {!displayedUseCase && } + + +
+
+
); } @@ -123,28 +176,46 @@ function EmptyStateView() { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ - duration: 0.2, - ease: 'easeInOut', + duration: 0.4, + ease: [0.22, 1, 0.36, 1], }} > -
+ -
- - {/* Instruction Text */} -

+ + + Hover on the cards to visualize,
select all that apply. -

- - {/* Help Text */} -

+ + + This helps us understand your use-case better with the channels you'd use in your product to communicate with your users.

don't worry, you can always change later as you build. -

+ ); } diff --git a/apps/dashboard/src/pages/welcome-page.tsx b/apps/dashboard/src/pages/welcome-page.tsx new file mode 100644 index 00000000000..94ffe256671 --- /dev/null +++ b/apps/dashboard/src/pages/welcome-page.tsx @@ -0,0 +1,116 @@ +import { ReactElement, useEffect } from 'react'; +import { motion } from 'motion/react'; +import { PageMeta } from '../components/page-meta'; +import { DashboardLayout } from '../components/dashboard-layout'; +import { ProgressSection } from '../components/welcome/progress-section'; +import { ResourcesList } from '../components/welcome/resources-list'; +import { RiBookletFill, RiBookmark2Fill } from 'react-icons/ri'; +import { Resource } from '../components/welcome/resources-list'; +import { useTelemetry } from '../hooks/use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; + +const helpfulResources: Resource[] = [ + { + title: 'Documentation', + image: 'blog.svg', + url: 'https://docs.novu.co/', + }, + { + title: 'Join our community on Discord', + image: 'discord.svg', + url: 'https://discord.gg/novu', + }, + { + title: 'See our code on GitHub', + image: 'git.svg', + url: 'https://github.com/novuhq/novu', + }, + { + title: 'Security & Compliance', + image: 'security.svg', + url: 'https://trust.novu.co/', + }, +]; + +const learnResources: Resource[] = [ + { + title: 'Manage Subscribers', + duration: '4m read', + image: 'subscribers.svg', + url: 'https://docs.novu.co/concepts/subscribers?utm_source=novu.co&utm_medium=welcome-page', + }, + { + title: 'Topics', + duration: '5m read', + image: 'topics.svg', + url: 'https://docs.novu.co/concepts/topics?utm_source=novu.co&utm_medium=welcome-page', + }, + { + title: 'Code First Workflows', + duration: '4m read', + image: 'code-first.svg', + url: 'https://docs.novu.co/workflow/introduction?utm_source=novu.co&utm_medium=welcome-page', + }, + { + title: 'Digest Engine', + duration: '3m read', + image: 'digest engine-1.svg', + url: 'https://docs.novu.co/workflow/digest?utm_source=novu.co&utm_medium=welcome-page', + }, +]; + +export function WelcomePage(): ReactElement { + const telemetry = useTelemetry(); + + useEffect(() => { + telemetry(TelemetryEvent.WELCOME_PAGE_VIEWED); + }, [telemetry]); + + const pageVariants = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + staggerChildren: 0.2, + delayChildren: 0.1, + }, + }, + }; + + const sectionVariants = { + hidden: { opacity: 0, y: 20 }, + show: { + opacity: 1, + y: 0, + transition: { + duration: 0.5, + ease: [0.16, 1, 0.3, 1], + }, + }, + }; + + return ( + <> + + + + + + + + + } + resources={helpfulResources} + /> + + + + } resources={learnResources} /> + + + + + ); +} diff --git a/apps/dashboard/src/pages/workflows.tsx b/apps/dashboard/src/pages/workflows.tsx index f7aaf78959f..155f04f11d5 100644 --- a/apps/dashboard/src/pages/workflows.tsx +++ b/apps/dashboard/src/pages/workflows.tsx @@ -1,14 +1,16 @@ +import { useEffect } from 'react'; +import { RiSearch2Line } from 'react-icons/ri'; + import { WorkflowList } from '@/components/workflow-list'; import { DashboardLayout } from '@/components/dashboard-layout'; import { Input } from '@/components/primitives/input'; import { Button } from '@/components/primitives/button'; -import { RiSearch2Line } from 'react-icons/ri'; import { CreateWorkflowButton } from '@/components/create-workflow-button'; import { OptInModal } from '@/components/opt-in-modal'; import { PageMeta } from '@/components/page-meta'; import { useTelemetry } from '../hooks'; import { TelemetryEvent } from '../utils/telemetry'; -import { useEffect } from 'react'; +import { Badge } from '@/components/primitives/badge'; export const WorkflowsPage = () => { const track = useTelemetry(); @@ -20,7 +22,16 @@ export const WorkflowsPage = () => { return ( <> - Workflows}> + + Workflows + + BETA + + + } + >
diff --git a/apps/dashboard/src/routes/onboarding.tsx b/apps/dashboard/src/routes/onboarding.tsx new file mode 100644 index 00000000000..daca22e5a7e --- /dev/null +++ b/apps/dashboard/src/routes/onboarding.tsx @@ -0,0 +1,10 @@ +import { AnimatedOutlet } from '@/components/animated-outlet'; +import { AuthLayout } from '../components/auth-layout'; + +export const OnboardingParentRoute = () => { + return ( + + + + ); +}; diff --git a/apps/dashboard/src/utils/query-keys.ts b/apps/dashboard/src/utils/query-keys.ts index 0b24be9d86b..3af473e3792 100644 --- a/apps/dashboard/src/utils/query-keys.ts +++ b/apps/dashboard/src/utils/query-keys.ts @@ -6,4 +6,5 @@ export const QueryKeys = Object.freeze({ fetchWorkflowTestData: 'fetchWorkflowTestData', fetchWorkflows: 'fetchWorkflows', fetchTags: 'fetchTags', + fetchIntegrations: 'fetchIntegrations', }); diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 7b982b2dd24..340a1e204e2 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -2,13 +2,17 @@ export const ROUTES = { SIGN_IN: '/auth/sign-in', SIGN_UP: '/auth/sign-up', SIGNUP_ORGANIZATION_LIST: '/auth/organization-list', - SIGNUP_QUESTIONNAIRE: '/auth/questionnaire', - USECASE_SELECT: '/auth/usecase', + SIGNUP_QUESTIONNAIRE: '/onboarding/questionnaire', + USECASE_SELECT: '/onboarding/usecase', + INBOX_USECASE: '/onboarding/inbox', + INBOX_EMBED: '/onboarding/inbox/embed', + INBOX_EMBED_SUCCESS: '/onboarding/inbox/success', ROOT: '/', ENV: '/env', WORKFLOWS: '/env/:environmentSlug/workflows', EDIT_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug', TEST_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug/test', + WELCOME: '/env/:environmentSlug/welcome', EDIT_STEP: 'steps/:stepSlug', EDIT_STEP_TEMPLATE: 'steps/:stepSlug/edit', }; diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index 33fc0df2dc3..e03747cb752 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -5,5 +5,19 @@ export enum TelemetryEvent { CREATE_ORGANIZATION_FORM_SUBMITTED = 'Create Organization Form Submitted', USE_CASE_SELECTED = 'Use Case Selected', USE_CASE_SKIPPED = 'Use Case Skipped', + RESOURCE_CLICKED = 'Resource clicked - [Welcome]', WORKFLOWS_PAGE_VISIT = 'Workflows page visit', + WELCOME_PAGE_VIEWED = 'Welcome page viewed - [Welcome]', + WELCOME_STEP_CLICKED = 'Welcome step clicked - [Welcome]', + WELCOME_STEP_COMPLETED = 'Welcome step completed - [Welcome]', + WELCOME_MENU_HIDDEN = 'Welcome menu hidden - [Welcome]', + INBOX_NOTIFICATION_SENT = 'Inbox notification sent - [Onboarding]', + INBOX_CUSTOMIZATION_CHANGED = 'Inbox customization changed - [Onboarding]', + INBOX_IMPLEMENTATION_CLICKED = 'Inbox implementation clicked - [Onboarding]', + INBOX_PREVIEW_STYLE_CHANGED = 'Inbox preview style changed - [Onboarding]', + SKIP_ONBOARDING_CLICKED = 'Skip onboarding clicked - [Onboarding]', + USECASE_SELECT_PAGE_VIEWED = 'Use case select page viewed - [Onboarding]', + INBOX_USECASE_PAGE_VIEWED = 'Inbox use case page viewed - [Onboarding]', + INBOX_EMBED_PAGE_VIEWED = 'Inbox embed page viewed - [Onboarding]', + INBOX_EMBED_SUCCESS_PAGE_VIEWED = 'Inbox embed success page viewed - [Onboarding]', } diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js index ebe39246f87..3b15bd41875 100644 --- a/apps/dashboard/tailwind.config.js +++ b/apps/dashboard/tailwind.config.js @@ -137,6 +137,10 @@ export default { boxShadow: '0 0 0 0 rgba(255, 82, 82, 0)', }, }, + gradient: { + '0%, 100%': { backgroundPosition: '0% 50%' }, + '50%': { backgroundPosition: '100% 50%' }, + }, 'pulse-subtle': { '0%, 100%': { opacity: '1' }, '50%': { opacity: '0.85' }, @@ -188,6 +192,7 @@ export default { 'pulse-subtle': 'pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', swing: 'swing 3s ease-in-out', jingle: 'jingle 3s ease-in-out', + gradient: 'gradient 5s ease infinite', }, backgroundImage: { 'test-pattern': diff --git a/apps/web/env-config.js b/apps/web/env-config.js deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/libs/dal/src/repositories/integration/integration.entity.ts b/libs/dal/src/repositories/integration/integration.entity.ts index 4f1a46e0d67..f9ef0439067 100644 --- a/libs/dal/src/repositories/integration/integration.entity.ts +++ b/libs/dal/src/repositories/integration/integration.entity.ts @@ -37,6 +37,8 @@ export class IntegrationEntity { conditions?: StepFilter[]; removeNovuBranding?: boolean; + + connected?: boolean; } export type ICredentialsEntity = ICredentials; diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 8bee7315c12..851d823653a 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -94,6 +94,7 @@ const integrationSchema = new Schema( ], }, ], + connected: Schema.Types.Boolean, }, schemaOptions ); diff --git a/packages/js/jest.config.cjs b/packages/js/jest.config.cjs index bde0ef6b7f8..a03abac029d 100644 --- a/packages/js/jest.config.cjs +++ b/packages/js/jest.config.cjs @@ -1,4 +1,9 @@ module.exports = { preset: 'ts-jest', setupFiles: ['./jest.setup.ts'], + globals: { + NOVU_API_VERSION: '2024-06-26', + PACKAGE_NAME: '@novu/js', + PACKAGE_VERSION: 'test', + }, }; diff --git a/packages/js/jest.setup.ts b/packages/js/jest.setup.ts index 85cf83a3672..e69de29bb2d 100644 --- a/packages/js/jest.setup.ts +++ b/packages/js/jest.setup.ts @@ -1,2 +0,0 @@ -global.PACKAGE_VERSION = 'test-version'; -global.PACKAGE_NAME = 'test-package'; diff --git a/packages/js/package.json b/packages/js/package.json index 18b739b6201..02b14a5fea2 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -126,7 +126,6 @@ }, "dependencies": { "@floating-ui/dom": "^1.6.7", - "@novu/client": "workspace:*", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "mitt": "^3.0.1", diff --git a/packages/js/src/api/http-client.ts b/packages/js/src/api/http-client.ts new file mode 100644 index 00000000000..b0cb50566ee --- /dev/null +++ b/packages/js/src/api/http-client.ts @@ -0,0 +1,119 @@ +export type HttpClientOptions = { + apiVersion?: string; + backendUrl?: string; + userAgent?: string; +}; + +const DEFAULT_API_VERSION = 'v1'; +const DEFAULT_BACKEND_URL = 'https://api.novu.co'; +const DEFAULT_USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; + +export class HttpClient { + private backendUrl: string; + private apiVersion: string; + private headers: Record; + + constructor(options: HttpClientOptions = {}) { + const { + apiVersion = DEFAULT_API_VERSION, + backendUrl = DEFAULT_BACKEND_URL, + userAgent = DEFAULT_USER_AGENT, + } = options || {}; + this.apiVersion = apiVersion; + this.backendUrl = `${backendUrl}/${this.apiVersion}`; + this.headers = { + 'Novu-API-Version': NOVU_API_VERSION, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + } + + setAuthorizationToken(token: string) { + this.headers.Authorization = `Bearer ${token}`; + } + + setHeaders(headers: Record) { + this.headers = { + ...this.headers, + ...headers, + }; + } + + async get(path: string, searchParams?: URLSearchParams, unwrapEnvelope = true) { + return this.doFetch({ + path, + searchParams, + options: { + method: 'GET', + }, + unwrapEnvelope, + }); + } + + async post(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'POST', + body, + }, + }); + } + + async patch(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'PATCH', + body, + }, + }); + } + + async delete(path: string, body?: any) { + return this.doFetch({ + path, + options: { + method: 'DELETE', + body, + }, + }); + } + + private async doFetch({ + path, + searchParams, + options, + unwrapEnvelope = true, + }: { + path: string; + searchParams?: URLSearchParams; + options?: RequestInit; + unwrapEnvelope?: boolean; + }) { + const fullUrl = combineUrl(this.backendUrl, path, searchParams ? `?${searchParams.toString()}` : ''); + const reqInit = { + method: options?.method || 'GET', + headers: { ...this.headers, ...(options?.headers || {}) }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }; + + const response = await fetch(fullUrl, reqInit); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(`${this.headers['User-Agent']} error. Status: ${response.status}, Message: ${errorData.message}`); + } + if (response.status === 204) { + return undefined as unknown as T; + } + + const res = await response.json(); + + return (unwrapEnvelope ? res.data : res) as Promise; + } +} + +function combineUrl(...args: string[]): string { + return args.map((part) => part.replace(/^\/+|\/+$/g, '')).join('/'); +} diff --git a/packages/js/src/api/inbox-service.ts b/packages/js/src/api/inbox-service.ts index e375934c3ac..70573f4f439 100644 --- a/packages/js/src/api/inbox-service.ts +++ b/packages/js/src/api/inbox-service.ts @@ -1,4 +1,3 @@ -import { ApiOptions, HttpClient } from '@novu/client'; import type { ActionTypeEnum, ChannelPreference, @@ -7,10 +6,10 @@ import type { PreferencesResponse, Session, } from '../types'; +import { HttpClient, HttpClientOptions } from './http-client'; -export type InboxServiceOptions = ApiOptions; +export type InboxServiceOptions = HttpClientOptions; -const NOVU_API_VERSION = '2024-06-26'; const INBOX_ROUTE = '/inbox'; const INBOX_NOTIFICATIONS_ROUTE = `${INBOX_ROUTE}/notifications`; @@ -20,10 +19,6 @@ export class InboxService { constructor(options: InboxServiceOptions = {}) { this.#httpClient = new HttpClient(options); - this.#httpClient.updateHeaders({ - 'Novu-API-Version': NOVU_API_VERSION, - 'Novu-User-Agent': options.userAgent || '@novu/js', - }); } async initializeSession({ @@ -61,24 +56,24 @@ export class InboxService { after?: string; offset?: number; }): Promise<{ data: InboxNotification[]; hasMore: boolean; filter: NotificationFilter }> { - const queryParams = new URLSearchParams(`limit=${limit}`); + const searchParams = new URLSearchParams(`limit=${limit}`); if (after) { - queryParams.append('after', after); + searchParams.append('after', after); } if (offset) { - queryParams.append('offset', `${offset}`); + searchParams.append('offset', `${offset}`); } if (tags) { - tags.forEach((tag) => queryParams.append('tags[]', tag)); + tags.forEach((tag) => searchParams.append('tags[]', tag)); } if (read !== undefined) { - queryParams.append('read', `${read}`); + searchParams.append('read', `${read}`); } if (archived !== undefined) { - queryParams.append('archived', `${archived}`); + searchParams.append('archived', `${archived}`); } - return this.#httpClient.getFullResponse(`${INBOX_NOTIFICATIONS_ROUTE}?${queryParams.toString()}`); + return this.#httpClient.get(INBOX_NOTIFICATIONS_ROUTE, searchParams, false); } count({ filters }: { filters: Array<{ tags?: string[]; read?: boolean; archived?: boolean }> }): Promise<{ @@ -87,7 +82,13 @@ export class InboxService { filter: NotificationFilter; }>; }> { - return this.#httpClient.getFullResponse(`${INBOX_NOTIFICATIONS_ROUTE}/count?filters=${JSON.stringify(filters)}`); + return this.#httpClient.get( + `${INBOX_NOTIFICATIONS_ROUTE}/count`, + new URLSearchParams({ + filters: JSON.stringify(filters), + }), + false + ); } read(notificationId: string): Promise { diff --git a/packages/js/src/base-module.test.ts b/packages/js/src/base-module.test.ts new file mode 100644 index 00000000000..58f2a43287d --- /dev/null +++ b/packages/js/src/base-module.test.ts @@ -0,0 +1,71 @@ +import { InboxService } from './api'; +import { BaseModule } from './base-module'; +import { NovuEventEmitter } from './event-emitter'; + +beforeAll(() => jest.spyOn(global, 'fetch')); +afterAll(() => jest.restoreAllMocks()); + +describe('callWithSession(fn)', () => { + test('should invoke callback function immediately if session is initialized', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: { + isSessionInitialized: true, + } as InboxService, + eventEmitterInstance: emitter, + }); + + const cb = jest.fn(); + bm.callWithSession(cb); + expect(cb).toHaveBeenCalled(); + }); + + test('should invoke callback function as soon as session is initialized', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: {} as InboxService, + eventEmitterInstance: emitter, + }); + + const cb = jest.fn(); + + bm.callWithSession(cb); + expect(cb).not.toHaveBeenCalled(); + + emitter.emit('session.initialize.resolved', { + args: { + applicationIdentifier: 'foo', + subscriberId: 'bar', + }, + data: { + token: 'cafebabe', + totalUnreadCount: 10, + removeNovuBranding: true, + }, + }); + + expect(cb).toHaveBeenCalled(); + }); + + test('should return an error if session initialization failed', async () => { + const emitter = new NovuEventEmitter(); + const bm = new BaseModule({ + inboxServiceInstance: {} as InboxService, + eventEmitterInstance: emitter, + }); + + emitter.emit('session.initialize.resolved', { + args: { + applicationIdentifier: 'foo', + subscriberId: 'bar', + }, + error: new Error('Failed to initialize session'), + }); + + const cb = jest.fn(); + const result = await bm.callWithSession(cb); + expect(result).toEqual({ + error: new Error('Failed to initialize session, please contact the support'), + }); + }); +}); diff --git a/packages/js/src/global.d.ts b/packages/js/src/global.d.ts index 9741972a4ed..2ea80da406b 100644 --- a/packages/js/src/global.d.ts +++ b/packages/js/src/global.d.ts @@ -1,10 +1,11 @@ -/* eslint-disable vars-on-top */ -/* eslint-disable no-var */ -import { Novu } from './novu'; +import type { Novu } from './novu'; + +export {}; declare global { - var PACKAGE_NAME: string; - var PACKAGE_VERSION: string; + const NOVU_API_VERSION: string; + const PACKAGE_NAME: string; + const PACKAGE_VERSION: string; interface Window { Novu: typeof Novu; } diff --git a/packages/js/src/novu.test.ts b/packages/js/src/novu.test.ts index 49fa73342b6..5e134a646ed 100644 --- a/packages/js/src/novu.test.ts +++ b/packages/js/src/novu.test.ts @@ -1,6 +1,6 @@ -import { ListNotificationsArgs } from './notifications'; import { Novu } from './novu'; -import { NovuError } from './utils/errors'; + +const mockSessionResponse = { data: { token: 'cafebabe' } }; const mockNotificationsResponse = { data: [], @@ -8,100 +8,71 @@ const mockNotificationsResponse = { filter: { tags: [], read: false, archived: false }, }; -const post = jest.fn().mockResolvedValue({ token: 'token', profile: 'profile' }); -const getFullResponse = jest.fn(() => mockNotificationsResponse); -const updateHeaders = jest.fn(); -const setAuthorizationToken = jest.fn(); - -jest.mock('@novu/client', () => ({ - ...jest.requireActual('@novu/client'), - HttpClient: jest.fn().mockImplementation(() => { - const httpClient = { - post, - getFullResponse, - updateHeaders, - setAuthorizationToken, +async function mockFetch(url: string, reqInit: Request) { + if (url.includes('/session')) { + return { + ok: true, + status: 200, + json: async () => mockSessionResponse, }; + } + if (url.includes('/notifications')) { + return { + ok: true, + status: 200, + json: async () => mockNotificationsResponse, + }; + } + throw new Error(`Unmocked request: ${url}`); +} - return httpClient; - }), -})); +beforeAll(() => jest.spyOn(global, 'fetch')); +afterAll(() => jest.restoreAllMocks()); describe('Novu', () => { + const applicationIdentifier = 'foo'; + const subscriberId = 'bar'; + beforeEach(() => { - jest.clearAllMocks(); + // @ts-ignore + global.fetch.mockImplementation(mockFetch) as jest.Mock; }); - describe('lazy session initialization', () => { - test('should call the queued notifications.list after the session is initialized', async () => { + describe('http client', () => { + test('should call the notifications.list after the session is initialized', async () => { const options = { limit: 10, offset: 0, }; - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - const { data } = await novu.notifications.list(options); - expect(post).toHaveBeenCalledTimes(1); - expect(getFullResponse).toHaveBeenCalledWith('/inbox/notifications?limit=10'); - expect(data).toEqual({ - notifications: mockNotificationsResponse.data, - hasMore: mockNotificationsResponse.hasMore, - filter: mockNotificationsResponse.filter, + const novu = new Novu({ applicationIdentifier, subscriberId }); + expect(fetch).toHaveBeenNthCalledWith(1, 'https://api.novu.co/v1/inbox/session/', { + method: 'POST', + body: JSON.stringify({ applicationIdentifier, subscriberId }), + headers: { + 'Content-Type': 'application/json', + 'Novu-API-Version': '2024-06-26', + 'User-Agent': '@novu/js@test', + }, }); - }); - test('should call the notifications.list right away when session is already initialized', async () => { - const options: ListNotificationsArgs = { - limit: 10, - offset: 0, - }; - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - // await for session initialization - await new Promise((resolve) => { - setTimeout(resolve, 10); + const { data } = await novu.notifications.list(options); + expect(fetch).toHaveBeenNthCalledWith(2, 'https://api.novu.co/v1/inbox/notifications/?limit=10', { + method: 'GET', + body: undefined, + headers: { + Authorization: 'Bearer cafebabe', + 'Content-Type': 'application/json', + 'Novu-API-Version': '2024-06-26', + 'User-Agent': '@novu/js@test', + }, }); - const { data } = await novu.notifications.list({ limit: 10, offset: 0 }); - - expect(post).toHaveBeenCalledTimes(1); - expect(getFullResponse).toHaveBeenCalledWith('/inbox/notifications?limit=10'); expect(data).toEqual({ notifications: mockNotificationsResponse.data, hasMore: mockNotificationsResponse.hasMore, filter: mockNotificationsResponse.filter, }); }); - - test('should reject the queued notifications.list if session initialization fails', async () => { - const options = { - limit: 10, - offset: 0, - }; - const expectedError = 'reason'; - post.mockRejectedValueOnce(expectedError); - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - - const { error } = await novu.notifications.list(options); - - expect(error).toEqual(new NovuError('Failed to initialize session, please contact the support', expectedError)); - }); - - test('should reject the notifications.list right away when session initialization has failed', async () => { - const options = { - limit: 10, - offset: 0, - }; - const expectedError = 'reason'; - post.mockRejectedValueOnce(expectedError); - const novu = new Novu({ applicationIdentifier: 'applicationIdentifier', subscriberId: 'subscriberId' }); - // await for session initialization - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - - const { error } = await novu.notifications.list(options); - - expect(error).toEqual(new NovuError('Failed to initialize session, please contact the support', expectedError)); - }); }); }); diff --git a/packages/js/src/novu.ts b/packages/js/src/novu.ts index 01c769e5e53..feebd4564c3 100644 --- a/packages/js/src/novu.ts +++ b/packages/js/src/novu.ts @@ -8,12 +8,6 @@ import { PRODUCTION_BACKEND_URL } from './utils/config'; import type { NovuOptions } from './types'; import { InboxService } from './api'; -// @ts-ignore -const version = PACKAGE_VERSION; -// @ts-ignore -const name = PACKAGE_NAME; -const userAgent = `${name}@${version}`; - export class Novu implements Pick { #emitter: NovuEventEmitter; #session: Session; @@ -32,7 +26,7 @@ export class Novu implements Pick { constructor(options: NovuOptions) { this.#inboxService = new InboxService({ backendUrl: options.backendUrl ?? PRODUCTION_BACKEND_URL, - userAgent: options.__userAgent ?? userAgent, + userAgent: options.__userAgent, }); this.#emitter = new NovuEventEmitter(); this.#session = new Session( diff --git a/packages/js/src/session/session.ts b/packages/js/src/session/session.ts index bef8f3ac154..1e72a768606 100644 --- a/packages/js/src/session/session.ts +++ b/packages/js/src/session/session.ts @@ -21,7 +21,6 @@ export class Session { try { const { applicationIdentifier, subscriberId, subscriberHash } = this.#options; this.#emitter.emit('session.initialize.pending', { args: this.#options }); - const response = await this.#inboxService.initializeSession({ applicationIdentifier, subscriberId, diff --git a/packages/js/tsconfig.json b/packages/js/tsconfig.json index 0e5d3b81e19..4a83a390ddf 100644 --- a/packages/js/tsconfig.json +++ b/packages/js/tsconfig.json @@ -19,5 +19,5 @@ "removeComments": false }, "include": ["src/**/*", "src/**/*.d.ts"], - "exclude": ["src/**/*.test.ts", "src/*.test.ts", "node_modules", "**/node_modules/*"] + "exclude": ["node_modules", "**/node_modules/*"] } diff --git a/packages/js/tsup.config.ts b/packages/js/tsup.config.ts index 52cae64f1e5..ab3d4638c22 100644 --- a/packages/js/tsup.config.ts +++ b/packages/js/tsup.config.ts @@ -22,14 +22,13 @@ const buildCSS = async () => { fs.writeFileSync(destinationCssFilePath, processedCss); }; -const isProd = process.env?.NODE_ENV === 'production'; +const isProd = process.env.NODE_ENV === 'production'; const baseConfig: Options = { splitting: true, sourcemap: false, clean: true, esbuildPlugins: [solidPlugin()], - define: { PACKAGE_NAME: `"${name}"`, PACKAGE_VERSION: `"${version}"`, __DEV__: `${!isProd}` }, }; const baseModuleConfig: Options = { @@ -42,6 +41,12 @@ const baseModuleConfig: Options = { 'themes/index': './src/ui/themes/index.ts', 'internal/index': './src/ui/internal/index.ts', }, + define: { + NOVU_API_VERSION: `"2024-06-26"`, + PACKAGE_NAME: `"${name}"`, + PACKAGE_VERSION: `"${version}"`, + __DEV__: `${isProd ? false : true}`, + }, }; export default defineConfig((config: Options) => { diff --git a/packages/node/CHANGELOG.md b/packages/node/CHANGELOG.md index 5eb6758e571..4b0693e859d 100644 --- a/packages/node/CHANGELOG.md +++ b/packages/node/CHANGELOG.md @@ -1,3 +1,13 @@ +## 2.0.5 (2024-12-02) + +### 🩹 Fixes + +- **node:** Allow setting includeInactiveChannels to false ([129355e269](https://github.com/novuhq/novu/commit/129355e269)) + +### ❤️ Thank You + +- Sokratis Vidros @SokratisVidros + ## 2.0.4 (2024-11-29) ### 🚀 Features diff --git a/packages/node/package.json b/packages/node/package.json index 2cb0d567e13..c832a06ac94 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -1,6 +1,6 @@ { "name": "@novu/node", - "version": "2.0.4", + "version": "2.0.5", "description": "Notification Management Framework", "main": "build/main/index.js", "typings": "build/main/index.d.ts", diff --git a/packages/shared/src/entities/integration/index.ts b/packages/shared/src/entities/integration/index.ts index 8817fc01ba3..67076f60f2f 100644 --- a/packages/shared/src/entities/integration/index.ts +++ b/packages/shared/src/entities/integration/index.ts @@ -1 +1,2 @@ export * from './credential.interface'; +export * from './integration.interface'; diff --git a/packages/shared/src/entities/integration/integration.interface.ts b/packages/shared/src/entities/integration/integration.interface.ts new file mode 100644 index 00000000000..a1f929bbeec --- /dev/null +++ b/packages/shared/src/entities/integration/integration.interface.ts @@ -0,0 +1,38 @@ +import { ChannelTypeEnum, OrganizationId, EnvironmentId, IPreviousStepFilterPart } from '../../types'; +import { ICredentials } from './credential.interface'; + +export interface IIntegration { + _id: string; + + _environmentId: EnvironmentId; + + _organizationId: OrganizationId; + + providerId: string; + + channel: ChannelTypeEnum; + + credentials: ICredentials; + + active: boolean; + + name: string; + + identifier: string; + + priority: number; + + primary: boolean; + + deleted: boolean; + + deletedAt: string; + + deletedBy: string; + + conditions?: IPreviousStepFilterPart[]; + + removeNovuBranding?: boolean; + + connected?: boolean; +} diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 99ae28f1d6f..8d204dc948f 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -43,4 +43,5 @@ export enum FeatureFlagsKeysEnum { IS_CONTROLS_AUTOCOMPLETE_ENABLED = 'IS_CONTROLS_AUTOCOMPLETE_ENABLED', IS_USAGE_ALERTS_ENABLED = 'IS_USAGE_ALERTS_ENABLED', IS_NEW_DASHBOARD_ENABLED = 'IS_NEW_DASHBOARD_ENABLED', + IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED = 'IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6f796e7c77..e7421d217e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,6 +766,9 @@ importers: '@uiw/codemirror-extensions-langs': specifier: ^4.23.6 version: 4.23.6(@codemirror/autocomplete@6.18.3(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.3))(@codemirror/language-data@6.5.1(@codemirror/view@6.34.3))(@codemirror/language@6.10.3)(@codemirror/legacy-modes@6.4.1)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)(@lezer/common@1.2.3)(@lezer/highlight@1.2.1)(@lezer/javascript@1.4.19)(@lezer/lr@1.4.2) + '@uiw/codemirror-theme-material': + specifier: ^4.23.6 + version: 4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) '@uiw/codemirror-theme-white': specifier: ^4.23.6 version: 4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) @@ -820,6 +823,12 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-confetti: + specifier: ^6.1.0 + version: 6.1.0(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -3638,9 +3647,6 @@ importers: '@floating-ui/dom': specifier: ^1.6.7 version: 1.6.7 - '@novu/client': - specifier: workspace:* - version: link:../client class-variance-authority: specifier: ^0.7.0 version: 0.7.0 @@ -17730,6 +17736,9 @@ packages: '@codemirror/language-data': '>=6.0.0' '@codemirror/legacy-modes': '>=6.0.0' + '@uiw/codemirror-theme-material@4.23.6': + resolution: {integrity: sha512-QmFXWseYRPXPJZXG7bNxCIfGhIUQr7OmaBC41uBKttFMNWo09R+xjo7vtkdNeJwGBXySC7ZC4k0FS13jjrPTZw==} + '@uiw/codemirror-theme-white@4.23.6': resolution: {integrity: sha512-plBzEU7QOh8pm3JIxJLJ4YWxIB8t0DPOQrTABzMfFxjdGoGIMe+a/J4zhYdVslSVmwGBXbKDAj5TFqnS/1wrzQ==} @@ -18365,7 +18374,7 @@ packages: hasBin: true add-px-to-style@1.0.0: - resolution: {integrity: sha512-YMyxSlXpPjD8uWekCQGuN40lV4bnZagUwqa2m/uFv1z/tNImSk9fnXVMUI5qwME/zzI3MMQRvjZ+69zyfSSyew==} + resolution: {integrity: sha1-0ME1RB+oAUqBN5BFMQlvZ/KPJjo=} add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} @@ -18672,7 +18681,7 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} argv@0.0.2: - resolution: {integrity: sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==} + resolution: {integrity: sha1-7L0W+JSbFXGDcRsb2jNPN4QBhas=} engines: {node: '>=0.6.10'} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -19230,7 +19239,7 @@ packages: engines: {node: '>=10.0.0'} batch@0.6.1: - resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + resolution: {integrity: sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=} bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -19440,7 +19449,7 @@ packages: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -19510,7 +19519,7 @@ packages: engines: {node: '>= 0.8'} bytes@3.0.0: - resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=} engines: {node: '>= 0.8'} bytes@3.1.2: @@ -20209,7 +20218,7 @@ packages: resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -20314,7 +20323,7 @@ packages: resolution: {integrity: sha512-L2rLOcK0wzWSfSDA33YR+PUHDG10a8px7rUHKWbGLP4YfbsMed2KFUw5fczvDPbT98DDe3LEzviswl810apTEw==} cookie-signature@1.0.6: - resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} cookie@0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} @@ -21357,7 +21366,7 @@ packages: resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} dom-css@2.1.0: - resolution: {integrity: sha512-w9kU7FAbaSh3QKijL6n59ofAhkkmMJ31GclJIz/vyQdjogfyxcB6Zf8CZyibOERI5o0Hxz30VmJS7+7r5fEj2Q==} + resolution: {integrity: sha1-/bwtWgFdCj4YcuEUcrvQ57nmogI=} dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -21491,7 +21500,7 @@ packages: hasBin: true ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} @@ -24165,7 +24174,7 @@ packages: engines: {node: '>=12'} indexof@0.0.1: - resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==} + resolution: {integrity: sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=} individual@3.0.0: resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==} @@ -26315,7 +26324,7 @@ packages: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} map-stream@0.0.7: - resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==} + resolution: {integrity: sha1-ih8HiW2CsQkmvTdEokIACfiJdKg=} map-visit@1.0.0: resolution: {integrity: sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==} @@ -26535,7 +26544,7 @@ packages: resolution: {integrity: sha512-88ZRGcNxAq4EH38cQ4D85PM57pikCwS8Z99EWHODxN7KBY+UuPiqzRTtZzS8KTXO/ywSWbdjjJST2Hly/EQxLw==} media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} engines: {node: '>= 0.6'} mediaquery-text@1.2.0: @@ -28297,7 +28306,7 @@ packages: resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} pause@0.0.1: - resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + resolution: {integrity: sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=} peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -29402,7 +29411,7 @@ packages: engines: {node: '>=10'} prefix-style@2.0.1: - resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} + resolution: {integrity: sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY=} prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} @@ -29801,7 +29810,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.10.4: @@ -30168,6 +30176,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + react-confetti@6.1.0: + resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==} + engines: {node: '>=10.18'} + peerDependencies: + react: ^16.3.0 || ^17.0.1 || ^18.0.0 + react-css-theme-switcher@0.3.0: resolution: {integrity: sha512-RV+fJ6mSbtsLOgIgeL4Q8MEH4Hyl72tQvGpCFBbk3ia6ie3KzXO1gfbKTV2q1ryP3hBpmyy1qrX+6E1f937A1A==} engines: {node: '>=10'} @@ -30175,7 +30189,7 @@ packages: react: '>=16' react-custom-scrollbars@4.2.1: - resolution: {integrity: sha512-VtJTUvZ7kPh/auZWIbBRceGPkE30XBYe+HktFxuMWBR2eVQQ+Ur6yFJMoaYcNpyGq22uYJ9Wx4UAEcC0K+LNPQ==} + resolution: {integrity: sha1-gw/ZUCkn6X6KeMIIaBOJmyqLZts=} peerDependencies: react: ^0.14.0 || ^15.0.0 || ^16.0.0 react-dom: ^0.14.0 || ^15.0.0 || ^16.0.0 @@ -30824,7 +30838,7 @@ packages: resolution: {integrity: sha512-zEMsvb4GgxVKBBTHgy2tte67RYBZx2Kyg9mTYpg+JfATHDqYJqhuC3zG1VoiYhDVP5JaB5+mPKcAvdnT0n3jxA==} remove-accents@0.4.2: - resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==} + resolution: {integrity: sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=} remove-markdown@0.3.0: resolution: {integrity: sha512-5392eIuy1mhjM74739VunOlsOYKjsH82rQcTBlJ1bkICVC3dQ3ksQzTHh4jGHQFnM+1xzLzcFOMH+BofqXhroQ==} @@ -31172,7 +31186,7 @@ packages: hasBin: true run-p@0.0.0: - resolution: {integrity: sha512-ZLiUUVOXJcM/S1hMnm6Ooc1zAgAx98Mmn1qyA+y3WNeK7hOTGAusVR5r3uOQJ0NuUxZt7J9vNusYNNVgKPSbww==} + resolution: {integrity: sha1-cWpVvRICd6nZDaX4IzO3C5GAiPI=} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -31323,7 +31337,7 @@ packages: engines: {node: '>=4'} secure-compare@3.0.1: - resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + resolution: {integrity: sha1-8aAymzCLIh+uN7mXTz1XjQypmeM=} secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -32761,7 +32775,7 @@ packages: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} to-camel-case@1.0.0: - resolution: {integrity: sha512-nD8pQi5H34kyu1QDMFjzEIYqk0xa9Alt6ZfrdEMuHCFOfTLhDG5pgTu/aAM9Wt9lXILwlXmWP43b8sav0GNE8Q==} + resolution: {integrity: sha1-GlYFSy+daWKYzmamCJcyK29CPkY=} to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} @@ -33216,6 +33230,9 @@ packages: resolution: {integrity: sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==} engines: {node: '>= 0.8.0'} + tween-functions@1.2.0: + resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -57656,6 +57673,14 @@ snapshots: - '@lezer/javascript' - '@lezer/lr' + '@uiw/codemirror-theme-material@4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': + dependencies: + '@uiw/codemirror-themes': 4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + '@uiw/codemirror-theme-white@4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3)': dependencies: '@uiw/codemirror-themes': 4.23.6(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.3) @@ -75557,6 +75582,11 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-confetti@6.1.0(react@18.3.1): + dependencies: + react: 18.3.1 + tween-functions: 1.2.0 + react-css-theme-switcher@0.3.0(react@18.3.1): dependencies: react: 18.3.1 @@ -79993,6 +80023,8 @@ snapshots: tv4@1.3.0: {} + tween-functions@1.2.0: {} + tweetnacl@0.14.5: {} tweetnacl@1.0.3: {}