Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/Global/Modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ const Modal = ({
>
<Dialog.Panel
className={twMerge(
`relative bottom-0 z-10 mx-0 max-h-[] w-full max-w-[26rem] self-end rounded-md border-0 bg-white outline-none sm:m-auto sm:self-auto dark:bg-n-1 ${
`relative bottom-0 z-10 mx-0 max-h-[] w-full max-w-[26rem] self-end rounded-md border-0 bg-white outline-none dark:bg-n-1 sm:m-auto sm:self-auto ${
video
? 'static aspect-video max-w-[64rem] overflow-hidden bg-n-1 shadow-[0_2.5rem_8rem_rgba(0,0,0,0.5)] dark:border-transparent'
: ''
Expand Down
2 changes: 1 addition & 1 deletion src/components/Global/ValidatedInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ const ValidatedInput = ({
e.preventDefault()
onUpdate({ value: '', isValid: false, isChanging: false })
}}
className="flex h-full w-6 items-center justify-center pr-2 md:w-8 md:pr-0 dark:bg-n-1"
className="flex h-full w-6 items-center justify-center pr-2 dark:bg-n-1 md:w-8 md:pr-0"
>
<Icon className="h-6 w-6 dark:fill-white" name="cancel" />
</button>
Expand Down
79 changes: 79 additions & 0 deletions src/components/Kyc/CameraPermissionWarningModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client'

import ActionModal, { type ActionModalButtonProps } from '@/components/Global/ActionModal'
import { type IconName } from '@/components/Global/Icons/Icon'
import type { MediaCheckResult } from '@/utils/mediaPermissions.utils'

interface CameraPermissionWarningModalProps {
visible: boolean
onClose: () => void
onContinueAnyway: () => void
onOpenInBrowser: () => void
mediaCheckResult: MediaCheckResult
}

/**
* Modal that warns users when camera/microphone access is likely to fail
* Offers two options: try anyway in iframe, or open in browser
*/
export default function CameraPermissionWarningModal({
visible,
onClose,
onContinueAnyway,
onOpenInBrowser,
mediaCheckResult,
}: CameraPermissionWarningModalProps) {
const isError = mediaCheckResult.severity === 'error'

const getTitle = () => {
if (isError) {
return 'Camera or Microphone Required'
}
return 'Camera Access May Be Limited'
}

const getDescription = () => {
if (mediaCheckResult.message) {
return mediaCheckResult.message
}
return 'Identity verification requires camera and microphone access. Opening in your browser may provide better results.'
}

const ctas: ActionModalButtonProps[] = [
{
text: 'Open in Browser',
icon: 'arrow-up-right' as IconName,
iconPosition: 'right',
onClick: onOpenInBrowser,
variant: 'purple',
shadowSize: '4',
className: 'justify-center',
},
]

// Only show "Try Anyway" if it's a warning (not a hard error)
if (!isError) {
ctas.push({
text: 'Try Anyway',
onClick: onContinueAnyway,
variant: 'transparent',
className: 'underline text-xs md:text-sm !font-normal !transform-none !pt-2 text-grey-1',
})
}

return (
<ActionModal
visible={visible}
onClose={onClose}
title={getTitle()}
description={getDescription()}
icon={'alert' as IconName}
iconContainerClassName={isError ? 'bg-error-1' : 'bg-secondary-1'}
iconProps={{ className: 'text-white' }}
ctas={ctas}
modalPanelClassName="max-w-md"
contentContainerClassName="text-center"
ctaClassName="flex-col sm:flex-col"
/>
)
}
43 changes: 37 additions & 6 deletions src/components/Kyc/InitiateBridgeKYCModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import ActionModal from '@/components/Global/ActionModal'
import { useBridgeKycFlow } from '@/hooks/useBridgeKycFlow'
import IframeWrapper from '@/components/Global/IframeWrapper'
import { KycVerificationInProgressModal } from './KycVerificationInProgressModal'
import CameraPermissionWarningModal from './CameraPermissionWarningModal'
import { type IconName } from '@/components/Global/Icons/Icon'
import { saveRedirectUrl } from '@/utils'
import useClaimLink from '../Claim/useClaimLink'
import { useKycCameraCheck } from '@/hooks/useKycCameraCheck'

interface BridgeKycModalFlowProps {
isOpen: boolean
Expand Down Expand Up @@ -32,10 +34,24 @@ export const InitiateBridgeKYCModal = ({
} = useBridgeKycFlow({ onKycSuccess, flow, onManualClose })
const { addParamStep } = useClaimLink()

const {
showCameraWarning,
setShowCameraWarning,
mediaCheckResult,
handleVerifyClick: checkAndInitiate,
handleContinueAnyway,
handleOpenInBrowser,
isChecking,
} = useKycCameraCheck({
onInitiateKyc: handleInitiateKyc,
onClose,
saveRedirect: saveRedirectUrl,
})

const handleVerifyClick = async () => {
addParamStep('bank')
const result = await handleInitiateKyc()
if (result?.success) {
const result = await checkAndInitiate()
if (result?.shouldProceed) {
saveRedirectUrl()
onClose()
}
Expand All @@ -44,7 +60,7 @@ export const InitiateBridgeKYCModal = ({
return (
<>
<ActionModal
visible={isOpen}
visible={isOpen && !showCameraWarning}
onClose={onManualClose ? onManualClose : onClose}
title="Verify your identity first"
description="To continue, you need to complete identity verification. This usually takes just a few minutes."
Expand All @@ -53,10 +69,10 @@ export const InitiateBridgeKYCModal = ({
ctaClassName="grid grid-cols-1 gap-3"
ctas={[
{
text: isLoading ? 'Loading...' : 'Verify now',
text: isLoading || isChecking ? 'Loading...' : 'Verify now',
onClick: handleVerifyClick,
variant: 'purple',
disabled: isLoading,
disabled: isLoading || isChecking,
shadowSize: '4',
icon: 'check-circle',
className: 'h-11',
Expand All @@ -71,7 +87,22 @@ export const InitiateBridgeKYCModal = ({
},
]}
/>
<IframeWrapper {...iframeOptions} onClose={handleIframeClose} />

{mediaCheckResult && (
<CameraPermissionWarningModal
visible={showCameraWarning}
onClose={() => setShowCameraWarning(false)}
onContinueAnyway={handleContinueAnyway}
onOpenInBrowser={handleOpenInBrowser}
mediaCheckResult={mediaCheckResult}
/>
)}

<IframeWrapper
{...iframeOptions}
visible={iframeOptions.visible && !showCameraWarning}
onClose={handleIframeClose}
/>
<KycVerificationInProgressModal
isOpen={isVerificationProgressModalOpen}
onClose={closeVerificationProgressModal}
Expand Down
45 changes: 39 additions & 6 deletions src/components/Kyc/InitiateMantecaKYCModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import ActionModal from '@/components/Global/ActionModal'
import IframeWrapper from '@/components/Global/IframeWrapper'
import CameraPermissionWarningModal from './CameraPermissionWarningModal'
import { type IconName } from '@/components/Global/Icons/Icon'
import { useMantecaKycFlow } from '@/hooks/useMantecaKycFlow'
import { type CountryData } from '@/components/AddMoney/consts'
import { Button } from '../0_Bruddle'
import { PeanutDoesntStoreAnyPersonalInformation } from './KycVerificationInProgressModal'
import { useEffect } from 'react'
import { useKycCameraCheck } from '@/hooks/useKycCameraCheck'

interface Props {
isOpen: boolean
Expand Down Expand Up @@ -39,8 +41,24 @@ const InitiateMantecaKYCModal = ({
country,
})

const {
showCameraWarning,
setShowCameraWarning,
mediaCheckResult,
handleVerifyClick,
handleContinueAnyway,
handleOpenInBrowser,
isChecking,
} = useKycCameraCheck({
onInitiateKyc: () => openMantecaKyc(country),
onClose,
})

useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Validate origin for security
if (!event.origin.includes('manteca')) return

if (event.data.source === 'peanut-kyc-success') {
onKycSuccess?.()
}
Expand All @@ -51,12 +69,12 @@ const InitiateMantecaKYCModal = ({
return () => {
window.removeEventListener('message', handleMessage)
}
}, [])
}, [onKycSuccess])

return (
<>
<ActionModal
visible={isOpen && !iframeOptions.visible}
visible={isOpen && !iframeOptions.visible && !showCameraWarning}
onClose={onClose}
title={title ?? 'Verify your identity first'}
description={
Expand All @@ -68,18 +86,33 @@ const InitiateMantecaKYCModal = ({
ctaClassName="grid grid-cols-1 gap-3"
ctas={[
{
text: isLoading ? 'Loading...' : (ctaText ?? 'Verify now'),
onClick: () => openMantecaKyc(country),
text: isLoading || isChecking ? 'Loading...' : (ctaText ?? 'Verify now'),
onClick: handleVerifyClick,
variant: 'purple',
disabled: isLoading,
disabled: isLoading || isChecking,
shadowSize: '4',
icon: 'check-circle',
className: 'h-11',
},
]}
footer={footer}
/>
<IframeWrapper {...iframeOptions} onClose={handleIframeClose} />

{mediaCheckResult && (
<CameraPermissionWarningModal
visible={showCameraWarning}
onClose={() => setShowCameraWarning(false)}
onContinueAnyway={handleContinueAnyway}
onOpenInBrowser={handleOpenInBrowser}
mediaCheckResult={mediaCheckResult}
/>
)}

<IframeWrapper
{...iframeOptions}
visible={iframeOptions.visible && !showCameraWarning}
onClose={handleIframeClose}
/>
</>
)
}
Expand Down
80 changes: 80 additions & 0 deletions src/hooks/useKycCameraCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { checkKycMediaReadiness, type MediaCheckResult } from '@/utils/mediaPermissions.utils'

interface UseKycCameraCheckOptions {
onInitiateKyc: () => Promise<{ success: boolean; url?: string; data?: { kycLink?: string } } | undefined>
onClose: () => void
saveRedirect?: () => void
}

/**
* Hook to handle camera/microphone pre-flight checks for KYC flows
* Manages warning modal state and provides handlers for user actions
*/
export function useKycCameraCheck({ onInitiateKyc, onClose, saveRedirect }: UseKycCameraCheckOptions) {
const [showCameraWarning, setShowCameraWarning] = useState(false)
const [mediaCheckResult, setMediaCheckResult] = useState<MediaCheckResult | null>(null)
const [kycUrlForBrowser, setKycUrlForBrowser] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false)

const handleVerifyClick = async () => {
// Prevent double-clicks
if (isChecking) return { shouldProceed: false }
setIsChecking(true)

try {
// Pre-flight check: see if camera/mic are available
const mediaCheck = await checkKycMediaReadiness()

// Always call KYC initiation once
const result = await onInitiateKyc()

// If media is not supported or it's a restricted environment, show warning
if (result?.success && (!mediaCheck.supported || mediaCheck.severity === 'warning')) {
setMediaCheckResult(mediaCheck)
const url = result.url || result.data?.kycLink
if (url) {
setKycUrlForBrowser(url)
setShowCameraWarning(true)
return { shouldProceed: false }
}
}

return { shouldProceed: result?.success ?? false }
} finally {
setIsChecking(false)
}
}

const handleContinueAnyway = () => {
setShowCameraWarning(false)
saveRedirect?.()
onClose()
}

const handleOpenInBrowser = () => {
if (kycUrlForBrowser) {
// Validate URL is from expected domain for security
try {
const url = new URL(kycUrlForBrowser)
if (url.protocol === 'https:') {
window.open(kycUrlForBrowser, '_blank')
}
} catch {
console.error('Invalid KYC URL')
}
}
setShowCameraWarning(false)
onClose()
}

return {
showCameraWarning,
setShowCameraWarning,
mediaCheckResult,
handleVerifyClick,
handleContinueAnyway,
handleOpenInBrowser,
isChecking,
}
}
2 changes: 1 addition & 1 deletion src/hooks/useMantecaKycFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
src: url,
visible: true,
})
return { success: true as const }
return { success: true as const, url }
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Failed to initiate onboarding'
setError(message)
Expand Down
Loading
Loading