From 32b40a1b7f90a57eabdc8b3b61e63c85bf2ca157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Tue, 3 Dec 2024 11:31:19 +0100 Subject: [PATCH 01/10] chore(dashboard): beta label (#7197) --- .../src/components/primitives/badge.tsx | 15 +++++--- .../src/components/primitives/tag-input.tsx | 2 +- .../workflow-editor/add-step-menu.tsx | 2 +- .../workflow-editor/editor-breadcrumbs.tsx | 34 ++++++++++++++----- .../dashboard/src/components/workflow-row.tsx | 2 +- apps/dashboard/src/pages/workflows.tsx | 17 ++++++++-- 6 files changed, 53 insertions(+), 19 deletions(-) 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/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} - {breadcrumbs.map(({ label, href }) => ( + {breadcrumbs.map(({ label, href, node }) => ( - + {label} + {node} ))} - - -
- {workflow?.name} -
-
+ {workflow && ( + + {workflow.origin === WorkflowOriginEnum.EXTERNAL ? ( + + + + ) : ( + + )} +
+ {workflow?.name} +
+
+ )}
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/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 + + + } + >
From d5036d4e5e61e55a6e6d44f198edc74a7667021f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Tue, 3 Dec 2024 15:42:43 +0100 Subject: [PATCH 02/10] fix(dashboard): step form values were not updated when switching between steps (#7201) --- .../src/components/workflow-editor/nodes.tsx | 7 +++- .../steps/configure-step-form.tsx | 8 ++--- .../steps/configure-step-template-form.tsx | 4 --- .../workflow-editor/steps/configure-step.tsx | 12 +++++-- .../workflow-editor/steps/step-provider.tsx | 14 ++++---- apps/dashboard/src/hooks/use-fetch-step.tsx | 32 +++++++++++++------ 6 files changed, 50 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/components/workflow-editor/nodes.tsx b/apps/dashboard/src/components/workflow-editor/nodes.tsx index 51f397a0bdc..7ba79e26dcb 100644 --- a/apps/dashboard/src/components/workflow-editor/nodes.tsx +++ b/apps/dashboard/src/components/workflow-editor/nodes.tsx @@ -12,6 +12,7 @@ import { STEP_TYPE_TO_COLOR } from '@/utils/color'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { WorkflowOriginEnum } from '@novu/shared'; import { createStep } from '@/components/workflow-editor/steps/step-provider'; +import { getEncodedId, STEP_DIVIDER } from '@/utils/step'; export type NodeData = { name?: string; @@ -52,7 +53,11 @@ const StepNode = (props: StepNodeProps) => { stepSlug: string; }>(); - return ; + const isSelected = + getEncodedId({ slug: stepSlug ?? '', divider: STEP_DIVIDER }) === + getEncodedId({ slug: data.stepSlug ?? '', divider: STEP_DIVIDER }); + + return ; }; export const EmailNode = ({ data }: NodeProps) => { diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx index 9e3b188a548..90fa58e293f 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-form.tsx @@ -43,10 +43,11 @@ type ConfigureStepFormProps = { environment: IEnvironment; step: StepDataDto; update: (data: UpdateWorkflowDto) => void; + updateStepCache: (step: Partial) => void; }; export const ConfigureStepForm = (props: ConfigureStepFormProps) => { - const { step, workflow, update, environment } = props; + const { step, workflow, update, updateStepCache, environment } = props; const navigate = useNavigate(); const isCodeCreatedWorkflow = workflow.origin === WorkflowOriginEnum.EXTERNAL; @@ -64,10 +65,6 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { const form = useForm>({ defaultValues, - values: { - ...defaultValues, - ...step.controls.values, - }, resolver: zodResolver(stepSchema), shouldFocusError: false, }); @@ -78,6 +75,7 @@ export const ConfigureStepForm = (props: ConfigureStepFormProps) => { isReadOnly: isCodeCreatedWorkflow, save: (data) => { update(updateStepInWorkflow(workflow, data)); + updateStepCache(data); }, }); diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx index 9232ca870fb..59898b51468 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step-template-form.tsx @@ -60,10 +60,6 @@ export const ConfigureStepTemplateForm = (props: ConfigureStepTemplateFormProps) const form = useForm({ resolver: zodResolver(schema), defaultValues, - values: { - ...defaultValues, - ...step.controls.values, - }, shouldFocusError: false, }); diff --git a/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx b/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx index a3765c8ffae..616cc048374 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/configure-step.tsx @@ -4,12 +4,20 @@ import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { useEnvironment } from '@/context/environment/hooks'; export const ConfigureStep = () => { - const { step } = useStep(); + const { step, updateStepCache } = useStep(); const { workflow, update } = useWorkflow(); const { currentEnvironment } = useEnvironment(); if (!currentEnvironment || !step || !workflow) { return null; } - return ; + return ( + + ); }; 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/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, }; }; From dc829deb9cf30fbdc35a502c4ecdde80b969bd26 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Tue, 3 Dec 2024 17:08:10 +0200 Subject: [PATCH 03/10] chore(api): Enable preview deployments (#7200) --- apps/api/package.json | 2 +- apps/api/src/.env.development | 2 - apps/api/src/app.module.ts | 4 +- apps/api/src/config/cors.config.spec.ts | 69 ++++++------------------- apps/api/src/config/cors.config.ts | 32 +++--------- apps/web/env-config.js | 0 packages/node/CHANGELOG.md | 10 ++++ packages/node/package.json | 2 +- 8 files changed, 36 insertions(+), 85 deletions(-) delete mode 100644 apps/web/env-config.js 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/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/web/env-config.js b/apps/web/env-config.js deleted file mode 100644 index e69de29bb2d..00000000000 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", From 8a2352086a027f0ee5775f58f980ccb1c3064047 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Tue, 3 Dec 2024 17:08:36 +0200 Subject: [PATCH 04/10] fix(js): Remove @novu/shared dependency (#6906) --- packages/js/jest.config.cjs | 5 ++ packages/js/jest.setup.ts | 2 - packages/js/package.json | 1 - packages/js/src/api/http-client.ts | 119 ++++++++++++++++++++++++++ packages/js/src/api/inbox-service.ts | 31 +++---- packages/js/src/base-module.test.ts | 71 ++++++++++++++++ packages/js/src/global.d.ts | 11 +-- packages/js/src/novu.test.ts | 121 ++++++++++----------------- packages/js/src/novu.ts | 8 +- packages/js/src/session/session.ts | 1 - packages/js/tsconfig.json | 2 +- packages/js/tsup.config.ts | 5 ++ pnpm-lock.yaml | 3 - 13 files changed, 270 insertions(+), 110 deletions(-) create mode 100644 packages/js/src/api/http-client.ts create mode 100644 packages/js/src/base-module.test.ts 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..4d0cc27ba70 100644 --- a/packages/js/tsup.config.ts +++ b/packages/js/tsup.config.ts @@ -42,6 +42,11 @@ 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}"`, + }, }; export default defineConfig((config: Options) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6f796e7c77..2d697eeaed6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3638,9 +3638,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 From df292438b3ff674585b164a39a171dc2cfe93ea1 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 19:23:48 +0200 Subject: [PATCH 05/10] feat(dashboard): Getting started page (#7132) --- .cspell.json | 3 +- .source | 2 +- .../images/welcome/calendar_schedule.png | Bin 0 -> 4149 bytes .../public/images/welcome/compliance.png | Bin 0 -> 6554 bytes .../public/images/welcome/view_code.png | Bin 0 -> 2728 bytes apps/dashboard/src/api/integrations.ts | 8 + .../src/components/primitives/scroll-area.tsx | 38 ++++ .../side-navigation/free-trial-card.tsx | 2 +- .../getting-started-menu-item.tsx | 55 ++++++ .../side-navigation/navigation-link.tsx | 56 ++++++ .../side-navigation/side-navigation.tsx | 166 +++++++----------- .../src/components/welcome/icons.tsx | 49 ++++++ .../welcome/progress-section.animations.ts | 77 ++++++++ .../components/welcome/progress-section.tsx | 148 ++++++++++++++++ .../src/components/welcome/resources-list.tsx | 107 +++++++++++ .../src/components/workflow-list.tsx | 43 ++--- apps/dashboard/src/hooks/use-integrations.ts | 19 ++ .../src/hooks/use-onboarding-steps.ts | 115 ++++++++++++ apps/dashboard/src/hooks/use-workflows.ts | 32 ++++ apps/dashboard/src/main.tsx | 5 + apps/dashboard/src/pages/index.ts | 1 + .../src/pages/usecase-select-page.tsx | 4 +- apps/dashboard/src/pages/welcome-page.tsx | 93 ++++++++++ apps/dashboard/src/utils/query-keys.ts | 1 + apps/dashboard/src/utils/routes.ts | 1 + apps/dashboard/src/utils/telemetry.ts | 3 + .../shared/src/entities/integration/index.ts | 1 + .../integration/integration.interface.ts | 38 ++++ packages/shared/src/types/feature-flags.ts | 1 + 29 files changed, 930 insertions(+), 138 deletions(-) create mode 100644 apps/dashboard/public/images/welcome/calendar_schedule.png create mode 100644 apps/dashboard/public/images/welcome/compliance.png create mode 100644 apps/dashboard/public/images/welcome/view_code.png create mode 100644 apps/dashboard/src/api/integrations.ts create mode 100644 apps/dashboard/src/components/primitives/scroll-area.tsx create mode 100644 apps/dashboard/src/components/side-navigation/getting-started-menu-item.tsx create mode 100644 apps/dashboard/src/components/side-navigation/navigation-link.tsx create mode 100644 apps/dashboard/src/components/welcome/icons.tsx create mode 100644 apps/dashboard/src/components/welcome/progress-section.animations.ts create mode 100644 apps/dashboard/src/components/welcome/progress-section.tsx create mode 100644 apps/dashboard/src/components/welcome/resources-list.tsx create mode 100644 apps/dashboard/src/hooks/use-integrations.ts create mode 100644 apps/dashboard/src/hooks/use-onboarding-steps.ts create mode 100644 apps/dashboard/src/hooks/use-workflows.ts create mode 100644 apps/dashboard/src/pages/welcome-page.tsx create mode 100644 packages/shared/src/entities/integration/integration.interface.ts diff --git a/.cspell.json b/.cspell.json index 93c9b7b81ff..5dbaf060f75 100644 --- a/.cspell.json +++ b/.cspell.json @@ -708,7 +708,8 @@ "rstrip", "truncatewords", "xmlschema", - "jsonify" + "jsonify", + "touchpoint" ], "flagWords": [], "patterns": [ diff --git a/.source b/.source index e37e9d3f03e..61941c5bd9f 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit e37e9d3f03e2574565e00f8ed52c4ea11bfd37aa +Subproject commit 61941c5bd9f09f22a68620f2a51e36bcdc0f3abe 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 0000000000000000000000000000000000000000..7cdc9ef53d35d7669ec89a9a1f43382be596c2a1 GIT binary patch literal 4149 zcmV-55X$d~P)1ik(L}b}shJNPIK+gQO%% z)C?(!A^`TUN;|V7k`f6tx&ibgb|aX^>Si+*?xu>7(#r7TO;?|qcXrEu@=-lJ<%6uG?Nf|4YmXuT%a^OZ24 zb}{N~1>L?_62pK(4*Vx8U)kr9G^R93$@e{)&zDN`ixJfMC?+&+Rw`ckn#r4=OVf5m zQ79kAJm~!p3Nn5Q&s?2C2`@Be@BSvw31xrvq|&*)eL-W-q0h4+E&v|ja(N?7ABU1S zCWg5N%5dweG37~N7|J>JLcRjhVF7G(0j|MA!8{_*Gh&#*IbptU18(rzCZ4=I()@;+9%I-nY%=QD+=J|vEFnQ;;KR%mQqT!34vRox-2{bh8Z38m$w#MX_3k>ZgvB`}nTfn$t}y zJ&!1LaF12!EX(D9)lI2f6YYjj#}R`GR}>`_M)9CpEyp&4oX020d5duvXmk}`fM*!X zfpE1l(YvisBHU--f$xHj1C`k}5LKR79p!N-%|66urI{J4@uqywCW6YvPxDtljTd0G z8BYw;4_h`aj2B=9OEA+DrGrh6WFT7vm|=Rt8dw`Noy)LQpfUDLyDSjk?yHHj+4XMM zot?Mk;JW&4S+p^mnejdMVGXPaedaO2BA~h4qaAYYqg+CRx!NN=hnt%vxwwX=ssi_k zP`c$(FW15@^oT&!9^*4|^Z|_q#{&~Qu{^1P?{06{6Kep3Me`dAQtD7|OzC*gayG1# zGQ(lT9GGMJnSS&SX3!NYOz1vYIpQQ$u8u=D9?Ue)ch!9O6!86`C{}*kw&AGI(Yaix z8>{=3I~S%0V*o1zxa*t-Ik@icS63T(x zjx9j!N~s0Bg2nX3o<3uVD$CT*j4qj(2xA zWnQeISEOYR3Fh;Yql&P1?71#Qs8(Uw93Ku8^T0pZIyoPE9;hXb9Nj_q+s#tH^q_4Z zqUqeICZ?UfSt)g8QuTpN0{4Y_#$hy`D(QX=QEHz_v~e^q_pUyp5y<9Q4kd z2zz4b0Bb6yFXe!5L(iF1QagqzDXdt)0_JPr(kq>8L#J#J*YsYy8XVBC31ah)RV>de zPkJfT7LtLfW1oXvDK+7g0^cSO%zg4bx5^?yqaYli(%agQJIYq9@zaAKBj2-4olW@+ zkLd7kBN@t}?l}J#?@Ia0{o}I-A@LX-^T$a*)*U3Ro6l|X7-B9>6h$f$O6Q#0u)2_q zv`YbkP7;}AIjzHJl$=ssab%-}VOWl^4ATMzc=Qq1^U6PHxcG_4Iz-ELtsc@@rQ(8x zhKbhdQPlT+HM*jaScsW@&@a+7RefVNt7eWGYG5eKK3;ge=D@n@UkI_|%*b^;HNpZ6 zI0zl&1(@klyV=%)`}p`sp1&<_r(=<`zAm)ZEMg%AjUNQGUOkeKniz9oG#UiB6mS;g zsZa}UX*HGtv0krL_@L))1=xno3u^=zTOLy1QV=XD3fHRfb{DkRtez(52dmXe_Kz9O z2>iL~6Y5n9 zu*SmMHyddu0mcmnqb4DHp4|)!<-8YjAR)Cc7PsIG!NNNwL{)O9>-S@u_J#+8H{s$C?@7K?STFz-4v-yDS zd$jjFHs`l|9}?U45-s<*X@{2IOW~~r*{ovewHs}hY^$zo;oNZBP|b}3v3dX4dCdbw z)f>dY1scCL&M)Zr>Sp6}pZLA4Q@4DDgLB1^-<{pOVb9$A?lag}HB<>~_5LOgS<5+% zYwdo=qQhtIy|-z_o%U#Kvxw$rG}^abX*NV?_faN)hvTfxS-RVBVxuD4sXL?CQ;mfZ z-hT7`?G9rt%*kS09Fbxyl>(-Qlohp201f!F@xz(y}pp92}Mz?y!3qE&SG(HbmgSo zGx;##XfYI7idf2smW$<}pTp=&EvWE&#tTtu;d(`Z>=Q!@G2(c;7TUWMZr8ZOOCbjI z=EBH}YJM!d%r5YWhup$P9zr}6S>7)c@DjMz)Vz_;4WWc(NlzADrU^`F#M(wxq}mV! zBcwmFCdMw!QngHl;01uUF7=Zb~_b|T5-CcRtq{)*>L+&j`ef8Dfl(WLCSHGd(y?IM--n^j?K6puAfAux} z_lF-g$JKM>Oyg=Brg2R-%6cJ=1=v^$rG5AFl*LIxKmGV4y?y(J{`2pD(Jw#$Os`-6 zmR74rm8o+VoXhEUFUSasV{AejN?F&u*h@xG^6OWx=hxGa9pC4%4Z@>MPKKtx1oBx+p@bcb?#F^t<^I6te zMp%FiM!8JN7gnQd53ElT?`77pxXHOS*k=tA58GFwUtxvzW^OXBzmRcTLKE z5~AD(_FNWV_Z2G_J#TFyY4pmIdc)<7=!)U zA=d_XcOF=PO-4!|95UdH0S_Q-3FVbJ-7x1+YO{JN51|5CD*Vc=l7;uulzF)6DzE?> zil`IMS0)vHLWs+nxYA5MNWWbA=tA>CYrDz>W8pO&ceM%+{#Zw@a8Yc%Q9=yocG*mx za8>olL~7wQk(wBbRMl~gDBINYOTpb#YGCvE;-vDqr%9q#_=jdD+tN+jJaMw}i`*;& zPRD8lrOAe1eGIyo+ZMYO*s33*3Nx&$7_jhO7OX9-*WuN|6;GbL+f1!Vi(PF?+p?l{(Gn8PwFTq zr4vTcJH!7E!GuJ0WxVF&3+ zy=f;&zZ8=Wn4g-}AvG|3Cy*EG8iO`!fVqJ!#myK-1IhrC>t*_68<}AYh6R{m42A`G zV5aR$&bt^x!vZ`oo;TgCc9$1I2ZtRw1_l`#5L~aKzNWG&i#ohs9dpbK2@CKQKy0;7 zcGRO~^ny)jhA|k{z$bzHQZyCU=D|_PK{!VFBv^oZqFe+~LNZM`Uiw17{Sb?NbDyUf zePI*2Cuy3g0%9z@mjl^fq9|5BnSN*|z-UCB&8alwPBs)sNuw)HRTNz}G!x*0;yBVg zj+8a;U}*ekQOX1r7j#1-Nhy?hn##H5jQ0;2&G0;5)mP3nvl28B;6e#*mZi#kMmQtG z=mb*sA~Y2JR3{-Gbrrg-7Lb=IY}GT*C}HE&IY7t3 z9cN-*RKmM`g<(jZd_cPAQBu_ai5UV^me3gl&}V6)gxK@EO8JOwrHc^+*@f9UCbj0p z90LzUci~ght|~4kM!5%fyTnezJAS86L2&%L)%ozM14D$~%aazvz00000NkvXXu0mjf*`DY< literal 0 HcmV?d00001 diff --git a/apps/dashboard/public/images/welcome/compliance.png b/apps/dashboard/public/images/welcome/compliance.png new file mode 100644 index 0000000000000000000000000000000000000000..9062c7872ea6bd8c6d575ca129e63481a930291b GIT binary patch literal 6554 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 i0fT@60tNvC1k68|x`6X_HjXs_0000('/integrations'); + + return data; +} 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/side-navigation/free-trial-card.tsx b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx index 1ca715fed79..3f904360c61 100644 --- a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx +++ b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx @@ -25,7 +25,7 @@ export const FreeTrialCard = () => { return (
+ + + Getting started + + + + + + + {completedSteps}/{totalSteps} + + + + + ); +} 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 ( -
- {isFormValid && ( -
- -
- )} + + {isFormValid && ( + + + + )} +
diff --git a/apps/dashboard/src/components/icons/onboarding-arrow-left.tsx b/apps/dashboard/src/components/icons/onboarding-arrow-left.tsx index 976674e98b0..980a8356e7a 100644 --- a/apps/dashboard/src/components/icons/onboarding-arrow-left.tsx +++ b/apps/dashboard/src/components/icons/onboarding-arrow-left.tsx @@ -5,8 +5,8 @@ export function OnboardingArrowLeft(props: React.SVGProps) { 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/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/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/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/side-navigation/getting-started-menu-item.tsx b/apps/dashboard/src/components/side-navigation/getting-started-menu-item.tsx index 58bb8d96f6e..3271f31b4a9 100644 --- a/apps/dashboard/src/components/side-navigation/getting-started-menu-item.tsx +++ b/apps/dashboard/src/components/side-navigation/getting-started-menu-item.tsx @@ -1,19 +1,47 @@ +import { useUser } from '@clerk/clerk-react'; import { motion } from 'motion/react'; -import { RiQuestionLine, RiSparkling2Fill } from 'react-icons/ri'; +import { RiQuestionLine, RiSparkling2Fill, RiCloseLine } from 'react-icons/ri'; import { Badge } from '../primitives/badge'; import { buildRoute, ROUTES } from '@/utils/routes'; import { useEnvironment } from '@/context/environment/hooks'; import { useOnboardingSteps } from '../../hooks/use-onboarding-steps'; import { NavigationLink } from './navigation-link'; +import { useTelemetry } from '@/hooks/use-telemetry'; +import { TelemetryEvent } from '@/utils/telemetry'; +import { Button } from '../primitives/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { FeatureFlagsKeysEnum } from '@novu/shared'; export function GettingStartedMenuItem() { - const { totalSteps, completedSteps } = useOnboardingSteps(); - const { currentEnvironment } = useEnvironment(); + const { totalSteps, completedSteps, steps } = useOnboardingSteps(); const isGettingStartedEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_NEW_DASHBOARD_GETTING_STARTED_ENABLED); - if (!isGettingStartedEnabled) { + const { currentEnvironment } = useEnvironment(); + const { user } = useUser(); + const track = useTelemetry(); + + const allStepsCompleted = completedSteps === totalSteps; + + const handleClose = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + track(TelemetryEvent.WELCOME_MENU_HIDDEN, { + completedSteps: steps.filter((step) => step.status === 'completed').map((step) => step.id), + totalSteps, + allStepsCompleted, + }); + + await user?.update({ + unsafeMetadata: { + ...user.unsafeMetadata, + hideGettingStarted: true, + }, + }); + }; + + if (!isGettingStartedEnabled || user?.unsafeMetadata?.hideGettingStarted) { return null; } @@ -49,6 +77,31 @@ export function GettingStartedMenuItem() { {completedSteps}/{totalSteps} + + {allStepsCompleted && ( + + + + + + This will hide the Getting Started page + + + )} ); diff --git a/apps/dashboard/src/components/usecase-playground-header.tsx b/apps/dashboard/src/components/usecase-playground-header.tsx new file mode 100644 index 00000000000..4eed5b99e75 --- /dev/null +++ b/apps/dashboard/src/components/usecase-playground-header.tsx @@ -0,0 +1,38 @@ +import { RiArrowLeftSLine } from 'react-icons/ri'; +import { Button } from './primitives/button'; +import { useNavigate } from 'react-router-dom'; + +interface UsecasePlaygroundHeaderProps { + title: string; + description: string; + skipPath: string; + onSkip?: () => void; +} + +export function UsecasePlaygroundHeader({ title, description, skipPath, onSkip }: UsecasePlaygroundHeaderProps) { + const navigate = useNavigate(); + + const handleSkip = () => { + onSkip?.(); + navigate(skipPath); + }; + + return ( +
+
+ + +
+

{title}

+

{description}

+
+
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx new file mode 100644 index 00000000000..f8b1bf4ca42 --- /dev/null +++ b/apps/dashboard/src/components/welcome/framework-guides.instructions.tsx @@ -0,0 +1,297 @@ +import { RiAngularjsFill, RiJavascriptFill, RiNextjsFill, RiReactjsFill, RiRemixRunFill } from 'react-icons/ri'; +import { Language } from '../primitives/code-block'; + +export interface Framework { + name: string; + icon: JSX.Element; + selected?: boolean; + installSteps: InstallationStep[]; +} + +export interface InstallationStep { + title: string; + description: string; + code?: string; + codeLanguage: Language; + codeTitle?: string; + tip?: { + title?: string; + description: string | React.ReactNode; + }; +} + +export const customizationTip = { + title: 'Tip:', + description: ( + <> + You can customize your inbox to match your app theme,{' '} + + learn more + + . + + ), +}; + +export const commonInstallStep = (packageName: string): InstallationStep => ({ + title: 'Install the package', + description: `${packageName} is the package that powers the notification center.`, + code: `npm install ${packageName}`, + codeLanguage: 'shell', + codeTitle: 'Terminal', +}); + +export const frameworks: Framework[] = [ + { + name: 'Next.js', + icon: , + selected: true, + installSteps: [ + commonInstallStep('@novu/react'), + { + title: 'Add the inbox code to your Next.js app', + description: 'Novu uses the router hook to make your notifications navigatable in Next.js.', + code: `'use client'; + +import { Inbox } from '@novu/react'; +import { useRouter } from 'next/navigation'; + +function Novu() { + const router = useRouter(); + + return ( + router.push(path)} + /> + ); +}`, + codeLanguage: 'tsx', + codeTitle: 'Inbox.tsx', + tip: customizationTip, + }, + ], + }, + { + name: 'React', + icon: , + installSteps: [ + commonInstallStep('@novu/react'), + { + title: 'Add the inbox code to your React app', + description: 'Novu uses the onNavigate prop to handle notification clicks in React.', + code: `import { Inbox } from '@novu/react'; +import { useNavigate } from 'react-router-dom'; + +function Novu() { + const navigate = useNavigate(); + + return ( + navigate(path)} + /> + ); +}`, + codeLanguage: 'tsx', + codeTitle: 'Inbox.tsx', + tip: customizationTip, + }, + ], + }, + { + name: 'Remix', + icon: , + installSteps: [ + commonInstallStep('@novu/react'), + { + title: 'Add the inbox code to your Remix app', + description: 'Implement the notification center in your Remix application.', + code: `import { Inbox } from '@novu/react'; +import { useNavigate } from '@remix-run/react'; + +function Novu() { + const navigate = useNavigate(); + + return ( + navigate(path)} + /> + ); +}`, + codeLanguage: 'tsx', + codeTitle: 'Inbox.tsx', + tip: customizationTip, + }, + ], + }, + { + name: 'Native', + icon: , + installSteps: [ + commonInstallStep('@novu/react-native'), + { + title: 'Add the inbox code to your React Native app', + description: 'Implement the notification center in your React Native application.', + code: `import { NovuProvider } from '@novu/react-native'; +import { YourCustomInbox } from './Inbox'; + +function Layout() { + return ( + + + + ); +}`, + codeLanguage: 'tsx', + codeTitle: 'App.tsx', + }, + { + title: 'Build your custom inbox component', + description: 'Build your custom inbox component to use within your app.', + code: `import { + FlatList, + View, + Text, + ActivityIndicator, + RefreshControl, +} from "react-native"; +import { useNotifications, Notification } from "@novu/react-native"; + +export function YourCustomInbox() { + const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications(); + + const renderItem = ({ item }) => ( + + {item.body} + + ); + + const renderFooter = () => { + if (!hasMore) return null; + + return ( + + + + ); + }; + + const renderEmpty = () => ( + + No updates available + + ); + + if (isLoading) { + return ( + + + + ); + } + + return ( + item.id} + contentContainerStyle={styles.listContainer} + onEndReached={fetchMore} + onEndReachedThreshold={0.5} + ListFooterComponent={renderFooter} + ListEmptyComponent={renderEmpty} + refreshControl={ + + } + /> + ); +}`, + codeLanguage: 'tsx', + codeTitle: 'Inbox.tsx', + }, + ], + }, + { + name: 'Angular', + icon: , + installSteps: [ + commonInstallStep('@novu/js'), + { + title: 'Add the inbox code to your Angular app', + description: 'Currently, angular applications are supported with the Novu UI library.', + code: `import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { RouterOutlet } from '@angular/router'; +import { NovuUI } from '@novu/js/ui'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [RouterOutlet], + templateUrl: './app.component.html', + styleUrl: './app.component.css', +}) +export class AppComponent implements AfterViewInit { + @ViewChild('notificationInbox') notificationInbox!: ElementRef; + title = 'inbox-angular'; + + ngAfterViewInit() { + const novu = new NovuUI({ + options: { + applicationIdentifier: '123', + subscriberId: '456', + }, + }); + + novu.mountComponent({ + name: 'Inbox', + props: {}, + element: this.notificationInbox.nativeElement, + }); + } +}`, + codeLanguage: 'typescript', + tip: customizationTip, + }, + ], + }, + { + name: 'JavaScript', + icon: , + installSteps: [ + commonInstallStep('@novu/js'), + { + title: 'Add the inbox code to your JavaScript app', + description: + 'You can use the Novu UI library to implement the notification center in your vanilla JavaScript application or any other non-supported framework like Vue.', + code: `import { NovuUI } from '@novu/js/ui'; + + const novu = new NovuUI({ + options: { + applicationIdentifier: '123', + subscriberId: '456', + }, +}); + +novu.mountComponent({ + name: 'Inbox', + props: {}, + element: document.getElementById('notification-inbox'), +});`, + codeLanguage: 'typescript', + tip: customizationTip, + }, + ], + }, +]; diff --git a/apps/dashboard/src/components/welcome/framework-guides.tsx b/apps/dashboard/src/components/welcome/framework-guides.tsx new file mode 100644 index 00000000000..f1712cb042e --- /dev/null +++ b/apps/dashboard/src/components/welcome/framework-guides.tsx @@ -0,0 +1,133 @@ +import { motion, AnimatePresence } from 'motion/react'; +import { CodeBlock, Language } from '../primitives/code-block'; +import { InlineToast } from '../primitives/inline-toast'; +import { Framework, InstallationStep } from './framework-guides.instructions'; + +const fadeInAnimation = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + transition: { duration: 0.2 }, +}; + +const stepAnimation = (index: number) => ({ + initial: { opacity: 0, y: 20 }, + animate: { opacity: 1, y: 0 }, + transition: { + duration: 0.3, + delay: index * 0.15, + ease: 'easeOut', + }, +}); + +const numberAnimation = (index: number) => ({ + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1 }, + transition: { + duration: 0.2, + delay: index * 0.15 + 0.1, + ease: 'easeOut', + }, +}); + +const codeBlockAnimation = (index: number) => ({ + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + transition: { + duration: 0.3, + delay: index * 0.15 + 0.2, + ease: 'easeOut', + }, +}); + +function StepNumber({ index }: { index: number }) { + return ( + + {index + 1} + + ); +} + +function StepContent({ + title, + description, + tip, +}: { + title: string; + description: string; + tip?: InstallationStep['tip']; +}) { + return ( +
+
+ {title} +
+

{description}

+ {tip && } +
+ ); +} + +function StepCodeBlock({ + code, + language, + title, + index, +}: { + code: string; + language: Language; + title?: string; + index: number; +}) { + return ( + + + + ); +} + +function InstallationStepRow({ + step, + index, + frameworkName, +}: { + step: InstallationStep; + index: number; + frameworkName: string; +}) { + return ( + + + + {step.code && ( + + )} + + ); +} + +export function FrameworkInstructions({ framework }: { framework: Framework }) { + return ( + + +
+ {framework.installSteps.map((step, index) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx b/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx new file mode 100644 index 00000000000..f9e8d4de8cf --- /dev/null +++ b/apps/dashboard/src/components/welcome/inbox-connected-guide.tsx @@ -0,0 +1,81 @@ +import { RiCheckboxCircleFill, RiLoader3Line, RiNotification2Fill } from 'react-icons/ri'; +import { Loader2 } from 'lucide-react'; +import { Button } from '../primitives/button'; +import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers'; +import { useTriggerWorkflow } from '@/hooks/use-trigger-workflow'; +import { ROUTES } from '../../utils/routes'; +import { useNavigate } from 'react-router-dom'; +import { ONBOARDING_DEMO_WORKFLOW_ID } from '../../config'; + +interface InboxConnectedGuideProps { + subscriberId: string; +} + +export function InboxConnectedGuide({ subscriberId }: InboxConnectedGuideProps) { + const navigate = useNavigate(); + const { triggerWorkflow, isPending } = useTriggerWorkflow(); + + async function handleSendNotification() { + try { + await triggerWorkflow({ + name: ONBOARDING_DEMO_WORKFLOW_ID, + to: subscriberId, + payload: { + subject: '**Welcome to Inbox!**', + body: 'This is your first notification. Customize and explore more features.', + primaryActionLabel: 'Add to your app', + secondaryActionLabel: '', + }, + }); + + showSuccessToast('Notification sent successfully!'); + navigate(ROUTES.INBOX_EMBED_SUCCESS); + } catch (error) { + showErrorToast('Failed to send notification'); + } + } + + return ( + <> +
+
+
+ + Amazing, you did it 🎉 +
+

Now, let the magic happen! Click send notification below.

+
+
+ +
+
+
+
+ +
+ +
+
+ Let the magic happen 🪄 +
+

+ Now, trigger a notification to see it pop up in your app! If it doesn't appear, double-check that the + subscriberId matches as above. +

+
+ +
+
+
+
+
+ + ); +} diff --git a/apps/dashboard/src/components/welcome/inbox-embed.tsx b/apps/dashboard/src/components/welcome/inbox-embed.tsx new file mode 100644 index 00000000000..4663bfedaa9 --- /dev/null +++ b/apps/dashboard/src/components/welcome/inbox-embed.tsx @@ -0,0 +1,55 @@ +import { useState, useEffect } from 'react'; +import { useIntegrations } from '../../hooks/use-integrations'; +import { useFetchEnvironments } from '../../context/environment/hooks'; +import { useAuth } from '../../context/auth/hooks'; +import { ChannelTypeEnum } from '@novu/shared'; +import ReactConfetti from 'react-confetti'; +import { InboxConnectedGuide } from './inbox-connected-guide'; +import { InboxFrameworkGuide } from './inbox-framework-guide'; +import { useSearchParams } from 'react-router-dom'; + +export function InboxEmbed(): JSX.Element | null { + const [showConfetti, setShowConfetti] = useState(false); + const auth = useAuth(); + const { integrations } = useIntegrations({ refetchInterval: 1000, refetchOnWindowFocus: true }); + const { environments } = useFetchEnvironments({ organizationId: auth?.currentOrganization?._id }); + const [searchParams] = useSearchParams(); + + const currentEnvironment = environments?.find((env) => !env._parentId); + const subscriberId = auth?.currentUser?._id; + + const foundIntegration = integrations?.find( + (integration) => + integration._environmentId === environments?.[0]?._id && integration.channel === ChannelTypeEnum.IN_APP + ); + + const primaryColor = searchParams.get('primaryColor') || '#DD2450'; + const foregroundColor = searchParams.get('foregroundColor') || '#0E121B'; + + useEffect(() => { + if (foundIntegration?.connected) { + setShowConfetti(true); + const timer = setTimeout(() => setShowConfetti(false), 10000); + return () => clearTimeout(timer); + } + }, [foundIntegration?.connected]); + + if (!subscriberId) return null; + + return ( +
+ {showConfetti && } + + {!foundIntegration?.connected && ( + + )} + + {foundIntegration?.connected && } +
+ ); +} diff --git a/apps/dashboard/src/components/welcome/inbox-framework-guide.tsx b/apps/dashboard/src/components/welcome/inbox-framework-guide.tsx new file mode 100644 index 00000000000..4145f55e906 --- /dev/null +++ b/apps/dashboard/src/components/welcome/inbox-framework-guide.tsx @@ -0,0 +1,203 @@ +import { Loader } from 'lucide-react'; +import { Card, CardContent } from '../primitives/card'; +import { useState, useEffect } from 'react'; +import { IEnvironment } from '@novu/shared'; +import { API_HOSTNAME, WEBSOCKET_HOSTNAME } from '../../config'; +import { motion } from 'motion/react'; +import { Framework, frameworks } from './framework-guides.instructions'; +import { FrameworkInstructions } from './framework-guides'; + +const containerVariants = { + hidden: {}, + show: { + transition: { + staggerChildren: 0.05, + delayChildren: 0.1, + }, + }, +}; + +const cardVariants = { + hidden: { + opacity: 0, + y: 10, + }, + show: { + opacity: 1, + y: 0, + transition: { + duration: 0.2, + ease: 'easeOut', + }, + }, +}; + +const iconVariants = { + initial: { + scale: 1, + }, + hover: { + scale: 1.1, + transition: { + scale: { + duration: 0.2, + ease: 'easeOut', + }, + }, + }, +}; + +interface InboxFrameworkGuideProps { + currentEnvironment: IEnvironment | undefined; + subscriberId: string; + primaryColor: string; + foregroundColor: string; +} + +function getUrlProps(isDefaultApi: boolean, isDefaultWs: boolean): string { + const props = [ + ...(isDefaultApi ? [] : [`backendUrl="${API_HOSTNAME}"`]), + ...(isDefaultWs ? [] : [`socketUrl="${WEBSOCKET_HOSTNAME}"`]), + ]; + + return props.length ? '\n ' + props.join('\n ') : ''; +} + +function generateInboxComponent( + environmentIdentifier: string, + subscriberId: string, + urlProps: string, + primaryColor: string, + foregroundColor: string +): string { + return ` router.push(path)}${urlProps} + appearance={{ + variables: { + colorPrimary: "${primaryColor}", + colorForeground: "${foregroundColor}" + } + }} + />`; +} + +function generateNovuProvider(environmentIdentifier: string, subscriberId: string, urlProps: string): string { + return ` + + `; +} + +function updateFrameworkCode( + framework: Framework, + inboxComponent: string, + novuProvider: string, + environmentIdentifier: string, + subscriberId: string +): Framework { + return { + ...framework, + installSteps: framework.installSteps.map((step) => ({ + ...step, + code: step.code + ?.replace(//, inboxComponent) + ?.replace(//, novuProvider) + ?.replace(/YOUR_APP_ID/g, environmentIdentifier) + ?.replace(/YOUR_APPLICATION_IDENTIFIER/g, environmentIdentifier) + ?.replace(/YOUR_SUBSCRIBER_ID/g, subscriberId), + })), + }; +} + +export function InboxFrameworkGuide({ + currentEnvironment, + subscriberId, + primaryColor, + foregroundColor, +}: InboxFrameworkGuideProps) { + const [selectedFramework, setSelectedFramework] = useState(frameworks.find((f) => f.selected) || frameworks[0]); + + useEffect(() => { + if (!currentEnvironment?.identifier || !subscriberId) return; + + const isDefaultApi = API_HOSTNAME === 'https://api.novu.co'; + const isDefaultWs = WEBSOCKET_HOSTNAME === 'https://ws.novu.co'; + + const urlProps = getUrlProps(isDefaultApi, isDefaultWs); + const inboxComponent = generateInboxComponent( + currentEnvironment.identifier, + subscriberId, + urlProps, + primaryColor, + foregroundColor + ); + const novuProvider = generateNovuProvider(currentEnvironment.identifier, subscriberId, urlProps); + + const updatedFrameworks = frameworks.map((framework) => + updateFrameworkCode(framework, inboxComponent, novuProvider, currentEnvironment.identifier, subscriberId) + ); + + setSelectedFramework(updatedFrameworks.find((f) => f.name === selectedFramework.name) || updatedFrameworks[0]); + }, [currentEnvironment?.identifier, subscriberId, selectedFramework.name, primaryColor, foregroundColor]); + + function handleFrameworkSelect(framework: Framework) { + setSelectedFramework(framework); + } + + return ( + <> + +
+
+ + + Watching for Inbox Integration + +
+

You're just a couple steps away from your first notification.

+
+
+ + {/* Framework Cards */} + + {frameworks.map((framework) => ( + + handleFrameworkSelect(framework)} + className={`flex h-[100px] w-[100px] flex-col items-center justify-center border-none p-6 shadow-none hover:cursor-pointer ${ + framework.name === selectedFramework.name ? 'bg-neutral-100' : '' + }`} + > + + + {framework.icon} + + {framework.name} + + + + ))} + + +
+ +
+ + ); +} diff --git a/apps/dashboard/src/components/welcome/progress-section.tsx b/apps/dashboard/src/components/welcome/progress-section.tsx index b114f9e7974..5fd845d3b92 100644 --- a/apps/dashboard/src/components/welcome/progress-section.tsx +++ b/apps/dashboard/src/components/welcome/progress-section.tsx @@ -126,7 +126,6 @@ function getStepRoute(stepId: StepIdEnum, environmentSlug: string = '') { isLegacy: false, }; case StepIdEnum.CONNECT_EMAIL_PROVIDER: - case StepIdEnum.CONNECT_IN_APP_PROVIDER: case StepIdEnum.CONNECT_PUSH_PROVIDER: case StepIdEnum.CONNECT_CHAT_PROVIDER: case StepIdEnum.CONNECT_SMS_PROVIDER: @@ -134,6 +133,11 @@ function getStepRoute(stepId: StepIdEnum, environmentSlug: string = '') { path: LEGACY_ROUTES.INTEGRATIONS, isLegacy: true, }; + case StepIdEnum.CONNECT_IN_APP_PROVIDER: + return { + path: ROUTES.INBOX_EMBED, + isLegacy: false, + }; case StepIdEnum.INVITE_TEAM_MEMBER: return { path: LEGACY_ROUTES.INVITE_TEAM_MEMBERS, diff --git a/apps/dashboard/src/components/welcome/resources-list.tsx b/apps/dashboard/src/components/welcome/resources-list.tsx index 326a6c20bc4..9dac80be81e 100644 --- a/apps/dashboard/src/components/welcome/resources-list.tsx +++ b/apps/dashboard/src/components/welcome/resources-list.tsx @@ -8,7 +8,7 @@ import { TelemetryEvent } from '@/utils/telemetry'; export interface Resource { title: string; - duration: string; + duration?: string; image: string; url: string; } @@ -70,30 +70,32 @@ export function ResourcesList({ resources, title, icon }: ResourcesListProps) { - + {resources.map((resource, index) => ( handleResourceClick(resource)}> - + {resource.title} - +

{resource.title}

-
- - {resource.duration} -
+ {resource.duration && ( +
+ + {resource.duration} +
+ )}
diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx index fe39c5311f3..ea47e02c28e 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-body.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { Editor } from '@/components/primitives/editor'; -import { FormControl, FormField, FormItem, FormMessage } from '@/components/primitives/form/form'; +import { FormControl, FormField, FormItem } from '@/components/primitives/form/form'; import { InputField } from '@/components/primitives/input'; import { completions } from '@/utils/liquid-autocomplete'; import { parseStepVariablesToLiquidVariables } from '@/utils/parseStepVariablesToLiquidVariables'; @@ -41,7 +41,6 @@ export const InAppBody = () => { /> - {`Type {{ for variables, or wrap text in ** for bold.`} )} /> diff --git a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx index d9095a049d5..b38715e73ef 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/in-app/in-app-editor.tsx @@ -4,6 +4,7 @@ import { Notification5Fill } from '@/components/icons'; import { Separator } from '@/components/primitives/separator'; import { getComponentByType } from '@/components/workflow-editor/steps/component-utils'; import { InAppTabsSection } from '@/components/workflow-editor/steps/in-app/in-app-tabs-section'; +import { FormMessage } from '../../../primitives/form/form'; const avatarKey = 'avatar'; const subjectKey = 'subject'; @@ -40,7 +41,12 @@ export const InAppEditor = ({ uiSchema }: { uiSchema?: UiSchema }) => { {subject && getComponentByType({ component: subject.component })}
)} - {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/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 ( ({ queryKey: [QueryKeys.fetchIntegrations, currentEnvironment?._id], queryFn: getIntegrations, + refetchInterval, + refetchOnWindowFocus, }); return { diff --git a/apps/dashboard/src/hooks/use-onboarding-steps.ts b/apps/dashboard/src/hooks/use-onboarding-steps.ts index 43f08a38599..7dbbefad814 100644 --- a/apps/dashboard/src/hooks/use-onboarding-steps.ts +++ b/apps/dashboard/src/hooks/use-onboarding-steps.ts @@ -3,6 +3,7 @@ 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', @@ -10,7 +11,7 @@ export enum StepIdEnum { 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_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', @@ -46,7 +47,7 @@ function getProviderTitle(providerType: ChannelTypeEnum): string { function getProviderDescription(providerType: ChannelTypeEnum): string { return providerType === ChannelTypeEnum.IN_APP - ? 'Add an Inbox to your app' + ? 'Embed a full-featured Inbox in your app in minutes' : `Connect your provider to send ${providerType} notifications with Novu.`; } @@ -59,7 +60,7 @@ function isActiveIntegration(integration: IIntegration, providerType: ChannelTyp } export function useOnboardingSteps(): OnboardingStepsResult { - const { data: workflows } = useWorkflows(); + const workflows = useWorkflows(); const { organization } = useOrganization(); const { integrations } = useIntegrations(); @@ -67,6 +68,13 @@ export function useOnboardingSteps(): OnboardingStepsResult { 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; @@ -86,7 +94,7 @@ export function useOnboardingSteps(): OnboardingStepsResult { id: StepIdEnum.CREATE_A_WORKFLOW, title: 'Create a workflow', description: 'Workflows in Novu, orchestrate notifications across channels.', - status: workflows && workflows.totalCount > 0 ? 'completed' : 'in-progress', + status: hasCreatedWorkflow ? 'completed' : 'in-progress', }, { id: `connect-${providerType}-provider` as StepIdEnum, @@ -103,7 +111,7 @@ export function useOnboardingSteps(): OnboardingStepsResult { status: hasInvitedTeamMember ? 'completed' : 'pending', }, ], - [workflows, hasInvitedTeamMember, providerType, integrations] + [hasInvitedTeamMember, providerType, integrations, hasCreatedWorkflow] ); return { diff --git a/apps/dashboard/src/hooks/use-workflows.ts b/apps/dashboard/src/hooks/use-workflows.ts index 35f5e82411b..75028765a74 100644 --- a/apps/dashboard/src/hooks/use-workflows.ts +++ b/apps/dashboard/src/hooks/use-workflows.ts @@ -7,15 +7,18 @@ import { useEnvironment } from '../context/environment/hooks'; interface UseWorkflowsParams { limit?: number; offset?: number; + query?: string; } -export function useWorkflows({ limit = 12, offset = 0 }: UseWorkflowsParams = {}) { +export function useWorkflows({ limit = 12, offset = 0, query = '' }: UseWorkflowsParams = {}) { const { currentEnvironment } = useEnvironment(); const workflowsQuery = useQuery({ - queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset }], + queryKey: [QueryKeys.fetchWorkflows, currentEnvironment?._id, { limit, offset, query }], queryFn: async () => { - const { data } = await getV2<{ data: ListWorkflowResponse }>(`/workflows?limit=${limit}&offset=${offset}`); + const { data } = await getV2<{ data: ListWorkflowResponse }>( + `/workflows?limit=${limit}&offset=${offset}&query=${query}` + ); return data; }, placeholderData: keepPreviousData, diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index e6ff1b5141a..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, @@ -18,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(); @@ -50,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: , + }, ], }, { 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/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 bb3584c073f..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.WELCOME); + + if (selectedUseCases.includes(ChannelTypeEnum.IN_APP)) { + navigate(ROUTES.INBOX_USECASE); + } else { + navigate(ROUTES.WELCOME); + } }, onError: (error) => { console.error('Failed to update use cases:', error); @@ -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 index c7822192cd4..94ffe256671 100644 --- a/apps/dashboard/src/pages/welcome-page.tsx +++ b/apps/dashboard/src/pages/welcome-page.tsx @@ -11,31 +11,54 @@ import { TelemetryEvent } from '../utils/telemetry'; const helpfulResources: Resource[] = [ { - title: "Let's meet? Let's chat about notifications.", - duration: '15m meet', - image: 'calendar_schedule.png', - url: 'https://cal.com/novu/30min', + title: 'Documentation', + image: 'blog.svg', + url: 'https://docs.novu.co/', }, { title: 'Join our community on Discord', - duration: '30s', - image: 'calendar_schedule.png', + image: 'discord.svg', url: 'https://discord.gg/novu', }, { - title: 'Star us on GitHub', - duration: '10s for happiness', - image: 'view_code.png', + title: 'See our code on GitHub', + image: 'git.svg', url: 'https://github.com/novuhq/novu', }, { - title: 'Security & Compliance with Novu', - duration: '5m read', - image: 'compliance.png', + 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(); @@ -84,7 +107,7 @@ export function WelcomePage(): ReactElement { - } resources={helpfulResources} /> + } resources={learnResources} />
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/routes.ts b/apps/dashboard/src/utils/routes.ts index 0862cb077a0..340a1e204e2 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -2,8 +2,11 @@ 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', diff --git a/apps/dashboard/src/utils/telemetry.ts b/apps/dashboard/src/utils/telemetry.ts index cf4d53c9a7f..e03747cb752 100644 --- a/apps/dashboard/src/utils/telemetry.ts +++ b/apps/dashboard/src/utils/telemetry.ts @@ -7,6 +7,17 @@ export enum TelemetryEvent { USE_CASE_SKIPPED = 'Use Case Skipped', RESOURCE_CLICKED = 'Resource clicked - [Welcome]', WORKFLOWS_PAGE_VISIT = 'Workflows page visit', - WELCOME_PAGE_VIEWED = 'Welcome page viewed', - WELCOME_STEP_CLICKED = 'Welcome step clicked', + 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index b6f796e7c77..b9b5c56ea66 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) @@ -17730,6 +17739,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 +18377,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 +18684,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 +19242,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 +19452,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 +19522,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 +20221,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 +20326,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 +21369,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 +21503,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 +24177,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 +26327,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 +26547,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 +28309,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 +29414,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 +29813,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 +30179,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 +30192,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 +30841,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 +31189,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 +31340,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 +32778,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 +33233,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 +57676,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 +75585,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 +80026,8 @@ snapshots: tv4@1.3.0: {} + tween-functions@1.2.0: {} + tweetnacl@0.14.5: {} tweetnacl@1.0.3: {} From 184c54905b0ef71234344f35bee26c4d6fe43b9f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Wed, 4 Dec 2024 13:52:00 +0200 Subject: [PATCH 08/10] fix(api): session wrap connected true --- apps/api/src/app/inbox/usecases/session/session.usecase.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 f63b72fb0ec..5804dfd3602 100644 --- a/apps/api/src/app/inbox/usecases/session/session.usecase.ts +++ b/apps/api/src/app/inbox/usecases/session/session.usecase.ts @@ -111,7 +111,9 @@ export class Session { _environmentId: environment._id, }, { - connected: true, + $set: { + connected: true, + }, } ); } From d1c386585c731ec5b91b0384eb62157ea8045865 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 4 Dec 2024 14:16:59 +0200 Subject: [PATCH 09/10] Revert "Revert "fix(js): Remove @novu/shared dependency" (#7206)" This reverts commit adde8dd899d826abad39e9ab13c2b62f6321ba69. --- packages/js/jest.config.cjs | 5 ++ packages/js/jest.setup.ts | 2 - packages/js/package.json | 1 - packages/js/src/api/http-client.ts | 119 ++++++++++++++++++++++++++ packages/js/src/api/inbox-service.ts | 31 +++---- packages/js/src/base-module.test.ts | 71 ++++++++++++++++ packages/js/src/global.d.ts | 11 +-- packages/js/src/novu.test.ts | 121 ++++++++++----------------- packages/js/src/novu.ts | 8 +- packages/js/src/session/session.ts | 1 - packages/js/tsconfig.json | 2 +- packages/js/tsup.config.ts | 9 +- pnpm-lock.yaml | 3 - 13 files changed, 272 insertions(+), 112 deletions(-) create mode 100644 packages/js/src/api/http-client.ts create mode 100644 packages/js/src/base-module.test.ts 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/pnpm-lock.yaml b/pnpm-lock.yaml index b9b5c56ea66..e7421d217e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3647,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 From 17eaa7508459b0bf3e2c33f545042c834233bb07 Mon Sep 17 00:00:00 2001 From: Sokratis Vidros Date: Wed, 4 Dec 2024 14:50:03 +0200 Subject: [PATCH 10/10] fix(dashboard): Eliminate React warnings on SVG props --- .../src/components/icons/flags/eu.tsx | 2 +- .../src/components/icons/opt-in-arrow.tsx | 2 +- apps/dashboard/src/components/icons/plug.tsx | 36 +++++++++---------- .../src/components/icons/shield-zap.tsx | 6 ++-- 4 files changed, 23 insertions(+), 23 deletions(-) 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 ( - + ) => { ); 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" />