mirror of
https://github.com/selfxyz/self.git
synced 2026-04-27 03:01:15 -04:00
Add webview registration core: tour, outcome screens, and mocked provider handoff (#1872)
* save current registration state * save wip * update insets * wip * updates * commit * update flows * updates * fix animation
This commit is contained in:
@@ -10,5 +10,6 @@ circuits/build/**
|
||||
contracts/artifacts/**
|
||||
contracts/cache/**
|
||||
contracts/typechain-types/**
|
||||
packages/webview-app/public/animations/**
|
||||
.nvmrc
|
||||
.watchmanconfig
|
||||
|
||||
@@ -120,5 +120,6 @@ yarn lint && yarn types && yarn build
|
||||
## Workspace-Specific Instructions
|
||||
|
||||
- `app/AGENTS.md` — Mobile app development, E2E testing, deployment
|
||||
- `packages/webview-app/AGENTS.md` — WebView app development, Euclid screen migration, asset management
|
||||
- `packages/mobile-sdk-alpha/AGENTS.md` — SDK development, testing guidelines
|
||||
- `noir/AGENTS.md` — Noir circuit development
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
public/animations
|
||||
|
||||
83
packages/webview-app/AGENTS.md
Normal file
83
packages/webview-app/AGENTS.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# AGENTS Instructions
|
||||
|
||||
## Development Workflow
|
||||
|
||||
```bash
|
||||
yarn dev # Start Vite dev server at http://localhost:5173
|
||||
yarn build # Type-check + production build
|
||||
yarn nice # Fix linting + formatting + type-check in one command
|
||||
yarn types # TypeScript type-check only
|
||||
```
|
||||
|
||||
### Pre-commit Checklist
|
||||
|
||||
- [ ] `yarn nice` passes
|
||||
- [ ] `yarn build` succeeds
|
||||
- [ ] Visually verify affected screens in the browser dev server
|
||||
|
||||
## Euclid Screen Migration Checklist
|
||||
|
||||
When importing or wrapping a screen from `@selfxyz/euclid`, complete **every** item before considering the screen done. Missing any of these causes silent runtime failures (blank animations, broken layouts, missing fonts).
|
||||
|
||||
### 1. Assets — public directory
|
||||
|
||||
Euclid screens reference assets by URL path (e.g., `/animations/app-tour-welcome.json`, `/backgrounds/dialogue-background.jpg`). These are **not** bundled by the package — the consuming app must serve them from its `public/` directory.
|
||||
|
||||
- **Animations:** Check the Euclid screen source for Lottie URI constants or default props pointing to `/animations/*.json`. Copy the corresponding files from `selfxyz/euclid → packages/storybook/public/animations/` into `packages/webview-app/public/animations/`.
|
||||
- **Backgrounds:** Check for `/backgrounds/*` references. Copy from `selfxyz/euclid → packages/storybook/public/backgrounds/`.
|
||||
- **Fonts:** Verify `packages/webview-app/public/fonts/` has all typefaces used by the screen's Euclid components. Current set: Advercase-Regular, DINOT-Bold, DINOT-Medium, IBMPlexMono-Regular.
|
||||
- **Images:** Euclid components that import images from `../../assets/images/` are bundled via the build — no action needed. Only URL-path references (string literals starting with `/`) require `public/` copies.
|
||||
|
||||
Run a quick grep to catch URL-path asset references you might miss:
|
||||
|
||||
```bash
|
||||
grep -rE "'/[a-z].*\.(json|jpg|png|svg)'" packages/webview-app/node_modules/@selfxyz/euclid/src/screens/<screen-path>/
|
||||
```
|
||||
|
||||
**Downloading assets from the euclid repo:** The `selfxyz/euclid` repo is private. Use `gh api` to download files. The GitHub contents API silently returns empty content for files >1 MB. For any asset that may be large (Lottie animations, images), always use the **git blob API**:
|
||||
|
||||
```bash
|
||||
# Step 1: get the file's SHA
|
||||
sha=$(gh api repos/selfxyz/euclid/contents/<path> --jq '.sha')
|
||||
|
||||
# Step 2: download via blob (handles files up to 100 MB)
|
||||
gh api repos/selfxyz/euclid/git/blobs/$sha --jq '.content' | base64 -d > <filename>
|
||||
```
|
||||
|
||||
After downloading, always verify file sizes are non-zero (`wc -c <file>`). A 0-byte or 14-byte file means the download silently failed.
|
||||
|
||||
**Sandboxed / offline environments (Codex):** If you cannot access the network, check whether the required asset already exists in `public/animations/` or `public/backgrounds/`. If it does not exist and you cannot download it, document the missing asset in your PR description so it can be added before merge.
|
||||
|
||||
**Asset locations:**
|
||||
|
||||
| Asset type | Euclid source | Local destination |
|
||||
| ----------- | --------------------------------------------------------- | ------------------------------------------ |
|
||||
| Animations | `selfxyz/euclid → packages/storybook/public/animations/` | `packages/webview-app/public/animations/` |
|
||||
| Backgrounds | `selfxyz/euclid → packages/storybook/public/backgrounds/` | `packages/webview-app/public/backgrounds/` |
|
||||
| Fonts | Already in place | `packages/webview-app/public/fonts/` |
|
||||
| Images | Bundled via imports (no action needed) | N/A |
|
||||
|
||||
**Note on Prettier:** Lottie animation JSON files in `public/animations/` are excluded from Prettier formatting (see `.prettierignore`). These files should remain flat/minified — do not reformat them.
|
||||
|
||||
### 2. Safe area insets
|
||||
|
||||
Every Euclid screen that accepts a `SafeArea` / `insets` prop **must** receive `WEB_SAFE_AREA` (from `src/utils/insets.ts`). Missing insets cause content to render under notches or flush against edges.
|
||||
|
||||
- Full-screen Euclid components (e.g., `LaunchTour*Screen`, `IDTypeScreen`, `CountryPickerScreen`): spread `{...WEB_SAFE_AREA}` as a prop.
|
||||
- Composite Euclid components used inside custom layouts (e.g., `StatusState`, `ProofRequestScreen`): check whether the parent layout already handles padding. If the Euclid component accepts `insets`, pass them.
|
||||
|
||||
### 3. Validation
|
||||
|
||||
After wiring the screen, visually verify in the browser dev server (`yarn dev`):
|
||||
|
||||
- Lottie animations play (not blank/black or a static dot).
|
||||
- Background images load (not a solid color fallback).
|
||||
- Text renders in the correct typeface (not system fallback).
|
||||
- Content respects safe area padding (not clipped or flush).
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **Vite + React** — SPA with `react-router-dom` for routing
|
||||
- **`@selfxyz/euclid`** — external design system package providing screen components, icons, and tokens
|
||||
- **`@selfxyz/webview-bridge`** — communication layer to native shells; in standalone browser mode (no native shell), bridge requests reject immediately since there is no transport
|
||||
- **`@selfxyz/mobile-sdk-alpha`** — shared SDK logic consumed via the `/browser` entry point
|
||||
@@ -17,8 +17,8 @@
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@selfxyz/euclid": "^1.2.0",
|
||||
"@selfxyz/euclid-core": "^1.2.0",
|
||||
"@selfxyz/euclid": "1.2.6",
|
||||
"@selfxyz/euclid-core": "1.2.6",
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:^",
|
||||
"@selfxyz/webview-bridge": "workspace:^",
|
||||
"@sumsub/websdk": "^2.0.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
packages/webview-app/public/animations/cloud-backup.json
Normal file
1
packages/webview-app/public/animations/cloud-backup.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"assets":[{"id":"6","layers":[{"ind":5,"ty":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[69.5,69.5]},"r":{"a":0,"k":0},"s":{"a":0,"k":[139,139]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}}]},{"ind":0,"ty":4,"ks":{"s":{"a":0,"k":[133.33,133.33]}},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[0.03,3.61],[-3.48,3.48],[0,0],[-3.61,-0.03],[-3.52,-3.48],[0,0],[-0.03,-3.63],[3.48,-3.48],[0,0],[3.61,0.03],[3.52,3.48]],"o":[[-3.48,-3.52],[-0.03,-3.61],[0,0],[3.46,-3.48],[3.63,0.03],[0,0],[3.48,3.52],[0.03,3.61],[0,0],[-3.48,3.48],[-3.61,-0.03],[0,0]],"v":[[5.27,62.55],[0,51.87],[5.18,41.24],[41.28,5.18],[51.87,0],[62.59,5.27],[98.47,41.15],[103.74,51.87],[98.56,62.5],[62.5,98.56],[51.87,103.74],[41.19,98.47]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"i":[[0,0],[-0.75,0.42],[-0.54,0.77],[0,0],[-0.21,0.57],[0,0.57],[0.95,0.77],[1.19,0],[1.07,-1.7],[0,0],[0,0],[0.6,0.3],[0.75,0],[0.87,-0.89],[0,-1.25],[-0.21,-0.57],[-0.41,-0.57],[0,0],[-0.74,-0.41],[-0.92,0]],"o":[[0.89,0],[0.77,-0.45],[0,0],[0.32,-0.51],[0.24,-0.57],[0,-1.25],[-0.92,-0.77],[-1.58,0],[0,0],[0,0],[-0.6,-0.75],[-0.59,-0.32],[-1.25,0],[-0.86,0.86],[0,0.59],[0.24,0.54],[0,0],[0.66,0.83],[0.75,0.39],[0,0]],"v":[[46.96,74.03],[49.41,73.41],[51.38,71.57],[72.25,39.32],[73.05,37.71],[73.41,36.01],[71.98,32.97],[68.8,31.81],[64.83,34.36],[46.82,63.13],[38.56,52.77],[36.77,51.2],[34.76,50.71],[31.59,52.05],[30.29,55.22],[30.61,56.96],[31.59,58.62],[42.36,71.57],[44.45,73.45],[46.96,74.03]]}}},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"tr","o":{"a":0,"k":100}}]}]}]},{"id":"9","layers":[{"ind":8,"ty":0,"parent":4,"ks":{},"w":139,"h":139,"ip":0,"op":61,"st":0,"refId":"6"},{"ind":4,"ty":3,"parent":3,"ks":{"a":{"a":0,"k":[69.5,69.5]},"p":{"a":1,"k":[{"t":0,"s":[69.5,97.3],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":30,"s":[69.5,69.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[69.5,69.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[110,110],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":21,"s":[110,110],"i":{"x":[0,0],"y":[1,1]},"o":{"x":[0.5,0.5],"y":[0,0]}},{"t":60,"s":[100,100],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":3,"ty":3,"ks":{"p":{"a":0,"k":[7,7]}},"ip":0,"op":61,"st":0}]}],"fr":60,"h":393,"ip":0,"layers":[{"ind":11,"ty":0,"parent":2,"ks":{"a":{"a":0,"k":[7,7]},"o":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":22.2,"s":[100],"h":1},{"t":60,"s":[100],"h":1}]},"p":{"a":1,"k":[{"t":0,"s":[0,69.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":22.2,"s":[0,0],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":60,"s":[0,0],"h":1}]}},"w":153,"h":181,"ip":0,"op":61,"st":0,"refId":"9"},{"ind":2,"ty":3,"parent":1,"ks":{"p":{"a":0,"k":[127,127]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":393}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
packages/webview-app/public/animations/scan-bar.json
Normal file
1
packages/webview-app/public/animations/scan-bar.json
Normal file
@@ -0,0 +1 @@
|
||||
{"fr":60,"h":290,"ip":0,"layers":[{"ind":4,"ty":4,"parent":3,"ks":{},"ef":[{"ty":29,"ef":[{"ty":0,"nm":"","v":{"a":0,"k":23.33}},{"ty":7,"nm":"","v":{"a":0,"k":1}},{"ty":7,"nm":"","v":{"a":0,"k":0}}]}],"ip":0,"op":154.6,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[6,119]},"r":{"a":0,"k":0},"s":{"a":0,"k":[40,266]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[6,119]},"r":{"a":0,"k":20},"s":{"a":0,"k":[12,238]}},{"ty":"fl","c":{"a":0,"k":[0,1,0.702]},"o":{"a":0,"k":100}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":3,"ty":3,"parent":2,"ks":{},"ip":0,"op":154.6,"st":0},{"ind":6,"ty":4,"parent":5,"ks":{},"ip":0,"op":154.6,"st":0,"shapes":[{"ty":"rc","p":{"a":0,"k":[2,123.5]},"r":{"a":0,"k":20},"s":{"a":0,"k":[4,247]}},{"ty":"fl","c":{"a":0,"k":[0,1,0.702]},"o":{"a":0,"k":100}}]},{"ind":5,"ty":3,"parent":2,"ks":{"p":{"a":0,"k":[5,-4]}},"ip":0,"op":154.6,"st":0},{"ind":2,"ty":3,"parent":1,"ks":{"p":{"a":1,"k":[{"t":0,"s":[18,26],"i":{"x":[0,1],"y":[1,1]},"o":{"x":[0.5,0],"y":[0,0]}},{"t":76.8,"s":[363,26],"i":{"x":[0,1],"y":[1,1]},"o":{"x":[0.5,0],"y":[0,0]}},{"t":153.6,"s":[18,26],"h":1}]}},"ip":0,"op":154.6,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":154.6,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":154.6,"st":0}],"meta":{"g":"https://jitter.video"},"op":153.6,"v":"5.7.4","w":393}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
BIN
packages/webview-app/public/backgrounds/dialogue-background.jpg
Normal file
BIN
packages/webview-app/public/backgrounds/dialogue-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
packages/webview-app/public/backgrounds/dialogue-background.png
Normal file
BIN
packages/webview-app/public/backgrounds/dialogue-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
BIN
packages/webview-app/public/backgrounds/restore.png
Normal file
BIN
packages/webview-app/public/backgrounds/restore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 631 KiB |
@@ -17,12 +17,16 @@ import { HomeScreen } from './screens/home/HomeScreen';
|
||||
import { ConfirmIdentificationScreen } from './screens/onboarding/ConfirmIdentificationScreen';
|
||||
import { CountryPickerScreen } from './screens/onboarding/CountryPickerScreen';
|
||||
import { IDSelectionScreen } from './screens/onboarding/IDSelectionScreen';
|
||||
import { KycFailureScreen } from './screens/onboarding/KycFailureScreen';
|
||||
import { ProviderLaunchScreen } from './screens/onboarding/ProviderLaunchScreen';
|
||||
import { ProviderResultScreen } from './screens/onboarding/ProviderResultScreen';
|
||||
import { RegistrationFailureScreen } from './screens/onboarding/RegistrationFailureScreen';
|
||||
import { ScanSuccessScreen } from './screens/onboarding/ScanSuccessScreen';
|
||||
import { TourScreen } from './screens/onboarding/TourScreen';
|
||||
import { ProvingScreen } from './screens/proving/ProvingScreen';
|
||||
import { VerificationResultScreen } from './screens/proving/VerificationResultScreen';
|
||||
import { KycMockScreen } from './screens/tunnel/KycMockScreen';
|
||||
import { TourScreen } from './screens/tunnel/TourScreen';
|
||||
import { TourScreen as TunnelTourScreen } from './screens/tunnel/TourScreen';
|
||||
import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerScreen';
|
||||
import { TunnelIDTypeScreen } from './screens/tunnel/TunnelIDTypeScreen';
|
||||
import { TunnelProofReceiptScreen } from './screens/tunnel/TunnelProofReceiptScreen';
|
||||
@@ -35,11 +39,15 @@ export const App: React.FC = () => (
|
||||
<SelfClientProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomeScreen />} />
|
||||
<Route path="/onboarding/tour/:step" element={<TourScreen />} />
|
||||
<Route path="/onboarding/country" element={<CountryPickerScreen />} />
|
||||
<Route path="/onboarding/id-type" element={<IDSelectionScreen />} />
|
||||
<Route path="/onboarding/provider" element={<ProviderLaunchScreen />} />
|
||||
<Route path="/onboarding/provider-result" element={<ProviderResultScreen />} />
|
||||
<Route path="/onboarding/confirm" element={<ConfirmIdentificationScreen />} />
|
||||
<Route path="/onboarding/success" element={<ScanSuccessScreen />} />
|
||||
<Route path="/onboarding/failure" element={<RegistrationFailureScreen />} />
|
||||
<Route path="/onboarding/kyc-failure" element={<KycFailureScreen />} />
|
||||
<Route path="/proving" element={<ProvingScreen />} />
|
||||
<Route path="/proving/result" element={<VerificationResultScreen />} />
|
||||
<Route path="/settings" element={<SettingsScreen />} />
|
||||
@@ -49,7 +57,7 @@ export const App: React.FC = () => (
|
||||
{import.meta.env.DEV && <Route path="/debug/keychain" element={<KeychainDebugScreen />} />}
|
||||
<Route path="/account/verified" element={<VerificationResultScreen />} />
|
||||
<Route path="/coming-soon" element={<ComingSoonScreen />} />
|
||||
<Route path="/tunnel/tour/:step" element={<TourScreen />} />
|
||||
<Route path="/tunnel/tour/:step" element={<TunnelTourScreen />} />
|
||||
<Route path="/tunnel/kyc" element={<KycMockScreen />} />
|
||||
<Route path="/tunnel/registration/country" element={<TunnelCountryPickerScreen />} />
|
||||
<Route path="/tunnel/registration/id-type" element={<TunnelIDTypeScreen />} />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const MockRegistrationFailureButton: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const onOpenRegistrationFailureMock = useCallback(() => {
|
||||
navigate('/onboarding/failure?mock=registration-failure');
|
||||
}, [navigate]);
|
||||
|
||||
if (!import.meta.env.DEV) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenRegistrationFailureMock}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 10,
|
||||
border: '1px solid rgba(15, 23, 42, 0.16)',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255, 255, 255, 0.94)',
|
||||
color: '#0F172A',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(15, 23, 42, 0.12)',
|
||||
}}
|
||||
>
|
||||
Mock failure
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,27 @@
|
||||
@font-face {
|
||||
font-family: 'Advercase-Regular';
|
||||
font-family: Advercase;
|
||||
src: url('/fonts/Advercase-Regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DINOT-Bold';
|
||||
src: url('/fonts/DINOT-Bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'DINOT-Medium';
|
||||
font-family: 'DIN OT';
|
||||
src: url('/fonts/DINOT-Medium.otf') format('opentype');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBMPlexMono-Regular';
|
||||
src: url('/fonts/IBMPlexMono-Regular.otf') format('opentype');
|
||||
font-family: 'DIN OT';
|
||||
src: url('/fonts/DINOT-Bold.otf') format('opentype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
src: url('/fonts/IBMPlexMono-Regular.otf') format('opentype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ globalThis.Buffer = Buffer;
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<div style={{ display: 'flex', flex: 1, height: '100vh', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flex: 1, height: '100vh', width: '100%', maxWidth: 430, margin: '0 auto' }}>
|
||||
<BridgeProvider>
|
||||
<App />
|
||||
</BridgeProvider>
|
||||
|
||||
@@ -23,6 +23,16 @@ body {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tour4-lottie-scale {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tour4-lottie-scale svg {
|
||||
transform: scale(1.35) !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ComingSoonScreen as EuclidComingSoonScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../providers/SelfClientProvider';
|
||||
import { getCountryName, renderFlag } from '../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../utils/insets';
|
||||
|
||||
export const ComingSoonScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -38,7 +39,7 @@ export const ComingSoonScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<EuclidComingSoonScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
countryCode={countryCode}
|
||||
countryName={getCountryName(countryCode)}
|
||||
subtitle={
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { IDCardProps } from '@selfxyz/euclid';
|
||||
import { DevModeScreen as EuclidDevModeScreen, LeftArrowIcon } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { mockDocumentStore } from '../../utils/mockDocumentStore';
|
||||
|
||||
const ageOptions = ['18 or older', '21 or older', '25 or older', '30 or older'];
|
||||
const expiryOptions = ['1 year', '2 years', '5 years', '10 years'];
|
||||
@@ -38,6 +40,7 @@ export const DevModeScreen: React.FC = () => {
|
||||
const onResetAllValues = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('dev_mode_reset');
|
||||
mockDocumentStore.clear();
|
||||
setDocumentType('passport');
|
||||
setNationality('united states of america');
|
||||
setAgeIndex(1);
|
||||
@@ -60,7 +63,7 @@ export const DevModeScreen: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<EuclidDevModeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
idCard={idCard}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { LeftArrowIcon, NotificationPreferencesScreen as EuclidNotificationPreferencesScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const defaultToggles = [
|
||||
{ key: 'self', label: 'Allow Self notifications', description: 'App updates and more' },
|
||||
@@ -45,7 +46,7 @@ export const NotificationPreferencesScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<EuclidNotificationPreferencesScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
onBack={onBack}
|
||||
toggles={toggles}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const SecurityScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -64,7 +65,7 @@ export const SecurityScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<EuclidSecurityScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
cloudKeyIcon={CloudKeyIcon}
|
||||
lockIcon={LockIcon}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const SettingsScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -37,7 +38,7 @@ export const SettingsScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SettingsViewScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
escapeIcon={({ size, color }) => <LeftArrowIcon size={size} color={color} />}
|
||||
infoIcon={({ size, color }) => <QuestionCircleStrokeIcon size={size} color={color} />}
|
||||
onClose={onBack}
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { IDCardVariant } from '@selfxyz/euclid';
|
||||
import { GearIcon, HomeScreen as EuclidHomeScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { mockDocumentStore } from '../../utils/mockDocumentStore';
|
||||
|
||||
interface DocumentEntry {
|
||||
id: string;
|
||||
@@ -53,9 +55,11 @@ const docCategoryToTitle = (category: string): string => {
|
||||
|
||||
export const HomeScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { documents, analytics, haptic } = useSelfClient();
|
||||
const [catalog, setCatalog] = useState<DocumentCatalog | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const mockCatalog = useSyncExternalStore(mockDocumentStore.subscribe, () => mockDocumentStore.getCatalog());
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
try {
|
||||
@@ -72,13 +76,23 @@ export const HomeScreen: React.FC = () => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
const hasDocuments = catalog && catalog.documents.length > 0;
|
||||
const firstDoc = hasDocuments ? catalog.documents[0] : undefined;
|
||||
const allDocuments = [...(catalog?.documents ?? []), ...mockCatalog.documents];
|
||||
const hasDocuments = allDocuments.length > 0;
|
||||
const firstDoc = hasDocuments ? allDocuments[0] : undefined;
|
||||
const skipOnboardingRedirect = Boolean(
|
||||
(location.state as { skipOnboardingRedirect?: boolean } | null)?.skipOnboardingRedirect,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !hasDocuments && !skipOnboardingRedirect) {
|
||||
navigate('/onboarding/tour/1', { replace: true });
|
||||
}
|
||||
}, [hasDocuments, loading, navigate, skipOnboardingRedirect]);
|
||||
|
||||
const onAddDocument = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('home_add_document_pressed');
|
||||
navigate('/onboarding/country');
|
||||
navigate('/onboarding/tour/1');
|
||||
}, [navigate, haptic, analytics]);
|
||||
|
||||
const onSettings = useCallback(() => {
|
||||
@@ -86,7 +100,13 @@ export const HomeScreen: React.FC = () => {
|
||||
navigate('/settings');
|
||||
}, [navigate, haptic]);
|
||||
|
||||
if (loading) {
|
||||
const onRestartOnboarding = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
mockDocumentStore.clear();
|
||||
navigate('/onboarding/tour/1');
|
||||
}, [haptic, navigate]);
|
||||
|
||||
if (loading || (!hasDocuments && !skipOnboardingRedirect)) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -112,25 +132,51 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<EuclidHomeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
idCard={
|
||||
firstDoc
|
||||
? {
|
||||
variant: docCategoryToVariant(firstDoc.documentCategory),
|
||||
title: docCategoryToTitle(firstDoc.documentCategory),
|
||||
subtitle: firstDoc.isRegistered ? 'Registered' : 'Pending registration',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pointsCardProps={{ points: 0 }}
|
||||
showAddIdCTA={!hasDocuments}
|
||||
onAddIdPress={onAddDocument}
|
||||
topNavigationPrimaryButton={{
|
||||
variant: 'secondary-icon',
|
||||
icon: ({ size, color }) => <GearIcon size={size} color={color} />,
|
||||
onPress: onSettings,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestartOnboarding}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 10,
|
||||
border: '1px solid rgba(15, 23, 42, 0.16)',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255, 255, 255, 0.94)',
|
||||
color: '#0F172A',
|
||||
padding: '8px 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 24px rgba(15, 23, 42, 0.12)',
|
||||
}}
|
||||
>
|
||||
Restart onboarding
|
||||
</button>
|
||||
)}
|
||||
<EuclidHomeScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
idCard={
|
||||
firstDoc
|
||||
? {
|
||||
variant: docCategoryToVariant(firstDoc.documentCategory),
|
||||
title: docCategoryToTitle(firstDoc.documentCategory),
|
||||
subtitle: firstDoc.isRegistered ? 'Registered' : 'Pending registration',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pointsCardProps={{ points: 0 }}
|
||||
showAddIdCTA={!hasDocuments}
|
||||
onAddIdPress={onAddDocument}
|
||||
topNavigationPrimaryButton={{
|
||||
variant: 'secondary-icon',
|
||||
icon: ({ size, color }) => <GearIcon size={size} color={color} />,
|
||||
onPress: onSettings,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,24 +4,35 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CheckCircleIcon, colors, StatusState } from '@selfxyz/euclid';
|
||||
import { StatusState } from '@selfxyz/euclid';
|
||||
import type { VerificationResult } from '@selfxyz/webview-bridge';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
|
||||
export const ConfirmIdentificationScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
const { request, verificationId } = useVerificationRequest();
|
||||
const { nextPath, countryCode, documentType } =
|
||||
(location.state as { nextPath?: string; countryCode?: string; documentType?: string } | null) ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
haptic.trigger('success');
|
||||
}, [haptic]);
|
||||
|
||||
const onConfirm = useCallback(async () => {
|
||||
if (nextPath) {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('ownership_confirmed', { nextPath });
|
||||
navigate(nextPath, { replace: true, state: { countryCode, documentType } });
|
||||
return;
|
||||
}
|
||||
|
||||
const result: VerificationResult = {
|
||||
success: true,
|
||||
userId: request.userId,
|
||||
@@ -42,16 +53,21 @@ export const ConfirmIdentificationScreen: React.FC = () => {
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
}, [analytics, haptic, lifecycle, navigate, request.userId, verificationId]);
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate, nextPath, request.userId, verificationId]);
|
||||
|
||||
return (
|
||||
<StatusState
|
||||
variant="success"
|
||||
title="Confirm your identity"
|
||||
description="By continuing, you certify that this passport, biometric ID or Aadhaar card belongs to you and is not stolen or forged. Once registered with Self, this document will be permanently linked to your identity and can't be linked to another one."
|
||||
buttonText="Confirm"
|
||||
onButtonPress={onConfirm}
|
||||
icon={<CheckCircleIcon size={64} color={colors.green500} />}
|
||||
/>
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<StatusState
|
||||
variant="success"
|
||||
title="Confirm your identity"
|
||||
description="By continuing, you certify that this passport, biometric ID or Aadhaar card belongs to you and is not stolen or forged. Once registered with Self, this document will be permanently linked to your identity and can't be linked to another one."
|
||||
animationSource="/animations/proof-success.json"
|
||||
animationSize={240}
|
||||
loopAnimation={false}
|
||||
buttonText="Confirm"
|
||||
onButtonPress={onConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,9 +8,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CountryPickerScreen as EuclidCountryPickerScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import countryDocumentTypes from '../../data/country-document-types.json';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { getCountryName, renderFlag } from '../../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
type CountryData = Record<string, string[]>;
|
||||
const countryData = countryDocumentTypes as CountryData;
|
||||
@@ -39,16 +41,19 @@ export const CountryPickerScreen: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<EuclidCountryPickerScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
countries={countries}
|
||||
isLoading={false}
|
||||
onCountrySelect={onSelect}
|
||||
onClose={() => navigate('/')}
|
||||
renderFlag={renderFlag}
|
||||
getCountryName={getCountryName}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
/>
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<EuclidCountryPickerScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
countries={countries}
|
||||
isLoading={false}
|
||||
onCountrySelect={onSelect}
|
||||
onClose={() => navigate('/', { state: { skipOnboardingRedirect: true } })}
|
||||
renderFlag={renderFlag}
|
||||
getCountryName={getCountryName}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { IDType } from '@selfxyz/euclid';
|
||||
import { IDTypeScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { getCountryName, renderFlag } from '../../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const docTypeToIDType = (docType: string): IDType => {
|
||||
switch (docType) {
|
||||
@@ -43,6 +45,16 @@ export const IDSelectionScreen: React.FC = () => {
|
||||
documentTypes?: string[];
|
||||
}) || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!countryCode || documentTypes.length === 0) {
|
||||
navigate('/onboarding/country', { replace: true });
|
||||
}
|
||||
}, [countryCode, documentTypes.length, navigate]);
|
||||
|
||||
if (!countryCode || documentTypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idTypes = documentTypes.map(docTypeToIDType);
|
||||
|
||||
const onSelect = useCallback(
|
||||
@@ -61,15 +73,18 @@ export const IDSelectionScreen: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<IDTypeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
countryCode={countryCode}
|
||||
countryName={getCountryName(countryCode)}
|
||||
idTypes={idTypes}
|
||||
onIDTypeSelect={onSelect}
|
||||
onBack={() => navigate(-1)}
|
||||
renderFlag={renderFlag}
|
||||
renderIDTypeIcon={renderIDTypeIcon}
|
||||
/>
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<IDTypeScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
countryCode={countryCode}
|
||||
countryName={getCountryName(countryCode)}
|
||||
idTypes={idTypes}
|
||||
onIDTypeSelect={onSelect}
|
||||
onBack={() => navigate(-1)}
|
||||
renderFlag={renderFlag}
|
||||
renderIDTypeIcon={renderIDTypeIcon}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { KycFailureScreen as EuclidKycFailureScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import type { MockOnboardingNavigationState } from '../../utils/mockOnboardingFlow';
|
||||
import { getProviderPath } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const KycFailureScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const state = (location.state as MockOnboardingNavigationState | null) ?? null;
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('kyc_failure_dismissed');
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}, [analytics, haptic, navigate]);
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('kyc_failure_retry_pressed');
|
||||
navigate(getProviderPath(state?.retryMockOutcome ?? 'success'), {
|
||||
state: {
|
||||
countryCode: state?.countryCode,
|
||||
documentType: state?.documentType,
|
||||
},
|
||||
});
|
||||
}, [analytics, haptic, navigate, state?.countryCode, state?.documentType, state?.retryMockOutcome]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<EuclidKycFailureScreen {...WEB_SAFE_AREA} onDismiss={handleDismiss} onTryAgain={handleTryAgain} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -3,195 +3,76 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button, colors, Description, spacing, Title } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
import type { KycProviderResult } from '../../types/kycProvider';
|
||||
import { fetchSumsubAccessToken, launchSumsubWebSdk } from '../../utils/sumsubProvider';
|
||||
|
||||
const CONTAINER_ID = 'sumsub-websdk-container';
|
||||
|
||||
type Phase = 'loading' | 'active' | 'error';
|
||||
import type { MockOnboardingNavigationState } from '../../utils/mockOnboardingFlow';
|
||||
import {
|
||||
createMockProviderResult,
|
||||
getMockOutcomeFromSearch,
|
||||
getMockOutcomeSearch,
|
||||
} from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const ProviderLaunchScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
const { verificationId: ctxVerificationId } = useVerificationRequest();
|
||||
const { verificationId } = useVerificationRequest();
|
||||
const mockOutcome = getMockOutcomeFromSearch(location.search);
|
||||
|
||||
const { countryCode = '', documentType = '' } =
|
||||
(location.state as {
|
||||
countryCode?: string;
|
||||
documentType?: string;
|
||||
}) || {};
|
||||
|
||||
const verificationId = ctxVerificationId;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>(!verificationId ? 'error' : 'loading');
|
||||
const [errorMessage, setErrorMessage] = useState(!verificationId ? 'Missing verification context' : '');
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const destroyRef = useRef<(() => void) | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_complete', {
|
||||
status: result.status,
|
||||
provider: result.provider,
|
||||
});
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_error', {
|
||||
status: result.status,
|
||||
errorCode: result.error?.code,
|
||||
provider: result.provider,
|
||||
});
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
const { countryCode, documentType } = (location.state as MockOnboardingNavigationState | null) ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!verificationId) {
|
||||
setPhase('error');
|
||||
setErrorMessage('Missing verification context');
|
||||
return;
|
||||
}
|
||||
|
||||
mountedRef.current = true;
|
||||
|
||||
analytics.trackEvent('provider_launch_started', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
const providerResult = createMockProviderResult({
|
||||
outcome: mockOutcome,
|
||||
verificationId,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { token } = await fetchSumsubAccessToken(controller.signal);
|
||||
if (cancelled) return;
|
||||
analytics.trackEvent('provider_mock_completed', {
|
||||
status: providerResult.status,
|
||||
mockOutcome,
|
||||
});
|
||||
|
||||
const destroy = await launchSumsubWebSdk({
|
||||
accessToken: token,
|
||||
containerId: CONTAINER_ID,
|
||||
verificationId,
|
||||
onComplete: handleComplete,
|
||||
onError: handleError,
|
||||
onMessage: (type, payload) => {
|
||||
analytics.trackEvent('provider_message', {
|
||||
messageType: type,
|
||||
hasPayload: payload != null,
|
||||
});
|
||||
},
|
||||
});
|
||||
navigate(`/onboarding/provider-result${getMockOutcomeSearch(mockOutcome)}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
providerResult,
|
||||
countryCode,
|
||||
documentType,
|
||||
retryMockOutcome: mockOutcome,
|
||||
},
|
||||
});
|
||||
}, 700);
|
||||
|
||||
if (cancelled) {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
destroyRef.current = destroy;
|
||||
setPhase('active');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : 'Failed to launch provider';
|
||||
analytics.trackEvent('provider_launch_failed', { error: message });
|
||||
setPhase('error');
|
||||
setErrorMessage(message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
controller.abort();
|
||||
destroyRef.current?.();
|
||||
destroyRef.current = null;
|
||||
};
|
||||
}, [analytics, countryCode, documentType, handleComplete, handleError, verificationId, retryCount]);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [analytics, countryCode, documentType, mockOutcome, navigate, verificationId]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_back_pressed', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/');
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_retry_pressed');
|
||||
setPhase('loading');
|
||||
setErrorMessage('');
|
||||
setRetryCount(c => c + 1);
|
||||
}, [haptic, analytics]);
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.slate50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 24,
|
||||
padding: spacing.xl,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.md,
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Title textAlign="center">Unable to launch verification</Title>
|
||||
<Description>{errorMessage}</Description>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary-label" text="Try Again" fullWidth onPress={handleRetry} />
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, mockOutcome, navigate]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -202,41 +83,32 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
backgroundColor: colors.white,
|
||||
}}
|
||||
>
|
||||
{phase === 'loading' && (
|
||||
<MockRegistrationFailureButton />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
gap: spacing.md,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: spacing.md }}>
|
||||
<Title textAlign="center">Loading verification...</Title>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
id={CONTAINER_ID}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: phase === 'active' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<Title textAlign="center">Launching verification</Title>
|
||||
<Description textAlign="center">Preparing the mocked provider handoff for your registration flow.</Description>
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,94 +3,124 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CheckCircleIcon, colors, StatusState, WarningOctagonIcon } from '@selfxyz/euclid';
|
||||
import { colors, Description, spacing, Title } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import type { KycProviderResult } from '../../types/kycProvider';
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
success: {
|
||||
variant: 'success' as const,
|
||||
title: 'Verification Submitted',
|
||||
description:
|
||||
'Your identity documents have been submitted for verification. You can continue once the review is complete.',
|
||||
buttonText: 'Continue',
|
||||
},
|
||||
partial: {
|
||||
variant: 'success' as const,
|
||||
title: 'Verification In Progress',
|
||||
description: 'Your documents have been submitted and are under review. This may take a few minutes.',
|
||||
buttonText: 'Continue',
|
||||
},
|
||||
cancel: {
|
||||
variant: 'fail' as const,
|
||||
title: 'Verification Cancelled',
|
||||
description: 'You cancelled the verification process. You can try again when ready.',
|
||||
buttonText: 'Go Back',
|
||||
},
|
||||
error: {
|
||||
variant: 'fail' as const,
|
||||
title: 'Verification Failed',
|
||||
description: 'Something went wrong during verification. Please try again.',
|
||||
buttonText: 'Try Again',
|
||||
},
|
||||
};
|
||||
import type { MockOnboardingNavigationState } from '../../utils/mockOnboardingFlow';
|
||||
import { createMockProviderResult, getMockOutcomeFromSearch } from '../../utils/mockOnboardingFlow';
|
||||
|
||||
export const ProviderResultScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
const mockOutcome = getMockOutcomeFromSearch(location.search);
|
||||
const state =
|
||||
(location.state as ({ providerResult?: KycProviderResult } & MockOnboardingNavigationState) | null) ?? null;
|
||||
|
||||
const { providerResult } = (location.state as { providerResult?: KycProviderResult }) || {};
|
||||
const providerResult = state?.providerResult ?? createMockProviderResult({ outcome: mockOutcome });
|
||||
|
||||
const status = providerResult?.status ?? 'error';
|
||||
const config = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.error;
|
||||
const isSuccess = status === 'success' || status === 'partial';
|
||||
|
||||
const description =
|
||||
status === 'error' && providerResult?.error?.message ? providerResult.error.message : config.description;
|
||||
|
||||
const onButtonPress = useCallback(() => {
|
||||
useEffect(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_result_action_pressed', { status });
|
||||
analytics.trackEvent('provider_result_received', {
|
||||
status: providerResult.status,
|
||||
mockOutcome,
|
||||
});
|
||||
|
||||
if (status === 'cancel') {
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
const retryable = providerResult?.error?.retryable !== false;
|
||||
if (retryable) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
navigate('/');
|
||||
const timer = window.setTimeout(() => {
|
||||
if (providerResult.status === 'success' || providerResult.status === 'partial') {
|
||||
navigate('/onboarding/confirm', {
|
||||
replace: true,
|
||||
state: {
|
||||
nextPath: '/onboarding/success',
|
||||
countryCode: state?.countryCode,
|
||||
documentType: state?.documentType,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/proving');
|
||||
}, [analytics, haptic, lifecycle, navigate, providerResult, status]);
|
||||
if (providerResult.status === 'cancel') {
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
if (window.history.length > 1) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
navigate('/', {
|
||||
replace: true,
|
||||
state: { skipOnboardingRedirect: true },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerResult.error?.retryable !== false) {
|
||||
navigate('/onboarding/kyc-failure', {
|
||||
replace: true,
|
||||
state: {
|
||||
countryCode: state?.countryCode,
|
||||
documentType: state?.documentType,
|
||||
retryMockOutcome: mockOutcome,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/onboarding/failure', { replace: true });
|
||||
}, 450);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
analytics,
|
||||
haptic,
|
||||
lifecycle,
|
||||
mockOutcome,
|
||||
navigate,
|
||||
providerResult.error?.retryable,
|
||||
providerResult.status,
|
||||
state?.countryCode,
|
||||
state?.documentType,
|
||||
]);
|
||||
|
||||
return (
|
||||
<StatusState
|
||||
variant={config.variant}
|
||||
title={config.title}
|
||||
description={description}
|
||||
buttonText={config.buttonText}
|
||||
onButtonPress={onButtonPress}
|
||||
icon={
|
||||
isSuccess ? (
|
||||
<CheckCircleIcon size={64} color={colors.green500} />
|
||||
) : (
|
||||
<WarningOctagonIcon size={64} color={colors.red500} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.white,
|
||||
padding: spacing.lg,
|
||||
}}
|
||||
>
|
||||
<MockRegistrationFailureButton />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<Title textAlign="center">Processing verification result</Title>
|
||||
<Description textAlign="center">
|
||||
Routing your mocked provider outcome to the next registration step.
|
||||
</Description>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { RegistrationFailureScreen as EuclidRegistrationFailureScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const RegistrationFailureScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('registration_failure_dismissed');
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}, [analytics, haptic, navigate]);
|
||||
|
||||
const handleTryDifferentMethod = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('registration_failure_try_again');
|
||||
navigate('/onboarding/tour/1');
|
||||
}, [analytics, haptic, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<EuclidRegistrationFailureScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
onDismiss={handleDismiss}
|
||||
onTryDifferentMethod={handleTryDifferentMethod}
|
||||
copy={{ tryDifferentMethod: 'Try again' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ScanSuccessScreen as EuclidScanSuccessScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
import { mockDocumentStore } from '../../utils/mockDocumentStore';
|
||||
|
||||
export const ScanSuccessScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic } = useSelfClient();
|
||||
const { countryCode, documentType } =
|
||||
(location.state as { countryCode?: string; documentType?: string } | null) ?? {};
|
||||
|
||||
const persisted = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!persisted.current && countryCode && documentType) {
|
||||
mockDocumentStore.addDocument(countryCode, documentType);
|
||||
persisted.current = true;
|
||||
}
|
||||
}, [countryCode, documentType]);
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('registration_success_finished');
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}, [analytics, haptic, navigate]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<EuclidScanSuccessScreen
|
||||
{...WEB_SAFE_AREA}
|
||||
navLabel="Registration"
|
||||
totalSteps={4}
|
||||
currentStep={4}
|
||||
title="Your ID is now registered"
|
||||
onClose={goHome}
|
||||
onFinish={goHome}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
59
packages/webview-app/src/screens/onboarding/TourScreen.tsx
Normal file
59
packages/webview-app/src/screens/onboarding/TourScreen.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { LaunchTour1Screen, LaunchTour2Screen, LaunchTour3Screen, LaunchTour4Screen } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const TourScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { step } = useParams<{ step: string }>();
|
||||
const stepNumber = Number.parseInt(step ?? '1', 10);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
navigate(stepNumber < 4 ? `/onboarding/tour/${stepNumber + 1}` : '/onboarding/provider');
|
||||
}, [navigate, stepNumber]);
|
||||
|
||||
const onRestore = useCallback(() => {
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}, [navigate]);
|
||||
|
||||
switch (step) {
|
||||
case '1':
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<LaunchTour1Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />
|
||||
</>
|
||||
);
|
||||
case '2':
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<LaunchTour2Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />
|
||||
</>
|
||||
);
|
||||
case '3':
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
<LaunchTour3Screen {...WEB_SAFE_AREA} onNext={onNext} onRestore={onRestore} />
|
||||
</>
|
||||
);
|
||||
case '4':
|
||||
return (
|
||||
<div className="tour4-lottie-scale">
|
||||
<MockRegistrationFailureButton />
|
||||
<LaunchTour4Screen {...WEB_SAFE_AREA} onNext={onNext} onSkip={onNext} onRestore={onRestore} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Navigate to="/onboarding/tour/1" replace />;
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import type { VerificationResult } from '@selfxyz/webview-bridge';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
function titleCaseDisclosure(disclosure: string): string {
|
||||
return disclosure
|
||||
@@ -76,7 +77,7 @@ export const ProvingScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ProofRequestScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
variant={proving ? 'loading' : 'default'}
|
||||
onClose={onCancel}
|
||||
onConfirm={onVerify}
|
||||
@@ -85,7 +86,6 @@ export const ProvingScreen: React.FC = () => {
|
||||
appEndpoint={appEndpoint}
|
||||
timestamp={timestamp}
|
||||
items={proofItems}
|
||||
// TODO: hardcoding for now, fetch real value
|
||||
documentType="passport"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CheckCircleIcon, colors, StatusState, WarningOctagonIcon } from '@selfxyz/euclid';
|
||||
import { colors, StatusState, WarningOctagonIcon } from '@selfxyz/euclid';
|
||||
import type { VerificationResult } from '@selfxyz/webview-bridge';
|
||||
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
@@ -46,23 +46,22 @@ export const VerificationResultScreen: React.FC = () => {
|
||||
}, [analytics, haptic, lifecycle, navigate, result, resultSent]);
|
||||
|
||||
return (
|
||||
<StatusState
|
||||
variant={success ? 'success' : 'fail'}
|
||||
title={success ? 'ID Verified' : 'Verification Failed'}
|
||||
description={
|
||||
success
|
||||
? "Your document's information is now protected by Self ID. Just scan a participating partner's QR code to prove your identity."
|
||||
: (error ?? 'Something went wrong during verification. Please try again.')
|
||||
}
|
||||
buttonText="Continue"
|
||||
onButtonPress={onContinue}
|
||||
icon={
|
||||
success ? (
|
||||
<CheckCircleIcon size={64} color={colors.green500} />
|
||||
) : (
|
||||
<WarningOctagonIcon size={64} color={colors.red500} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<StatusState
|
||||
variant={success ? 'success' : 'fail'}
|
||||
title={success ? 'ID Verified' : 'Verification Failed'}
|
||||
description={
|
||||
success
|
||||
? "Your document's information is now protected by Self ID. Just scan a participating partner's QR code to prove your identity."
|
||||
: (error ?? 'Something went wrong during verification. Please try again.')
|
||||
}
|
||||
animationSource={success ? '/animations/proof-success.json' : undefined}
|
||||
animationSize={240}
|
||||
loopAnimation={false}
|
||||
buttonText="Continue"
|
||||
onButtonPress={onContinue}
|
||||
icon={success ? undefined : <WarningOctagonIcon size={64} color={colors.red500} />}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export const KycMockScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onContinue = useCallback(() => {
|
||||
navigate('/tunnel/registration/country');
|
||||
navigate('/tunnel/proof/receipt');
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { LaunchTour1Screen, LaunchTour2Screen, LaunchTour3Screen, LaunchTour4Screen } from '@selfxyz/euclid';
|
||||
|
||||
const insets = { top: 0, bottom: 0 };
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
export const TourScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,13 +21,13 @@ export const TourScreen: React.FC = () => {
|
||||
|
||||
switch (step) {
|
||||
case '1':
|
||||
return <LaunchTour1Screen insets={insets} onNext={onNext} />;
|
||||
return <LaunchTour1Screen {...WEB_SAFE_AREA} onNext={onNext} />;
|
||||
case '2':
|
||||
return <LaunchTour2Screen insets={insets} onNext={onNext} />;
|
||||
return <LaunchTour2Screen {...WEB_SAFE_AREA} onNext={onNext} />;
|
||||
case '3':
|
||||
return <LaunchTour3Screen insets={insets} onNext={onNext} />;
|
||||
return <LaunchTour3Screen {...WEB_SAFE_AREA} onNext={onNext} />;
|
||||
case '4':
|
||||
return <LaunchTour4Screen insets={insets} onNext={onNext} />;
|
||||
return <LaunchTour4Screen {...WEB_SAFE_AREA} onNext={onNext} />;
|
||||
default:
|
||||
return <Navigate to="/tunnel/tour/1" replace />;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { CountryPickerScreen as EuclidCountryPickerScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { getCountryName, renderFlag } from '../../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_COUNTRIES = [
|
||||
{ countryCode: 'US' },
|
||||
@@ -41,7 +42,7 @@ export const TunnelCountryPickerScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<EuclidCountryPickerScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
countries={MOCK_COUNTRIES}
|
||||
isLoading={false}
|
||||
onCountrySelect={onCountrySelect}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { IDType } from '@selfxyz/euclid';
|
||||
import { IDTypeScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { getCountryName, renderFlag } from '../../utils/countryFlags';
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const docTypeToIDType = (docType: string): IDType => {
|
||||
switch (docType) {
|
||||
@@ -45,7 +46,7 @@ export const TunnelIDTypeScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<IDTypeScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
countryCode={countryCode}
|
||||
countryName={getCountryName(countryCode)}
|
||||
idTypes={idTypes}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ProofRequestScreen, SelfLogo } from '@selfxyz/euclid';
|
||||
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ label: 'Full Name' },
|
||||
{ label: 'Date of Birth' },
|
||||
@@ -28,7 +30,7 @@ export const TunnelProofReceiptScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ProofRequestScreen
|
||||
insets={{ top: 0, bottom: 0 }}
|
||||
{...WEB_SAFE_AREA}
|
||||
variant="default"
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirm}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ProofGenerationScreen } from '@selfxyz/euclid';
|
||||
|
||||
import { WEB_SAFE_AREA } from '../../utils/insets';
|
||||
|
||||
const MOCK_ID_CARD = {
|
||||
variant: 'passport' as const,
|
||||
title: 'Passport',
|
||||
@@ -24,5 +26,5 @@ export const TunnelProvingScreen: React.FC = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigate]);
|
||||
|
||||
return <ProofGenerationScreen insets={{ top: 0, bottom: 0 }} step="generatingProof" idCardProps={MOCK_ID_CARD} />;
|
||||
return <ProofGenerationScreen {...WEB_SAFE_AREA} step="generatingProof" idCardProps={MOCK_ID_CARD} />;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import type React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CheckCircleIcon, colors, StatusState } from '@selfxyz/euclid';
|
||||
import { StatusState } from '@selfxyz/euclid';
|
||||
|
||||
export const TunnelResultScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -20,9 +20,11 @@ export const TunnelResultScreen: React.FC = () => {
|
||||
variant="success"
|
||||
title="Identity Verified"
|
||||
description="Your identity has been verified. You can now use Self ID to prove your identity to participating partners."
|
||||
animationSource="/animations/proof-success.json"
|
||||
animationSize={240}
|
||||
loopAnimation={false}
|
||||
buttonText="Continue"
|
||||
onButtonPress={onContinue}
|
||||
icon={<CheckCircleIcon size={64} color={colors.green500} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
8
packages/webview-app/src/utils/insets.ts
Normal file
8
packages/webview-app/src/utils/insets.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createSafeAreaProps } from '@selfxyz/euclid';
|
||||
|
||||
/**
|
||||
* Safe area props for the WebView context.
|
||||
* In a native shell, these would come from the OS safe area APIs.
|
||||
* For browser preview, we use small fixed values for visual padding.
|
||||
*/
|
||||
export const WEB_SAFE_AREA = createSafeAreaProps({ top: 16, bottom: 16 });
|
||||
100
packages/webview-app/src/utils/mockDocumentStore.ts
Normal file
100
packages/webview-app/src/utils/mockDocumentStore.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
interface MockDocument {
|
||||
id: string;
|
||||
documentType: string;
|
||||
documentCategory: string;
|
||||
data: string;
|
||||
mock: boolean;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
interface MockDocumentCatalog {
|
||||
documents: MockDocument[];
|
||||
selectedDocumentId?: string;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const STORAGE_KEY = 'self_mock_documents';
|
||||
|
||||
function loadFromStorage(): MockDocument[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(docs: MockDocument[]): void {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(docs));
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
let documents: MockDocument[] = loadFromStorage();
|
||||
let snapshot: MockDocumentCatalog = buildSnapshot(documents);
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function buildSnapshot(docs: MockDocument[]): MockDocumentCatalog {
|
||||
return { documents: docs, selectedDocumentId: docs[0]?.id };
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
snapshot = buildSnapshot(documents);
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
const docTypeToCategory = (documentType: string): string => {
|
||||
switch (documentType) {
|
||||
case 'p':
|
||||
return 'passport';
|
||||
case 'i':
|
||||
return 'id_card';
|
||||
case 'a':
|
||||
return 'aadhaar';
|
||||
default:
|
||||
return 'passport';
|
||||
}
|
||||
};
|
||||
|
||||
export const mockDocumentStore = {
|
||||
getCatalog(): MockDocumentCatalog {
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
addDocument(countryCode: string, documentType: string): MockDocument {
|
||||
const doc: MockDocument = {
|
||||
id: `mock-${Date.now()}`,
|
||||
documentType,
|
||||
documentCategory: docTypeToCategory(documentType),
|
||||
data: JSON.stringify({ countryCode, documentType, mock: true }),
|
||||
mock: true,
|
||||
isRegistered: true,
|
||||
};
|
||||
documents = [...documents, doc];
|
||||
saveToStorage(documents);
|
||||
notify();
|
||||
return doc;
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
documents = [];
|
||||
saveToStorage(documents);
|
||||
notify();
|
||||
},
|
||||
|
||||
hasDocuments(): boolean {
|
||||
return documents.length > 0;
|
||||
},
|
||||
|
||||
subscribe(fn: Listener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
};
|
||||
97
packages/webview-app/src/utils/mockOnboardingFlow.ts
Normal file
97
packages/webview-app/src/utils/mockOnboardingFlow.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
export interface MockOnboardingNavigationState {
|
||||
countryCode?: string;
|
||||
documentType?: string;
|
||||
retryMockOutcome?: MockRegistrationOutcome;
|
||||
nextPath?: string;
|
||||
}
|
||||
|
||||
export type MockRegistrationOutcome = 'success' | 'kyc-failure' | 'registration-failure' | 'cancel';
|
||||
|
||||
const DEFAULT_OUTCOME: MockRegistrationOutcome = 'success';
|
||||
const MOCKS_ENABLED = import.meta.env.DEV;
|
||||
|
||||
export const createMockProviderResult = ({
|
||||
outcome,
|
||||
verificationId,
|
||||
}: {
|
||||
outcome: MockRegistrationOutcome;
|
||||
verificationId?: string;
|
||||
}): KycProviderResult => {
|
||||
const resolvedVerificationId = verificationId ?? 'mock-verification';
|
||||
|
||||
switch (outcome) {
|
||||
case 'success':
|
||||
return {
|
||||
status: 'success',
|
||||
verificationId: resolvedVerificationId,
|
||||
provider: 'mock-provider',
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
case 'kyc-failure':
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId: resolvedVerificationId,
|
||||
provider: 'mock-provider',
|
||||
completedAt: new Date().toISOString(),
|
||||
error: {
|
||||
code: 'provider_unavailable',
|
||||
message: "We couldn't verify your ID this time. Please try again.",
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
case 'registration-failure':
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId: resolvedVerificationId,
|
||||
provider: 'mock-provider',
|
||||
completedAt: new Date().toISOString(),
|
||||
error: {
|
||||
code: 'provider_rejected',
|
||||
message: 'We were unable to register this document.',
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
case 'cancel':
|
||||
return {
|
||||
status: 'cancel',
|
||||
verificationId: resolvedVerificationId,
|
||||
provider: 'mock-provider',
|
||||
completedAt: new Date().toISOString(),
|
||||
error: {
|
||||
code: 'provider_cancelled',
|
||||
message: 'Verification was cancelled.',
|
||||
retryable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getMockOutcomeFromSearch = (search: string): MockRegistrationOutcome => {
|
||||
if (!MOCKS_ENABLED) {
|
||||
return DEFAULT_OUTCOME;
|
||||
}
|
||||
|
||||
const value = new URLSearchParams(search).get('mock');
|
||||
|
||||
switch (value) {
|
||||
case 'success':
|
||||
case 'kyc-failure':
|
||||
case 'registration-failure':
|
||||
case 'cancel':
|
||||
return value;
|
||||
default:
|
||||
return DEFAULT_OUTCOME;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMockOutcomeSearch = (outcome: MockRegistrationOutcome = DEFAULT_OUTCOME): string =>
|
||||
MOCKS_ENABLED ? `?mock=${outcome}` : '';
|
||||
|
||||
export const getProviderPath = (outcome: MockRegistrationOutcome): string =>
|
||||
`/onboarding/provider${getMockOutcomeSearch(outcome)}`;
|
||||
@@ -178,6 +178,10 @@ export class WebViewBridge {
|
||||
return Promise.reject(new Error('Bridge has been destroyed'));
|
||||
}
|
||||
|
||||
if (!this.transport) {
|
||||
return Promise.reject(new Error(`No transport available for bridge request: ${domain}.${method}`));
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const message: BridgeRequest = {
|
||||
type: 'request',
|
||||
|
||||
@@ -137,6 +137,12 @@ On **March 25, 2026**, the active SDK execution changed again:
|
||||
5. **Keep active specs aligned with the current pass.** If a top-level doc
|
||||
makes production provider or native work sound like a prerequisite for the
|
||||
current migration, it is stale and should be corrected.
|
||||
6. **Euclid screens require asset and inset verification.** Every screen
|
||||
imported from `@selfxyz/euclid` must be checked for URL-path asset
|
||||
references (Lottie animations, background images) and safe-area inset
|
||||
props. Missing assets cause silent failures (blank animations, black
|
||||
backgrounds). See the **Euclid Screen Migration Checklist** in
|
||||
`CLAUDE.md` for the full protocol.
|
||||
|
||||
## Where To Work
|
||||
|
||||
|
||||
@@ -83,6 +83,27 @@ That means preserving:
|
||||
|
||||
Do not redesign the screens during migration.
|
||||
|
||||
### Asset Requirements
|
||||
|
||||
Euclid screens reference Lottie animations, background images, and fonts via
|
||||
URL paths served from the app's `public/` directory. These are **not** bundled
|
||||
by the euclid package. You must copy them from the
|
||||
[euclid storybook public assets](https://github.com/selfxyz/euclid/tree/main/packages/storybook/public)
|
||||
into `packages/webview-app/public/`.
|
||||
|
||||
For tour screens specifically:
|
||||
|
||||
| Asset path | Source |
|
||||
| --------------------------------------- | ------------------------------- |
|
||||
| `/animations/app-tour-welcome.json` | `storybook/public/animations/` |
|
||||
| `/animations/app-tour-generate.json` | `storybook/public/animations/` |
|
||||
| `/animations/app-tour-proof.json` | `storybook/public/animations/` |
|
||||
| `/animations/app-tour-get-started.json` | `storybook/public/animations/` |
|
||||
| `/backgrounds/dialogue-background.jpg` | `storybook/public/backgrounds/` |
|
||||
|
||||
See the **Euclid Screen Migration Checklist** in `CLAUDE.md` for the full
|
||||
asset, inset, and validation protocol.
|
||||
|
||||
## Mock Flow Strategy
|
||||
|
||||
The webview app needs a temporary way to trigger unique registration branches
|
||||
|
||||
40
yarn.lock
40
yarn.lock
@@ -10670,12 +10670,26 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@selfxyz/euclid-core@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "@selfxyz/euclid-core@npm:1.2.0"
|
||||
"@selfxyz/euclid-core@npm:1.2.6, @selfxyz/euclid-core@npm:^1.2.6":
|
||||
version: 1.2.6
|
||||
resolution: "@selfxyz/euclid-core@npm:1.2.6"
|
||||
peerDependencies:
|
||||
react: ">=18.2.0"
|
||||
checksum: 10c0/198bcbd21e674a67ac801eb2b5f6a56cb08ae9fd09904deda78f07999540d3a7ccbfffa56599ab4ced2d8204732d2d7576d63eb14983a076223b546cef31e4b0
|
||||
checksum: 10c0/0496da70a4eb5e3b7bbae51f2379f8080ed21950eac6e09c3ad9a39020399ef0a7065798d626e86ac187a34ee40c04e0bac1d8186aa1507900c83b667ac6816a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@selfxyz/euclid@npm:1.2.6":
|
||||
version: 1.2.6
|
||||
resolution: "@selfxyz/euclid@npm:1.2.6"
|
||||
dependencies:
|
||||
"@lottiefiles/dotlottie-react": "npm:^0.18.4"
|
||||
"@selfxyz/euclid-core": "npm:^1.2.6"
|
||||
lottie-react: "npm:^2.4.1"
|
||||
peerDependencies:
|
||||
react: ">=18.2.0"
|
||||
react-dom: ">=18.2.0"
|
||||
checksum: 10c0/af56ea1abc20645dbe58f29d062ab4e2686f626de92fb5626cfbe7971b4ec31926edf8e0d598dd173a4f1b9749e6ccf690e3189e7694178e2653a2ddff195f4d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10692,20 +10706,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@selfxyz/euclid@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "@selfxyz/euclid@npm:1.2.0"
|
||||
dependencies:
|
||||
"@lottiefiles/dotlottie-react": "npm:^0.18.4"
|
||||
"@selfxyz/euclid-core": "npm:^1.2.0"
|
||||
lottie-react: "npm:^2.4.1"
|
||||
peerDependencies:
|
||||
react: ">=18.2.0"
|
||||
react-dom: ">=18.2.0"
|
||||
checksum: 10c0/73e50bcf8540c0f2d91e3b232016c90f1019587acf24a79d9fd0e1d07cfb977aae5336972769bc824eade8ea02e02b3e583906cffa11853b389a7e2b20a34e58
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@selfxyz/kmp-sdk-test-app@workspace:packages/kmp-sdk-test-app":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@selfxyz/kmp-sdk-test-app@workspace:packages/kmp-sdk-test-app"
|
||||
@@ -11146,8 +11146,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@selfxyz/webview-app@workspace:packages/webview-app"
|
||||
dependencies:
|
||||
"@selfxyz/euclid": "npm:^1.2.0"
|
||||
"@selfxyz/euclid-core": "npm:^1.2.0"
|
||||
"@selfxyz/euclid": "npm:1.2.6"
|
||||
"@selfxyz/euclid-core": "npm:1.2.6"
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:^"
|
||||
"@selfxyz/webview-bridge": "workspace:^"
|
||||
"@sumsub/websdk": "npm:^2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user