diff --git "a/\\" "b/\\" new file mode 100644 index 000000000..bf2a31dea --- /dev/null +++ "b/\\" @@ -0,0 +1,58 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { BackdropDialog, Button } from '@monkvision/common-ui-web'; +import { PhotoCaptureHUDTutorial, PhotoCaptureHUDTutorialProps } from '../../../src'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { TutorialViews } from '../../../src/PhotoCapture/hooks'; + +const BACKDROP_TEST_ID = 'backdrop'; +const translationPrefix = 'photo.hud.tutorial'; + +function createProps(): PhotoCaptureHUDTutorialProps { + return { + tutorialView: TutorialViews.WELCOME, + onTutorialNext: jest.fn(), + allowSkipTutorial: false, + enableSightGuidelines: true, + enableSightTutorial: true, + sightGuidelines: [], + sightId: 'test-sight-id', + }; +} + +describe('PhotoCaptureHUDTutorial component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not be shown if tutorialView is null', () => { + const props = createProps(); + render(); + + expect(screen.queryByTestId(BACKDROP_TEST_ID)).toBeNull(); + }); + + it('should be shown if tutorialView is set', () => { + const props = createProps(); + render(); + + expect(screen.queryByTestId(BACKDROP_TEST_ID)).toBeNull(); + }); + + it('handles the next button click correctly', () => { + const props = createProps(); + render(); + + const buttonProps = (Button as unknown as jest.Mock).mock.calls[1][0]; + buttonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.GUIDELINE); + }); + + it('should render with the correct props when tutorialView is null', () => { + const props = createProps(); + const { unmount } = render(); + + expect(Button).toBeCalled(); + + unmount(); + }); +}); diff --git a/apps/demo-app/src/local-config.json b/apps/demo-app/src/local-config.json index 62cde5f45..fec061484 100644 --- a/apps/demo-app/src/local-config.json +++ b/apps/demo-app/src/local-config.json @@ -1,4 +1,6 @@ { + "id": "demo-app-dev", + "description": "Config for the dev Demo App.", "allowSkipRetake": true, "enableAddDamage": true, "enableSightGuidelines": true, @@ -11,6 +13,9 @@ }, "apiDomain": "api.preview.monk.ai/v1", "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", + "enableTutorial": "enabled", + "allowSkipTutorial": true, + "enableSightTutorial": false, "startTasksOnComplete": true, "showCloseButton": false, "enforceOrientation": "landscape", diff --git a/apps/drive-app/src/local-config.json b/apps/drive-app/src/local-config.json index e2706b5ac..1bb9ec8a3 100644 --- a/apps/drive-app/src/local-config.json +++ b/apps/drive-app/src/local-config.json @@ -12,6 +12,9 @@ }, "apiDomain": "api.preview.monk.ai/v1", "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", + "enableTutorial": "first_time_only", + "allowSkipTutorial": false, + "enableSightTutorial": false, "startTasksOnComplete": true, "showCloseButton": false, "enforceOrientation": "landscape", diff --git a/apps/lux-demo-app/src/local-config.json b/apps/lux-demo-app/src/local-config.json index b087d72f0..81d766ec3 100644 --- a/apps/lux-demo-app/src/local-config.json +++ b/apps/lux-demo-app/src/local-config.json @@ -10,6 +10,9 @@ }, "apiDomain": "api.preview.monk.ai/v1", "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", + "enableTutorial": "enabled", + "allowSkipTutorial": true, + "enableSightTutorial": false, "startTasksOnComplete": true, "showCloseButton": false, "enforceOrientation": "landscape", diff --git a/apps/renault-demo-app/src/local-config.json b/apps/renault-demo-app/src/local-config.json index dd77163a1..7585c2641 100644 --- a/apps/renault-demo-app/src/local-config.json +++ b/apps/renault-demo-app/src/local-config.json @@ -10,6 +10,9 @@ }, "apiDomain": "api.preview.monk.ai/v1", "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", + "enableTutorial": "enabled", + "allowSkipTutorial": true, + "enableSightTutorial": false, "startTasksOnComplete": true, "showCloseButton": false, "enforceOrientation": "landscape", diff --git a/documentation/src/utils/schemas.ts b/documentation/src/utils/schemas.ts index 897ce8d21..0cab6e88d 100644 --- a/documentation/src/utils/schemas.ts +++ b/documentation/src/utils/schemas.ts @@ -5,6 +5,7 @@ import { CompressionFormat, DeviceOrientation, MonkApiPermission, + PhotoCaptureTutorialOption, SteeringWheelPosition, TaskName, VehicleType, @@ -230,6 +231,9 @@ export const LiveConfigSchema = z enableAddDamage: z.boolean().optional(), enableSightGuidelines: z.boolean().optional(), sightGuidelines: z.array(SightGuidelineSchema).optional(), + enableTutorial: z.nativeEnum(PhotoCaptureTutorialOption).optional(), + allowSkipTutorial: z.boolean().optional(), + enableSightTutorial: z.boolean().optional(), defaultVehicleType: z.nativeEnum(VehicleType), allowManualLogin: z.boolean(), allowVehicleTypeSelection: z.boolean(), diff --git a/packages/inspection-capture-web/README.md b/packages/inspection-capture-web/README.md index 3cd110e52..44e50cd83 100644 --- a/packages/inspection-capture-web/README.md +++ b/packages/inspection-capture-web/README.md @@ -60,33 +60,38 @@ export function MonkPhotoCapturePage({ authToken }) { } ``` -| Prop | Type | Description | Required | Default Value | -|------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-----------------------------| -| sights | Sight[] | The list of Sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` package. | ✔️ | | -| inspectionId | string | The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as the one described in the auth token in the `apiConfig` prop. | ✔️ | | -| apiConfig | ApiConfig | The api config used to communicate with the API. Make sure that the user described in the auth token is the same one as the one that created the inspection provided in the `inspectionId` prop. | ✔️ | | -| onClose | `() => void` | Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be displayed on the screen. | | | -| onComplete | `() => void` | Callback called when inspection capture is complete. | | | -| lang | string | null | The language to be used by this component. | | `'en'` | -| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | | -| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` | -| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` | -| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` | -| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` | -| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every Sight of the inspection. | | | -| tasksBySight | `Record` | Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the sight will be used. | | | -| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` | -| quality | `number` | Value indicating image quality for the compression output. | | `0.6` | -| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` | -| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` | -| allowSkipRetake | `boolean` | If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if some pictures are not compliant. | | `false` | -| enableCompliance | `boolean` | Indicates if compliance checks should be enabled or not. | | `true` | -| enableCompliancePerSight | `string[]` | Array of Sight IDs that indicates for which sight IDs the compliance should be enabled. | | | -| complianceIssues | `ComplianceIssue[]` | If compliance checks are enabled, this property can be used to select a list of compliance issues to check. | | `DEFAULT_COMPLIANCE_ISSUES` | -| complianceIssuesPerSight | `Record` | A map associating Sight IDs to a list of compliance issues to check. | | | -| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` | -| customComplianceThresholds | `CustomComplianceThresholds` | Custom thresholds that can be used to modify the strictness of the compliance for certain compliance issues. | | | -| customComplianceThresholdsPerSight | `Record` | A map associating Sight IDs to custom compliance thresholds. | | | -| validateButtonLabel | `string` | Custom label for validate button in gallery view. | | | -| enableSightGuideline | `boolean` | Boolean indicating whether the sight guideline feature is enabled. If disabled, the guideline text will be hidden. | | `true` | -| sightGuidelines | `sightGuideline[]` | A collection of sight guidelines in different language with a list of sightIds associate to it. | | | +| Prop | Type | Description | Required | Default Value | +|------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------| +| sights | Sight[] | The list of Sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` package. | ✔️ | | +| inspectionId | string | The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as the one described in the auth token in the `apiConfig` prop. | ✔️ | | +| apiConfig | ApiConfig | The api config used to communicate with the API. Make sure that the user described in the auth token is the same one as the one that created the inspection provided in the `inspectionId` prop. | ✔️ | | +| onClose | `() => void` | Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be displayed on the screen. | | | +| onComplete | `() => void` | Callback called when inspection capture is complete. | | | +| lang | string | null | The language to be used by this component. | | `'en'` | +| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | | +| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` | +| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` | +| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` | +| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` | +| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every Sight of the inspection. | | | +| tasksBySight | `Record` | Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the sight will be used. | | | +| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` | +| quality | `number` | Value indicating image quality for the compression output. | | `0.6` | +| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` | +| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` | +| allowSkipRetake | `boolean` | If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if some pictures are not compliant. | | `false` | +| enableCompliance | `boolean` | Indicates if compliance checks should be enabled or not. | | `true` | +| enableCompliancePerSight | `string[]` | Array of Sight IDs that indicates for which sight IDs the compliance should be enabled. | | | +| complianceIssues | `ComplianceIssue[]` | If compliance checks are enabled, this property can be used to select a list of compliance issues to check. | | `DEFAULT_COMPLIANCE_ISSUES` | +| complianceIssuesPerSight | `Record` | A map associating Sight IDs to a list of compliance issues to check. | | | +| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` | +| customComplianceThresholds | `CustomComplianceThresholds` | Custom thresholds that can be used to modify the strictness of the compliance for certain compliance issues. | | | +| customComplianceThresholdsPerSight | `Record` | A map associating Sight IDs to custom compliance thresholds. | | | +| validateButtonLabel | `string` | Custom label for validate button in gallery view. | | | +| enableSightGuideline | `boolean` | Boolean indicating whether the sight guideline feature is enabled. If disabled, the guideline text will be hidden. | | `true` | +| sightGuidelines | `sightGuideline[]` | A collection of sight guidelines in different language with a list of sightIds associate to it. | | | +| enableTutorial | `PhotoCaptureTutorialOption` | Options for displaying the photo capture tutorial. | | `PhotoCaptureTutorialOption.FIRST_TIME_ONLY` | +| allowSkipTutorial | `boolean` | Boolean indicating if the user can skip the PhotoCapture tutorial. | | `true` | +| thumbnailDomain | `string` | The API domain used to communicate with the resize micro service. | ✔️ | | + + diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 9b60fdca7..a421f6811 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -22,6 +22,7 @@ import { ComplianceOptions, CompressionOptions, DeviceOrientation, + PhotoCaptureTutorialOption, Sight, } from '@monkvision/types'; import { useState } from 'react'; @@ -35,6 +36,7 @@ import { useComplianceAnalytics, usePhotoCaptureImages, usePhotoCaptureSightState, + usePhotoCaptureTutorial, usePictureTaken, useStartTasksOnComplete, useTracking, @@ -61,6 +63,9 @@ export interface PhotoCaptureProps | 'sightGuidelines' | 'enableSightGuidelines' | 'thumbnailDomain' + | 'enableTutorial' + | 'allowSkipTutorial' + | 'enableSightTutorial' >, Partial, Partial { @@ -127,6 +132,9 @@ export function PhotoCapture({ allowSkipRetake = false, enableAddDamage = true, sightGuidelines, + enableTutorial = PhotoCaptureTutorialOption.FIRST_TIME_ONLY, + allowSkipTutorial = true, + enableSightTutorial = true, enableSightGuidelines = true, useAdaptiveImageQuality = true, lang, @@ -181,6 +189,9 @@ export function PhotoCapture({ complianceOptions, setIsInitialInspectionFetched, }); + const { tutorialView, handleTutorialView } = usePhotoCaptureTutorial({ + enableTutorial, + }); const { isBadConnectionWarningDialogDisplayed, closeBadConnectionWarningDialog, @@ -259,6 +270,10 @@ export function PhotoCapture({ enableAddDamage, sightGuidelines, enableSightGuidelines, + tutorialView, + onTutorialNext: handleTutorialView, + allowSkipTutorial, + enableSightTutorial, }; return ( diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx index 20ab2e12f..f2f78d590 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx @@ -7,16 +7,25 @@ import { LoadingState } from '@monkvision/common'; import { useAnalytics } from '@monkvision/analytics'; import { PhotoCaptureHUDButtons } from './PhotoCaptureHUDButtons'; import { usePhotoCaptureHUDStyle } from './hooks'; -import { PhotoCaptureMode } from '../hooks'; +import { PhotoCaptureMode, TutorialViews } from '../hooks'; import { PhotoCaptureHUDOverlay } from './PhotoCaptureHUDOverlay'; import { PhotoCaptureHUDElements } from './PhotoCaptureHUDElements'; +import { PhotoCaptureHUDTutorial } from './PhotoCaptureHUDTutorial'; /** * Props of the PhotoCaptureHUD component. */ export interface PhotoCaptureHUDProps extends CameraHUDProps, - Pick { + Pick< + CaptureAppConfig, + | 'enableSightGuidelines' + | 'sightGuidelines' + | 'enableAddDamage' + | 'showCloseButton' + | 'allowSkipTutorial' + | 'enableSightTutorial' + > { /** * The inspection ID. */ @@ -45,6 +54,14 @@ export interface PhotoCaptureHUDProps * Global loading state of the PhotoCapture component. */ loading: LoadingState; + /** + * The current tutorial view in PhotoCapture component. + */ + tutorialView: TutorialViews | null; + /** + * Callback called when the user clicks on "Next" button. + */ + onTutorialNext: (view: TutorialViews | null) => void; /** * Callback called when the user manually select a new sight. */ @@ -74,12 +91,6 @@ export interface PhotoCaptureHUDProps * displayed. */ onClose?: () => void; - /** - * Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. - * - * @default false - */ - showCloseButton?: boolean; /** * The current images taken by the user (ignoring retaken pictures etc.). */ @@ -113,6 +124,10 @@ export function PhotoCaptureHUD({ enableAddDamage, sightGuidelines, enableSightGuidelines, + tutorialView, + allowSkipTutorial, + onTutorialNext, + enableSightTutorial, }: PhotoCaptureHUDProps) { const { t } = useTranslation(); const [showCloseModal, setShowCloseModal] = useState(false); @@ -154,6 +169,8 @@ export function PhotoCaptureHUD({ enableAddDamage={enableAddDamage} sightGuidelines={sightGuidelines} enableSightGuidelines={enableSightGuidelines} + tutorialView={tutorialView} + onTutorialNext={onTutorialNext} /> setShowCloseModal(false)} onConfirm={handleCloseConfirm} /> + ); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx index 6da4ea3f2..553353406 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx @@ -1,5 +1,5 @@ import { CaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/types'; -import { PhotoCaptureMode } from '../../hooks'; +import { PhotoCaptureMode, TutorialViews } from '../../hooks'; import { PhotoCaptureHUDElementsSight } from '../PhotoCaptureHUDElementsSight'; import { PhotoCaptureHUDElementsAddDamage1stShot } from '../PhotoCaptureHUDElementsAddDamage1stShot'; import { PhotoCaptureHUDElementsAddDamage2ndShot } from '../PhotoCaptureHUDElementsAddDamage2ndShot'; @@ -25,6 +25,14 @@ export interface PhotoCaptureHUDElementsProps * The current mode of the component. */ mode: PhotoCaptureMode; + /** + * The current tutorial view in PhotoCapture component. + */ + tutorialView: TutorialViews | null; + /** + * Callback called when the user clicks on "Next" button. + */ + onTutorialNext: (view: TutorialViews | null) => void; /** * Callback called when the user presses the Add Damage button. */ @@ -80,6 +88,7 @@ export function PhotoCaptureHUDElements(params: PhotoCaptureHUDElementsProps) { enableAddDamage={params.enableAddDamage} sightGuidelines={params.sightGuidelines} enableSightGuidelines={params.enableSightGuidelines} + tutorialView={params.tutorialView} /> ); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.styles.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.styles.ts index b4981f61d..43b79cf66 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.styles.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.styles.ts @@ -15,7 +15,6 @@ export const styles: Styles = { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', - zIndex: 9, }, elementsContainerPortrait: { __media: { portrait: true }, diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.tsx index ff6e6cbb4..2700e09c9 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.tsx @@ -4,7 +4,7 @@ import { styles } from './PhotoCaptureHUDElementsSight.styles'; import { AddDamageButton } from './AddDamageButton'; import { PhotoCaptureHUDElementsSightProps, usePhotoCaptureHUDSightPreviewStyle } from './hooks'; import { PhotoCaptureHUDCounter } from '../PhotoCaptureHUDCounter'; -import { PhotoCaptureMode } from '../../hooks'; +import { PhotoCaptureMode, TutorialViews } from '../../hooks'; import { SightGuideline } from './SightGuideline'; /** @@ -23,38 +23,43 @@ export function PhotoCaptureHUDElementsSight({ enableAddDamage, sightGuidelines, enableSightGuidelines, + tutorialView, }: PhotoCaptureHUDElementsSightProps) { const style = usePhotoCaptureHUDSightPreviewStyle({ previewDimensions }); + const showSight = previewDimensions && (!tutorialView || tutorialView === TutorialViews.SIGHT); + return (
- {previewDimensions && } -
-
- - -
-
- - + {showSight && } + {!tutorialView && ( +
+
+ + +
+
+ + +
-
+ )}
); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts index f2d94d72a..65154d59f 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts @@ -2,6 +2,7 @@ import { CaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/typ import { useResponsiveStyle } from '@monkvision/common'; import { CSSProperties } from 'react'; import { styles } from './PhotoCaptureHUDElementsSight.styles'; +import { TutorialViews } from '../../hooks'; /** * Props of the PhotoCaptureHUDElementsSight component. @@ -16,6 +17,10 @@ export interface PhotoCaptureHUDElementsSightProps * The currently selected sight in the PhotoCapture component : the sight that the user needs to capture. */ selectedSight: Sight; + /** + * The current tutorial view in PhotoCapture component. + */ + tutorialView: TutorialViews | null; /** * Callback called when the user manually select a new sight. */ diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/index.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/index.ts index df19a58a6..60061b684 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/index.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/index.ts @@ -1,4 +1,5 @@ export * from './SightSlider'; export * from './AddDamageButton'; +export * from './SightGuideline'; export { PhotoCaptureHUDElementsSight } from './PhotoCaptureHUDElementsSight'; export { type PhotoCaptureHUDElementsSightProps } from './hooks'; diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.styles.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.styles.ts new file mode 100644 index 000000000..97c1cf202 --- /dev/null +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.styles.ts @@ -0,0 +1,66 @@ +import { Styles } from '@monkvision/types'; +import { PHOTO_CAPTURE_HUD_BUTTONS_BAR_WIDTH } from '../PhotoCaptureHUDButtons/PhotoCaptureHUDButtons.styles'; + +export const styles: Styles = { + backdropContainer: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + transition: 'opacity 0.5s ease-out', + backgroundColor: `rgba(0, 0, 0, 0.5)`, + }, + elementsContainer: { + display: 'flex', + flexDirection: 'column', + position: 'fixed', + width: `calc(98% - (${PHOTO_CAPTURE_HUD_BUTTONS_BAR_WIDTH * 4}px))`, + top: '10px', + bottom: '40px', + justifyContent: 'space-between', + alignItems: 'center', + }, + topContainer: { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: '3px', + }, + buttonsContainer: { + display: 'flex', + width: '100%', + justifyContent: 'space-between', + gap: '3px', + }, + closeButtonTwin: { + padding: '22px', + }, + closeButton: { + width: '44px', + height: '44px', + }, + text: { + display: 'flex', + flexDirection: 'column', + textAlign: 'center', + }, + title: { + fontWeight: 'bold', + fontSize: '20px', + paddingBottom: '5px', + }, + arrowGuideline: { + height: '40px', + }, + arrowSightTutorial: { + position: 'fixed', + bottom: '60px', + left: `calc((${PHOTO_CAPTURE_HUD_BUTTONS_BAR_WIDTH * 2}px))`, + width: '40px', + }, +}; diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx new file mode 100644 index 000000000..ac2234e7a --- /dev/null +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx @@ -0,0 +1,132 @@ +import { CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, DynamicSVG } from '@monkvision/common-ui-web'; +import { CaptureAppConfig } from '@monkvision/types'; +import { styles } from './PhotoCaptureHUDTutorial.styles'; +import { TutorialViews } from '../../hooks'; +import { usePhotoCaptureHUDButtonBackground } from '../hooks'; +import { SightGuideline } from '../PhotoCaptureHUDElementsSight'; + +const translationPrefix = 'photo.hud.tutorial'; +const arrowGuidelineSVG = + ''; +const arrowSightTutorialSVG = + ''; + +/** + * Props of the PhotoCaptureHUDTutorial component. + */ +export interface PhotoCaptureHUDTutorialProps + extends Pick< + CaptureAppConfig, + 'enableSightGuidelines' | 'allowSkipTutorial' | 'sightGuidelines' | 'enableSightTutorial' + > { + /** + * The id of the sight. + */ + sightId: string; + /** + * The current tutorial view in PhotoCapture component. + */ + tutorialView: TutorialViews | null; + /** + * Callback called when the user clicks on "Next" button. + */ + onTutorialNext: (view: TutorialViews | null) => void; +} + +function getButtonStyle(enableAddDamage?: boolean): CSSProperties { + return { visibility: enableAddDamage ? 'visible' : 'hidden' }; +} + +function ArrowIcon({ tutorialView }: Pick) { + if (tutorialView === TutorialViews.GUIDELINE) { + return ; + } + if (tutorialView === TutorialViews.SIGHT_TUTORIAL) { + return ; + } + return null; +} + +function DisplayText({ tutorialView }: Pick) { + const { t } = useTranslation(); + + const textArray = t(`${translationPrefix}.${tutorialView}`).split(//); + + return ( +
+
+ {t(`${translationPrefix}.title`)} +
+ {textArray.map((value: string, index: number) => ( +
+ {value} + {index < textArray.length - 1 &&
} +
+ ))} +
+ ); +} + +/** + * Component that displays an tutorial overlay on top of the PhotoCapture component. + */ +export function PhotoCaptureHUDTutorial({ + tutorialView, + onTutorialNext, + allowSkipTutorial, + enableSightGuidelines, + enableSightTutorial, + sightGuidelines, + sightId, +}: PhotoCaptureHUDTutorialProps) { + const { t } = useTranslation(); + const primaryColor = usePhotoCaptureHUDButtonBackground(); + + const handleNext = () => { + let views = Object.values(TutorialViews); + if (!enableSightGuidelines) { + views = views.filter((v) => v !== TutorialViews.GUIDELINE); + } + if (!enableSightTutorial) { + views = views.filter((v) => v !== TutorialViews.SIGHT_TUTORIAL); + } + if (tutorialView === views.at(-1)) { + onTutorialNext(null); + return; + } + const currentIndex = views.findIndex((v) => tutorialView === v); + onTutorialNext(views[currentIndex + 1]); + }; + + return tutorialView ? ( +
+
+
+
+
+ +
+ +
+ + +
+
+ ) : null; +} diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/index.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/index.ts new file mode 100644 index 000000000..254bf4ba1 --- /dev/null +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/index.ts @@ -0,0 +1,4 @@ +export { + PhotoCaptureHUDTutorial, + type PhotoCaptureHUDTutorialProps, +} from './PhotoCaptureHUDTutorial'; diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/index.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/index.ts index c10848f9e..d5b63885b 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/index.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/index.ts @@ -8,3 +8,4 @@ export * from './PhotoCaptureHUDElements'; export * from './PhotoCaptureHUDElementsAddDamage1stShot'; export * from './PhotoCaptureHUDElementsAddDamage2ndShot'; export * from './PhotoCaptureHUDElementsSight'; +export * from './PhotoCaptureHUDTutorial'; diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts index 7c25fb2b8..fb15e6ae6 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useComplianceAnalytics'; export * from './useBadConnectionWarning'; export * from './useAdaptiveCameraConfig'; export * from './useTracking'; +export * from './usePhotoCaptureTutorial'; diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts new file mode 100644 index 000000000..9d83c081a --- /dev/null +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { PhotoCaptureTutorialOption } from '@monkvision/types'; +import { useObjectMemo } from '@monkvision/common'; + +export const STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL = '@monk_photoCaptureTutorial'; + +/** + * Enum of the different views of the PhotoCapture Tutorial component. + */ +export enum TutorialViews { + /** + * Welcome view. + */ + WELCOME = 'welcome', + /** + * Guideline view. + */ + GUIDELINE = 'guideline', + /** + * Sight tutorial view. + */ + SIGHT_TUTORIAL = 'sightTutorial', + /** + * Sight view. + */ + SIGHT = 'sight', +} + +function getTutorialState(enableTutorial: PhotoCaptureTutorialOption): TutorialViews | null { + const isFirstTime = !!localStorage.getItem(STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL); + + return enableTutorial === PhotoCaptureTutorialOption.DISABLED || + (enableTutorial === PhotoCaptureTutorialOption.FIRST_TIME_ONLY && isFirstTime) + ? null + : TutorialViews.WELCOME; +} + +/** + * Parameters of the usePhotoCaptureTutorial hook. + */ +export interface PhotoCaptureTutorial { + enableTutorial: PhotoCaptureTutorialOption; +} + +/** + * Custom hook used to manage the state of photo capture tutorial. + */ +export function usePhotoCaptureTutorial({ enableTutorial }: PhotoCaptureTutorial) { + const [tutorialView, setTutorialView] = useState( + getTutorialState(enableTutorial), + ); + + const handleTutorialView = (view: TutorialViews | null) => { + setTutorialView(view); + }; + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL, STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL); + }, []); + + return useObjectMemo({ tutorialView, handleTutorialView }); +} diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json index a97ceb33a..760e2ee9b 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -31,6 +31,14 @@ "message": "Sind Sie sicher, dass Sie das Erfassungstool schließen wollen?", "cancel": "Abbrechen", "confirm": "Ja" + }, + "tutorial": { + "welcome": "Willkommen zu Ihrer ersten Inspektion!", + "title": "So verwenden Sie es", + "guideline": "Gehen Sie um das Fahrzeug herum, um alle Bilder aufzunehmen und den Anweisungen oben zu folgen, um den richtigen Winkel einzunehmen.", + "sightTutorial": "Verwenden Sie das Bild unten links, um sich bei der Rahmung führen zu lassen.
Tippen Sie darauf, um eine detaillierte Erklärung zu erhalten.", + "sight": "Richten Sie das Fahrzeug so gut wie möglich an den Linien aus, um das beste Foto zu erhalten.
Drücken Sie den Auslöser, um das Foto aufzunehmen.", + "next": "Weiter" } } } diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index 9261175f3..dc1378eb6 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -31,6 +31,14 @@ "message": "Are you sure you want to close the capture tool?", "cancel": "Cancel", "confirm": "Yes" + }, + "tutorial": { + "welcome": "Welcome to your first inspection!", + "title": "How to use it", + "guideline": "Go around the vehicle to take all picture and follow the guidelines at the top to position at the right angle.", + "sightTutorial": "Use bottom left image to guide you with the framing.
Tap it to see a detailed explanation.", + "sight": "Align the vehicle with the lines as much as possible to get the best shot.
Press the shutter button to take the photo.", + "next": "Next" } } } diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 4da682b2e..b3b893e4c 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -31,6 +31,14 @@ "message": "Êtes-vous sûr(e) de vouloir fermer l'outil de capture ?", "cancel": "Annuler", "confirm": "Oui" + }, + "tutorial": { + "welcome": "Bienvenue à votre première inspection!", + "title": "Comment l'utiliser", + "guideline": "Faites le tour du véhicule pour prendre toutes les photos et suivez les directives en haut pour vous positionner sous le bon angle.", + "sightTutorial": "Utilisez l'image en bas à gauche pour vous guider dans le cadrage.
Appuyez dessus pour voir une explication détaillée.", + "sight": "Alignez le véhicule avec les lignes autant que possible pour obtenir la meilleure photo.
Appuyez sur le déclencheur pour prendre la photo.", + "next": "Suivant" } } } diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json index a6e965029..8feb5160d 100644 --- a/packages/inspection-capture-web/src/translations/nl.json +++ b/packages/inspection-capture-web/src/translations/nl.json @@ -31,6 +31,14 @@ "message": "Weet je zeker dat je de capture tool wilt sluiten?", "cancel": "Annuleren", "confirm": "Ja" + }, + "tutorial": { + "welcome": "Welkom bij uw eerste inspectie!", + "title": "Hoe te gebruiken", + "guideline": "Ga rond het voertuig om alle foto's te nemen en volg de richtlijnen bovenaan om op de juiste hoek te staan.", + "sightTutorial": "Gebruik het plaatje linksonder om je te leiden bij het kadreren.
Klik erop voor een gedetailleerde uitleg.", + "sight": "Richt het voertuig zo goed mogelijk uit op de lijnen om de beste foto te krijgen.
Druk op de knop om de foto te nemen.", + "next": "Volgende" } } } diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 5d873443c..5dbb3e749 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -1,5 +1,11 @@ import { sights } from '@monkvision/sights'; -import { CameraResolution, ComplianceIssue, CompressionFormat, TaskName } from '@monkvision/types'; +import { + CameraResolution, + ComplianceIssue, + CompressionFormat, + PhotoCaptureTutorialOption, + TaskName, +} from '@monkvision/types'; const { PhotoCaptureMode } = jest.requireActual('../../src/PhotoCapture/hooks'); @@ -49,6 +55,10 @@ jest.mock('../../src/PhotoCapture/hooks', () => ({ }, })), useTracking: jest.fn(), + usePhotoCaptureTutorial: jest.fn(() => ({ + tutorialView: 'welcome', + handleTutorialView: jest.fn(), + })), })); import { Camera } from '@monkvision/camera-web'; @@ -67,6 +77,7 @@ import { usePictureTaken, useStartTasksOnComplete, useUploadQueue, + usePhotoCaptureTutorial, } from '../../src/PhotoCapture/hooks'; function createProps(): PhotoCaptureProps { @@ -107,6 +118,9 @@ function createProps(): PhotoCaptureProps { sightIds: ['sightId-test-1', 'sightId-test-2'], }, ], + enableTutorial: PhotoCaptureTutorialOption.ENABLED, + allowSkipTutorial: true, + enableSightTutorial: true, }; } @@ -298,6 +312,8 @@ describe('PhotoCapture component', () => { const loading = (useLoadingState as jest.Mock).mock.results[0].value; expect(usePhotoCaptureImages).toHaveBeenCalledWith(props.inspectionId); const images = (usePhotoCaptureImages as jest.Mock).mock.results[0].value; + const { tutorialView, handleTutorialView } = (usePhotoCaptureTutorial as jest.Mock).mock + .results[0].value; expectPropsOnChildMock(Camera, { hudProps: { sights: props.sights, @@ -318,6 +334,10 @@ describe('PhotoCapture component', () => { enableAddDamage: props.enableAddDamage, enableSightGuidelines: props.enableSightGuidelines, sightGuidelines: props.sightGuidelines, + tutorialView, + onTutorialNext: handleTutorialView, + allowSkipTutorial: props.allowSkipRetake, + enableSightTutorial: props.enableSightTutorial, }, }); diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx index 427867f3a..81c7e626d 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx @@ -62,6 +62,10 @@ function createProps(): PhotoCaptureHUDProps { } as unknown as CameraHandle, cameraPreview:
, images: [{ sightId: 'test-sight-1', status: ImageStatus.NOT_COMPLIANT }] as Image[], + tutorialView: null, + allowSkipTutorial: false, + onTutorialNext: jest.fn(), + enableSightTutorial: true, }; } diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements.test.tsx index 644aa5903..2b733f6ba 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements.test.tsx @@ -44,6 +44,8 @@ function createProps(): PhotoCaptureHUDElementsProps { isLoading: false, error: null, images: [{ sightId: 'test-sight-1', status: ImageStatus.NOT_COMPLIANT }] as Image[], + tutorialView: null, + onTutorialNext: jest.fn(), }; } diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.test.tsx index a8adbd4be..b3c352c1e 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/PhotoCaptureHUDElementsSight.test.tsx @@ -27,7 +27,7 @@ import { PhotoCaptureHUDElementsSightProps, SightSlider, } from '../../../../src'; -import { PhotoCaptureMode } from '../../../../src/PhotoCapture/hooks'; +import { PhotoCaptureMode, TutorialViews } from '../../../../src/PhotoCapture/hooks'; function createProps(): PhotoCaptureHUDElementsSightProps { const captureSights = [ @@ -47,6 +47,7 @@ function createProps(): PhotoCaptureHUDElementsSightProps { { sightId: 'test-sight-1', status: ImageStatus.NOT_COMPLIANT }, { sightId: 'test-sight-2', status: ImageStatus.SUCCESS }, ] as Image[], + tutorialView: null, }; } @@ -64,7 +65,9 @@ describe('PhotoCaptureHUDElementsSight component', () => { it('should display the PhotoCaptureHUDCounter component with the proper props', () => { const props = createProps(); - const { unmount } = render(); + const { unmount } = render( + , + ); expectPropsOnChildMock(PhotoCaptureHUDCounter, { mode: PhotoCaptureMode.SIGHT, diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial.test.tsx new file mode 100644 index 000000000..a3fa28471 --- /dev/null +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial.test.tsx @@ -0,0 +1,210 @@ +jest.mock( + '../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/SightGuideline', + () => ({ + SightGuideline: jest.fn(() => <>), + }), +); + +import { render, screen } from '@testing-library/react'; +import { Button, DynamicSVG } from '@monkvision/common-ui-web'; +import { + PhotoCaptureHUDTutorial, + PhotoCaptureHUDTutorialProps, + SightGuideline, +} from '../../../src'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { TutorialViews } from '../../../src/PhotoCapture/hooks'; + +const BACKDROP_TEST_ID = 'backdrop'; +const TITLE_TEST_ID = 'title'; +const translationPrefix = 'photo.hud.tutorial'; + +function createProps(): PhotoCaptureHUDTutorialProps { + return { + tutorialView: TutorialViews.WELCOME, + onTutorialNext: jest.fn(), + allowSkipTutorial: false, + enableSightGuidelines: true, + enableSightTutorial: true, + sightGuidelines: [], + sightId: 'test-sight-id', + }; +} + +describe('PhotoCaptureHUDTutorial component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not be shown if tutorialView is null', () => { + const props = createProps(); + render(); + + expect(screen.queryByTestId(BACKDROP_TEST_ID)).toBeNull(); + }); + + it('should be shown if tutorialView is set', () => { + const props = createProps(); + const { unmount } = render(); + + expect(screen.queryByTestId(BACKDROP_TEST_ID)).not.toBeNull(); + + expect(Button).toHaveBeenCalledTimes(2); + const nextButton = (Button as unknown as jest.Mock).mock.calls[1][0]; + expect(typeof nextButton.onClick).toBe('function'); + + expect(SightGuideline).toHaveBeenCalled(); + expectPropsOnChildMock(SightGuideline, { + sightId: props.sightId, + sightGuidelines: props.sightGuidelines, + enableSightGuidelines: props.tutorialView === TutorialViews.GUIDELINE, + enableAddDamage: true, + }); + + expect(DynamicSVG).not.toHaveBeenCalled(); + + expect(screen.getByTestId(TITLE_TEST_ID).textContent).toBe(`${translationPrefix}.title`); + + unmount(); + }); + + describe('Sight guideline', () => { + it('should only render SightGuideline when tutorialView is equal to TutorialViews.GUIDELINE', () => { + const props = createProps(); + const { unmount, rerender } = render(); + + expectPropsOnChildMock(SightGuideline, { enableSightGuidelines: false }); + + rerender(); + expectPropsOnChildMock(SightGuideline, { enableSightGuidelines: true }); + + rerender(); + expectPropsOnChildMock(SightGuideline, { enableSightGuidelines: false }); + + rerender(); + expectPropsOnChildMock(SightGuideline, { enableSightGuidelines: false }); + + unmount(); + }); + }); + + describe('Close button', () => { + it('should disable close button when allowSkipTutorial is false', () => { + const props = createProps(); + const { unmount } = render(); + + const closeButtonProps = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(closeButtonProps.disabled).toEqual(true); + + unmount(); + }); + + it('should call onTutorialNext callback with null param when close button is clicked', () => { + const props = createProps(); + const { unmount } = render(); + + const buttonProps = (Button as unknown as jest.Mock).mock.calls[0][0]; + buttonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(null); + + unmount(); + }); + }); + + describe('Arrow Icon', () => { + it('should only render Arrow SVG when tutorialView is equal to TutorialViews.GUIDELINE or TutorialViews.SIGHT_TUTORIAL', () => { + const props = createProps(); + const { unmount, rerender } = render( + , + ); + + expect(DynamicSVG).not.toHaveBeenCalled(); + + rerender(); + expect(DynamicSVG).not.toHaveBeenCalled(); + + rerender(); + expect(DynamicSVG).toHaveBeenCalledTimes(1); + + rerender(); + expect(DynamicSVG).toHaveBeenCalledTimes(2); + + unmount(); + }); + }); + + describe('Next button', () => { + it('should call onTutorialNext callback when next button is clicked', () => { + const props = createProps(); + const { unmount, rerender } = render(); + + const nextButtonProps = (Button as unknown as jest.Mock).mock.calls[1][0]; + nextButtonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.GUIDELINE); + + rerender(); + const nextButtonProps1 = (Button as unknown as jest.Mock).mock.calls[3][0]; + nextButtonProps1.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.SIGHT_TUTORIAL); + + rerender(); + const nextButtonProps2 = (Button as unknown as jest.Mock).mock.calls[5][0]; + nextButtonProps2.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.SIGHT); + + rerender(); + const nextButtonProps3 = (Button as unknown as jest.Mock).mock.calls[7][0]; + nextButtonProps3.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(null); + + unmount(); + }); + + it('should skip TutorialViews.GUIDELINE if enableSightGuidelines is false', () => { + const props = createProps(); + const { unmount } = render( + , + ); + + const nextButtonProps = (Button as unknown as jest.Mock).mock.calls[1][0]; + nextButtonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.SIGHT_TUTORIAL); + + unmount(); + }); + + it('should skip TutorialViews.SIGHT_TUTORIAL if enableSightTutorial is false', () => { + const props = createProps(); + const { unmount } = render( + , + ); + + const nextButtonProps = (Button as unknown as jest.Mock).mock.calls[1][0]; + nextButtonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.SIGHT); + + unmount(); + }); + + it('should skip TutorialViews.GUIDELINE and TutorialViews.SIGHT_TUTORIAL if enableSightGuidelines and enableSightTutorial are false', () => { + const props = createProps(); + const { unmount } = render( + , + ); + + const nextButtonProps = (Button as unknown as jest.Mock).mock.calls[1][0]; + nextButtonProps.onClick(); + expect(props.onTutorialNext).toHaveBeenCalledWith(TutorialViews.SIGHT); + + unmount(); + }); + }); +}); diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureTutorial.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureTutorial.test.ts new file mode 100644 index 000000000..886de15b7 --- /dev/null +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureTutorial.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react-hooks'; +import { + STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL, + TutorialViews, + usePhotoCaptureTutorial, +} from '../../../src/PhotoCapture/hooks'; +import { PhotoCaptureTutorialOption } from '@monkvision/types'; + +describe('usePhotoCaptureTutorial', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('should return WELCOME view if tutorial is enabled', () => { + const { result } = renderHook(() => + usePhotoCaptureTutorial({ enableTutorial: PhotoCaptureTutorialOption.ENABLED }), + ); + + expect(result.current.tutorialView).toBe(TutorialViews.WELCOME); + }); + + it('should return null if tutorial is disabled', () => { + const { result } = renderHook(() => + usePhotoCaptureTutorial({ enableTutorial: PhotoCaptureTutorialOption.DISABLED }), + ); + + expect(result.current.tutorialView).toBeNull(); + }); + + it('should set localStorage item if tutorial option is FIRST_TIME_ONLY', () => { + const { result } = renderHook(() => + usePhotoCaptureTutorial({ enableTutorial: PhotoCaptureTutorialOption.FIRST_TIME_ONLY }), + ); + + expect(localStorage.getItem(STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL)).toBe( + STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL, + ); + expect(result.current.tutorialView).toBe(TutorialViews.WELCOME); + }); + + it('should return null if tutorial is FIRST_TIME_ONLY and localStorage is set', () => { + localStorage.setItem(STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL, STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL); + + const { result } = renderHook(() => + usePhotoCaptureTutorial({ enableTutorial: PhotoCaptureTutorialOption.FIRST_TIME_ONLY }), + ); + + expect(result.current.tutorialView).toBeNull(); + }); + + it('should allow changing the tutorial view', () => { + const { result } = renderHook(() => + usePhotoCaptureTutorial({ enableTutorial: PhotoCaptureTutorialOption.ENABLED }), + ); + + act(() => { + result.current.handleTutorialView(TutorialViews.SIGHT); + }); + + expect(result.current.tutorialView).toBe(TutorialViews.SIGHT); + }); +}); diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index eef9dabd7..ec640714f 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -5,6 +5,24 @@ import { ComplianceOptions, TaskName } from './state'; import { DeviceOrientation } from './utils'; import { CreateInspectionOptions, MonkApiPermission } from './api'; +/** + * Enumeration of the tutorial options. + */ +export enum PhotoCaptureTutorialOption { + /** + * Photo capture is disabled. + */ + DISABLED = 'disabled', + /** + * Photo capture is enable. + */ + ENABLED = 'enabled', + /** + * Photo capture is enable only time. + */ + FIRST_TIME_ONLY = 'first_time_only', +} + /** * Configuration used to configure the Camera and picture output of the SDK. */ @@ -125,9 +143,27 @@ export type CaptureAppConfig = CameraConfig & */ apiDomain: string; /** - * The API domain used to communicate with the resize micro service + * The API domain used to communicate with the resize micro service. */ thumbnailDomain: string; + /** + * Options for displaying the photo capture tutorial. + * + * @default PhotoCaptureTutorialOption.FIRST_TIME_ONLY. + */ + enableTutorial?: PhotoCaptureTutorialOption; + /** + * Boolean indicating if the user can skip the PhotoCapture tutorial. + * + * @default true + */ + allowSkipTutorial?: boolean; + /** + * Boolean indicating whether the sight tutorial feature is enabled. If disabled, the sight tutorial icon displayed on the bottom left will be hidden. + * + * @default true + */ + enableSightTutorial?: boolean; /** * Required API permissions to use the app. */