diff --git a/packages/marketing/.storybook/main.js b/packages/marketing/.storybook/main.js new file mode 100644 index 0000000000000..fdbf72873125e --- /dev/null +++ b/packages/marketing/.storybook/main.js @@ -0,0 +1 @@ +module.exports = require( '@automattic/calypso-storybook' )(); diff --git a/packages/marketing/CHANGELOG.md b/packages/marketing/CHANGELOG.md new file mode 100644 index 0000000000000..39dc6f8ea57d3 --- /dev/null +++ b/packages/marketing/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +The initial version. diff --git a/packages/marketing/README.md b/packages/marketing/README.md new file mode 100644 index 0000000000000..df6a355475d77 --- /dev/null +++ b/packages/marketing/README.md @@ -0,0 +1,3 @@ +# Marketing + +The package where the marketing components related to automattic products are located diff --git a/packages/marketing/jest.config.js b/packages/marketing/jest.config.js new file mode 100644 index 0000000000000..528ed4699cbd5 --- /dev/null +++ b/packages/marketing/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: '../../test/packages/jest-preset.js', + testEnvironment: 'jsdom', + testMatch: [ '/**/__tests__/**/*.[jt]s?(x)', '!**/.eslintrc.*' ], + transformIgnorePatterns: [ 'node_modules/(?!gridicons)(?!.*\\.svg)' ], +}; diff --git a/packages/marketing/package.json b/packages/marketing/package.json new file mode 100644 index 0000000000000..e0a5a430ca9e7 --- /dev/null +++ b/packages/marketing/package.json @@ -0,0 +1,47 @@ +{ + "name": "@automattic/marketing", + "version": "1.0.0", + "description": "The package where the marketing components related to Automattic products are located.", + "homepage": "https://github.com/Automattic/wp-calypso", + "license": "GPL-2.0-or-later", + "author": "Automattic Inc.", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "calypso:src": "src/index.tsx", + "sideEffects": [ + "*.css", + "*.scss" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Automattic/wp-calypso.git", + "directory": "packages/marketing" + }, + "publishConfig": { + "access": "public" + }, + "bugs": "https://github.com/Automattic/wp-calypso/issues", + "types": "dist/types", + "scripts": { + "clean": "tsc --build ./tsconfig.json ./tsconfig-cjs.json --clean && rm -rf dist", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json && copy-assets", + "prepack": "yarn run clean && yarn run build", + "watch": "tsc --build ./tsconfig.json --watch", + "storybook": "sb dev" + }, + "peerDependencies": { + "postcss": "^8.4.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@automattic/calypso-build": "workspace:^", + "@automattic/calypso-storybook": "workspace:^", + "@automattic/calypso-typescript-config": "workspace:^", + "@storybook/cli": "^7.6.19", + "@storybook/react": "^7.6.19", + "typescript": "^4.7.4", + "webpack": "^5.94.0" + } +} diff --git a/packages/marketing/src/index.tsx b/packages/marketing/src/index.tsx new file mode 100644 index 0000000000000..f0b2c4b00dcd1 --- /dev/null +++ b/packages/marketing/src/index.tsx @@ -0,0 +1 @@ +export { default as Nps } from './nps'; diff --git a/packages/marketing/src/nps/README.md b/packages/marketing/src/nps/README.md new file mode 100644 index 0000000000000..6ea7ea799acee --- /dev/null +++ b/packages/marketing/src/nps/README.md @@ -0,0 +1,3 @@ +# Nps + +The NPS(Net Promoter Score) survey form. diff --git a/packages/marketing/src/nps/index.stories.tsx b/packages/marketing/src/nps/index.stories.tsx new file mode 100644 index 0000000000000..e730889494474 --- /dev/null +++ b/packages/marketing/src/nps/index.stories.tsx @@ -0,0 +1,22 @@ +import Nps from '.'; +import type { Meta, StoryObj } from '@storybook/react'; + +type NpsStory = StoryObj< typeof Nps >; + +const meta: Meta< typeof Nps > = { + title: 'Nps v2', + component: Nps, +}; + +export default meta; + +export const Desktop: NpsStory = { + args: {}, +}; + +export const Mobile: NpsStory = { + args: {}, + parameters: { + viewport: { defaultViewport: 'mobile1' }, + }, +}; diff --git a/packages/marketing/src/nps/index.tsx b/packages/marketing/src/nps/index.tsx new file mode 100644 index 0000000000000..d1690c227e6d2 --- /dev/null +++ b/packages/marketing/src/nps/index.tsx @@ -0,0 +1,38 @@ +import { FormEvent } from 'react'; + +function NpsScore( score: number ) { + return ( + + ); +} + +export function Nps() { + const scores = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]; + + const handleSubmit = ( event: FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + }; + + return ( +
+

How are we doing so far?

+
+ + On a scale from 0–10, how likely are you to recommend WordPress.com to a friend or + colleague? + +
+ Not likely +
{ scores.map( ( score ) => NpsScore( score ) ) }
+ Definitely +
+
+ +
+ ); +} + +export default Nps; diff --git a/packages/marketing/src/nps/mutations/__tests__/use-submit-nps-survey-mutation.test.tsx b/packages/marketing/src/nps/mutations/__tests__/use-submit-nps-survey-mutation.test.tsx new file mode 100644 index 0000000000000..ac823906421f7 --- /dev/null +++ b/packages/marketing/src/nps/mutations/__tests__/use-submit-nps-survey-mutation.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/dom'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import wpcomRequest from 'wpcom-proxy-request'; +import { SubmitNpsSurveyResponse } from '../../types'; +import useSubmitNpsSurveyMutation from '../use-submit-nps-survey-mutation'; + +jest.mock( 'wpcom-proxy-request', () => ( { + __esModule: true, + default: jest.fn(), +} ) ); + +const queryClient = new QueryClient(); +const wrapper = ( { children } ) => ( + { children } +); + +// TODO +// Cover the failing cases +describe( 'useSubmitNpsSurveyMutation', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'successfully submit the score', async () => { + ( wpcomRequest as jest.Mock ).mockImplementation( (): Promise< SubmitNpsSurveyResponse > => { + return Promise.resolve( { + result: true, + } ); + } ); + + const { result } = renderHook( () => useSubmitNpsSurveyMutation( 'test-survey' ), { wrapper } ); + + result.current.mutate( { score: 10, feedback: 'profit!' } ); + + await waitFor( () => { + expect( result.current.isSuccess ).toBe( true ); + expect( result.current.data ).toEqual( { + result: true, + } ); + } ); + } ); +} ); diff --git a/packages/marketing/src/nps/mutations/use-submit-nps-survey-mutation.ts b/packages/marketing/src/nps/mutations/use-submit-nps-survey-mutation.ts new file mode 100644 index 0000000000000..bffbf83be975e --- /dev/null +++ b/packages/marketing/src/nps/mutations/use-submit-nps-survey-mutation.ts @@ -0,0 +1,25 @@ +import { useMutation } from '@tanstack/react-query'; +import wpcomRequest from 'wpcom-proxy-request'; +import { SubmitNpsSurveyParams, SubmitNpsSurveyResponse } from '../types'; + +// TBD +// Note that the current endpoint is designed to be able to take score/dismissed/feedback separately by one endpoint. We can consider to separate the endpoint, separate the hook, or separate the both later. Currently it justadheres the existing design to begin with. +function useSubmitNpsSurveyMutation( surveyName: string ) { + return useMutation( { + mutationFn: async ( { score, dismissed, feedback }: SubmitNpsSurveyParams ) => { + const response = await wpcomRequest< SubmitNpsSurveyResponse >( { + path: `/nps/${ surveyName }`, + method: 'POST', + body: { + score, + dismissed, + feedback, + }, + } ); + + return response; + }, + } ); +} + +export default useSubmitNpsSurveyMutation; diff --git a/packages/marketing/src/nps/queries/__tests__/use-check-nps-survey-eligibility.test.tsx b/packages/marketing/src/nps/queries/__tests__/use-check-nps-survey-eligibility.test.tsx new file mode 100644 index 0000000000000..e049675800449 --- /dev/null +++ b/packages/marketing/src/nps/queries/__tests__/use-check-nps-survey-eligibility.test.tsx @@ -0,0 +1,48 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { waitFor } from '@testing-library/dom'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import wpcomRequest from 'wpcom-proxy-request'; +import { NpsEligibilityApiResponse } from '../../types'; +import useCheckNpsSurveyEligibility from '../use-check-nps-survey-eligibility'; + +jest.mock( 'wpcom-proxy-request', () => ( { + __esModule: true, + default: jest.fn(), +} ) ); + +const queryClient = new QueryClient(); +const wrapper = ( { children } ) => ( + { children } +); + +// TODO +// There are more cases can be covered here. e.g. non-eligible on success, a failed query, etc. +describe( 'use-check-nps-survey-eligibility', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + test( 'returns eligible on a successful query', async () => { + ( wpcomRequest as jest.Mock ).mockImplementation( (): Promise< NpsEligibilityApiResponse > => { + return Promise.resolve( { + display_survey: true, + seconds_until_eligible: 0, + has_available_concierge_sessions: false, + } ); + } ); + + const { result } = renderHook( () => useCheckNpsSurveyEligibility( 'test-survey-name' ), { + wrapper, + } ); + + await waitFor( () => { + expect( result.current.isSuccess ).toBe( true ); + expect( result.current.data ).toEqual( { + displaySurvey: true, + secondsUntilEligible: 0, + hasAvailableConciergeSessions: false, + } ); + } ); + } ); +} ); diff --git a/packages/marketing/src/nps/queries/use-check-nps-survey-eligibility.ts b/packages/marketing/src/nps/queries/use-check-nps-survey-eligibility.ts new file mode 100644 index 0000000000000..d92ab20088705 --- /dev/null +++ b/packages/marketing/src/nps/queries/use-check-nps-survey-eligibility.ts @@ -0,0 +1,24 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import wpcomRequest from 'wpcom-proxy-request'; +import { NpsEligibility, NpsEligibilityApiResponse } from '../types'; + +function useCheckNpsSurveyEligibility( surveyName: string ): UseQueryResult< NpsEligibility > { + return useQuery( { + queryKey: [ surveyName ], + queryFn: async (): Promise< NpsEligibility > => { + const response: NpsEligibilityApiResponse = await wpcomRequest( { + path: '/nps', + query: surveyName, + apiVersion: '1.2', + } ); + + return { + displaySurvey: response.display_survey, + secondsUntilEligible: response.seconds_until_eligible, + hasAvailableConciergeSessions: response.has_available_concierge_sessions, + }; + }, + } ); +} + +export default useCheckNpsSurveyEligibility; diff --git a/packages/marketing/src/nps/types.ts b/packages/marketing/src/nps/types.ts new file mode 100644 index 0000000000000..fd0d476b0586b --- /dev/null +++ b/packages/marketing/src/nps/types.ts @@ -0,0 +1,24 @@ +// TBD +// Currently the following data structures simply adhere the response of the current endpoint. +// However, it likely can be simplified. +export interface NpsEligibility { + displaySurvey: boolean; + secondsUntilEligible: number; + hasAvailableConciergeSessions: boolean; +} + +export interface NpsEligibilityApiResponse { + display_survey: boolean; + seconds_until_eligible: number; + has_available_concierge_sessions: boolean; +} + +export interface SubmitNpsSurveyResponse { + result: boolean; +} + +export interface SubmitNpsSurveyParams { + score?: number; + dismissed?: boolean; + feedback?: string; +} diff --git a/packages/marketing/tsconfig-cjs.json b/packages/marketing/tsconfig-cjs.json new file mode 100644 index 0000000000000..a3e788252a240 --- /dev/null +++ b/packages/marketing/tsconfig-cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist/cjs" + } +} diff --git a/packages/marketing/tsconfig.json b/packages/marketing/tsconfig.json new file mode 100644 index 0000000000000..ce1866d68137e --- /dev/null +++ b/packages/marketing/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@automattic/calypso-typescript-config/ts-package.json", + "compilerOptions": { + "declarationDir": "dist/types", + "outDir": "dist/esm", + "rootDir": "src", + "types": [] + }, + "include": [ "src", "src/*.json" ], + "exclude": [ "**/__tests__/*", "**/__mocks__/*" ] +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index b06ddca9a17c4..0db17db5218e9 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -40,6 +40,7 @@ { "path": "./languages" }, { "path": "./launchpad" }, { "path": "./launchpad-navigator" }, + { "path": "./marketing" }, { "path": "./mini-cart" }, { "path": "./odie-client" }, { "path": "./onboarding" }, diff --git a/yarn.lock b/yarn.lock index d74183831903e..f621ee59a7ad9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1373,6 +1373,25 @@ __metadata: languageName: unknown linkType: soft +"@automattic/marketing@workspace:packages/marketing": + version: 0.0.0-use.local + resolution: "@automattic/marketing@workspace:packages/marketing" + dependencies: + "@automattic/calypso-build": "workspace:^" + "@automattic/calypso-storybook": "workspace:^" + "@automattic/calypso-typescript-config": "workspace:^" + "@storybook/cli": "npm:^7.6.19" + "@storybook/react": "npm:^7.6.19" + typescript: "npm:^4.7.4" + webpack: "npm:^5.94.0" + peerDependencies: + postcss: ^8.4.5 + react: ^18.3.1 + react-dom: ^18.3.1 + tslib: ^2.3.0 + languageName: unknown + linkType: soft + "@automattic/material-design-icons@workspace:^, @automattic/material-design-icons@workspace:packages/material-design-icons": version: 0.0.0-use.local resolution: "@automattic/material-design-icons@workspace:packages/material-design-icons" @@ -33212,6 +33231,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^4.7.4": + version: 4.9.5 + resolution: "typescript@npm:4.9.5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 5f6cad2e728a8a063521328e612d7876e12f0d8a8390d3b3aaa452a6a65e24e9ac8ea22beb72a924fd96ea0a49ea63bb4e251fb922b12eedfb7f7a26475e5c56 + languageName: node + linkType: hard + "typescript@npm:^5.3.3": version: 5.3.3 resolution: "typescript@npm:5.3.3" @@ -33222,6 +33251,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^4.7.4#optional!builtin": + version: 4.9.5 + resolution: "typescript@patch:typescript@npm%3A4.9.5#optional!builtin::version=4.9.5&hash=289587" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: e3333f887c6829dfe0ab6c1dbe0dd1e3e2aeb56c66460cb85c5440c566f900c833d370ca34eb47558c0c69e78ced4bfe09b8f4f98b6de7afed9b84b8d1dd06a1 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.3.3#optional!builtin": version: 5.3.3 resolution: "typescript@patch:typescript@npm%3A5.3.3#optional!builtin::version=5.3.3&hash=e012d7"