@myazahq/kyc-sdk-react-native 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -5
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +8 -3
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DialCodePicker.tsx +47 -68
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -185
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MyazaInput.tsx +17 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/PhoneNumberInput.tsx +8 -0
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StepView.tsx +17 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +42 -4
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +20 -1
- package/src/config/businessSteps.ts +8 -2
- package/src/config/consentStep.ts +19 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/stepOrder.ts +79 -5
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/workflowMerge.ts +11 -5
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/BusinessDetailsFields.tsx +3 -0
- package/src/screens/BusinessDetailsStep.tsx +2 -0
- package/src/screens/BusinessDocumentSlot.tsx +3 -2
- package/src/screens/BusinessDocumentsStep.tsx +4 -55
- package/src/screens/CompanyInfoFields.tsx +6 -1
- package/src/screens/ConsentStep.tsx +6 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +4 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +26 -56
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +154 -52
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -99
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +71 -164
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +93 -1
- package/src/services/api-verify-types.ts +37 -0
- package/src/services/api.ts +133 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +7 -0
- package/src/store/address.ts +97 -0
- package/src/store/derive.ts +29 -4
- package/src/store/kycStore.ts +112 -3
- package/src/store/serverConfig.ts +18 -0
- package/src/store/session.ts +23 -4
- package/src/store/state.ts +134 -0
- package/src/store/submit.ts +18 -1
- package/src/types/config.ts +48 -1
- package/src/types/verification.ts +12 -0
- package/src/types/workflow.ts +97 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { CountryFlag } from '../../components/CountryFlag';
|
|
7
|
+
import { MyazaText } from '../../components/Typography';
|
|
8
|
+
import { MyazaInput } from '../../components/MyazaInput';
|
|
9
|
+
import type { AddressFieldMode } from '../../lib/address-field-modes';
|
|
10
|
+
|
|
11
|
+
// The edit-details form primitives (200-line split from DetailsSheet).
|
|
12
|
+
// Mirrors the web SDK's DetailsSheetFields; keep the two in lockstep.
|
|
13
|
+
|
|
14
|
+
/** undefined = never touched (the map's prefill shows); '' = cleared. The
|
|
15
|
+
* distinction is what lets an applicant DELETE a wrong prefill without the
|
|
16
|
+
* field refilling itself under their cursor. */
|
|
17
|
+
export interface DetailValues {
|
|
18
|
+
propertyNumber: string;
|
|
19
|
+
street?: string;
|
|
20
|
+
unit?: string;
|
|
21
|
+
propertyName: string;
|
|
22
|
+
directions: string;
|
|
23
|
+
neighbourhood?: string;
|
|
24
|
+
city?: string;
|
|
25
|
+
state?: string;
|
|
26
|
+
postcode?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DetailPatch = Partial<DetailValues>;
|
|
30
|
+
|
|
31
|
+
export function EditField({
|
|
32
|
+
label,
|
|
33
|
+
value,
|
|
34
|
+
placeholder,
|
|
35
|
+
maxLength,
|
|
36
|
+
disabled,
|
|
37
|
+
multiline,
|
|
38
|
+
required,
|
|
39
|
+
onChange,
|
|
40
|
+
}: {
|
|
41
|
+
label: string;
|
|
42
|
+
value: string;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
maxLength: number;
|
|
45
|
+
disabled?: boolean;
|
|
46
|
+
multiline?: boolean;
|
|
47
|
+
/** Workflow-required (addressCollection.fields): marked, and the pin
|
|
48
|
+
* step's Continue holds until it is filled. */
|
|
49
|
+
required?: boolean;
|
|
50
|
+
onChange: (value: string) => void;
|
|
51
|
+
}): React.ReactElement {
|
|
52
|
+
return (
|
|
53
|
+
<MyazaInput
|
|
54
|
+
value={value}
|
|
55
|
+
onChangeText={onChange}
|
|
56
|
+
label={label}
|
|
57
|
+
required={required}
|
|
58
|
+
placeholder={placeholder}
|
|
59
|
+
maxLength={maxLength}
|
|
60
|
+
multiline={multiline}
|
|
61
|
+
editable={!disabled}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SectionHeading({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
67
|
+
const { colors } = useTheme();
|
|
68
|
+
return (
|
|
69
|
+
<MyazaText
|
|
70
|
+
variant="bodySmall"
|
|
71
|
+
color={colors.textSecondary}
|
|
72
|
+
style={{ fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.8, fontSize: 11, marginBottom: spacing.sm }}
|
|
73
|
+
>
|
|
74
|
+
{children}
|
|
75
|
+
</MyazaText>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read-only country row: the flow's country is a fact of the verification,
|
|
80
|
+
* not an address field — visually distinct from a disabled input. */
|
|
81
|
+
export function CountryRow({ country }: { country: string | null }): React.ReactElement | null {
|
|
82
|
+
const { colors } = useTheme();
|
|
83
|
+
if (!country) return null;
|
|
84
|
+
let name = country;
|
|
85
|
+
try {
|
|
86
|
+
name = new Intl.DisplayNames(['en'], { type: 'region' }).of(country) ?? country;
|
|
87
|
+
} catch {
|
|
88
|
+
// The ISO code stands in where DisplayNames is unavailable.
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<View>
|
|
92
|
+
{/* The SAME label treatment MyazaInput gives its own fields (web: one
|
|
93
|
+
`text-sm font-medium` for both), so the tile's box lines up with the
|
|
94
|
+
input beside it and the two labels read as one row. */}
|
|
95
|
+
<MyazaText variant="label" style={{ marginBottom: spacing.sm }}>
|
|
96
|
+
Country
|
|
97
|
+
</MyazaText>
|
|
98
|
+
<View
|
|
99
|
+
style={{
|
|
100
|
+
flexDirection: 'row',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
gap: spacing.xs + 2,
|
|
103
|
+
height: 48,
|
|
104
|
+
borderWidth: 1,
|
|
105
|
+
borderColor: colors.border,
|
|
106
|
+
borderRadius: radius.md,
|
|
107
|
+
backgroundColor: colors.backgroundSecondary,
|
|
108
|
+
paddingHorizontal: spacing.sm + 4,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<CountryFlag country={country} size={20} />
|
|
112
|
+
<MyazaText variant="bodyMedium" style={{ fontWeight: '600', flexShrink: 1 }} numberOfLines={1}>
|
|
113
|
+
{name}
|
|
114
|
+
</MyazaText>
|
|
115
|
+
</View>
|
|
116
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ marginTop: 4 }}>
|
|
117
|
+
From your verification
|
|
118
|
+
</MyazaText>
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// TOP-aligned, so the two columns' labels and boxes sit on one line. Bottom
|
|
124
|
+
// alignment pushed the country tile up by the height of its own caption, since
|
|
125
|
+
// it is the taller of the pair (web has no such problem: its grid stretches).
|
|
126
|
+
const twoCol = { flexDirection: 'row' as const, gap: spacing.sm, alignItems: 'flex-start' as const };
|
|
127
|
+
const col = { flex: 1, minWidth: 0 };
|
|
128
|
+
|
|
129
|
+
/** The "Area and region" section. Each field honours its workflow mode:
|
|
130
|
+
* 'off' hides it, 'required' marks it. Returns null when all four are off. */
|
|
131
|
+
export function AreaFields({
|
|
132
|
+
modes,
|
|
133
|
+
values,
|
|
134
|
+
parts,
|
|
135
|
+
shown,
|
|
136
|
+
country,
|
|
137
|
+
disabled,
|
|
138
|
+
onChange,
|
|
139
|
+
}: {
|
|
140
|
+
modes: Record<'neighbourhood' | 'city' | 'state' | 'postcode', AddressFieldMode>;
|
|
141
|
+
values: DetailValues;
|
|
142
|
+
parts: { area?: string | null; city?: string | null; state?: string | null; postcode?: string | null } | null;
|
|
143
|
+
/** Typed wins (a cleared field stays cleared); the map's answer fills the gap. */
|
|
144
|
+
shown: (typed: string | undefined, part: string | null | undefined) => string;
|
|
145
|
+
country: string | null;
|
|
146
|
+
disabled?: boolean;
|
|
147
|
+
onChange: (patch: DetailPatch) => void;
|
|
148
|
+
}): React.ReactElement | null {
|
|
149
|
+
const on = (k: keyof typeof modes) => modes[k] !== 'off';
|
|
150
|
+
const req = (k: keyof typeof modes) => modes[k] === 'required';
|
|
151
|
+
if (!on('neighbourhood') && !on('city') && !on('state') && !on('postcode')) return null;
|
|
152
|
+
return (
|
|
153
|
+
<View style={{ marginTop: spacing.md }}>
|
|
154
|
+
<SectionHeading>Area and region</SectionHeading>
|
|
155
|
+
{on('neighbourhood') ? (
|
|
156
|
+
<EditField label="Neighbourhood" value={shown(values.neighbourhood, parts?.area)} placeholder="e.g. Idim Ita" maxLength={80} disabled={disabled} required={req('neighbourhood')} onChange={(neighbourhood) => onChange({ neighbourhood })} />
|
|
157
|
+
) : null}
|
|
158
|
+
{on('city') || on('state') ? (
|
|
159
|
+
<View style={[twoCol, { marginTop: spacing.sm }]}>
|
|
160
|
+
{on('city') ? (
|
|
161
|
+
<View style={col}>
|
|
162
|
+
<EditField label="City" value={shown(values.city, parts?.city)} placeholder="e.g. Calabar" maxLength={80} disabled={disabled} required={req('city')} onChange={(city) => onChange({ city })} />
|
|
163
|
+
</View>
|
|
164
|
+
) : null}
|
|
165
|
+
{on('state') ? (
|
|
166
|
+
<View style={col}>
|
|
167
|
+
<EditField label="State" value={shown(values.state, parts?.state)} placeholder="e.g. Cross River" maxLength={80} disabled={disabled} required={req('state')} onChange={(state) => onChange({ state })} />
|
|
168
|
+
</View>
|
|
169
|
+
) : null}
|
|
170
|
+
</View>
|
|
171
|
+
) : null}
|
|
172
|
+
<View style={[twoCol, { marginTop: spacing.sm }]}>
|
|
173
|
+
{on('postcode') ? (
|
|
174
|
+
<View style={col}>
|
|
175
|
+
<EditField label="Area code" value={shown(values.postcode, parts?.postcode)} placeholder="e.g. 540281" maxLength={12} disabled={disabled} required={req('postcode')} onChange={(postcode) => onChange({ postcode })} />
|
|
176
|
+
</View>
|
|
177
|
+
) : null}
|
|
178
|
+
<View style={col}>
|
|
179
|
+
<CountryRow country={country} />
|
|
180
|
+
</View>
|
|
181
|
+
</View>
|
|
182
|
+
</View>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActivityIndicator, Image, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { DashedBorder } from '../../components/DashedBorder';
|
|
7
|
+
import { Icon } from '../../components/Icon';
|
|
8
|
+
import { OverlayPill, pillSurface } from './EntrancePills';
|
|
9
|
+
import { MyazaText } from '../../components/Typography';
|
|
10
|
+
|
|
11
|
+
const FRAME_HEIGHT = 300;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The entrance photo: the hero of its step, so it is a full-width dropzone
|
|
15
|
+
* rather than a button in a list.
|
|
16
|
+
*
|
|
17
|
+
* Filled, it shows the photo itself with Replace and Remove over it. A RESTORED
|
|
18
|
+
* session holds the uploaded mediaId but not the bytes, so that case gets an
|
|
19
|
+
* honest "photo added" placeholder instead of a broken image.
|
|
20
|
+
*/
|
|
21
|
+
export function EntranceDropzone({
|
|
22
|
+
uploaded,
|
|
23
|
+
uploading,
|
|
24
|
+
previewUri,
|
|
25
|
+
required,
|
|
26
|
+
onPick,
|
|
27
|
+
onRemove,
|
|
28
|
+
}: {
|
|
29
|
+
uploaded: boolean;
|
|
30
|
+
uploading: boolean;
|
|
31
|
+
/** Local preview of the picked file; absent on a restored session. */
|
|
32
|
+
previewUri: string | null;
|
|
33
|
+
required: boolean;
|
|
34
|
+
onPick: () => void;
|
|
35
|
+
onRemove: () => void;
|
|
36
|
+
}): React.ReactElement {
|
|
37
|
+
const { colors } = useTheme();
|
|
38
|
+
|
|
39
|
+
if (uploaded && !uploading) {
|
|
40
|
+
return (
|
|
41
|
+
<View
|
|
42
|
+
style={{
|
|
43
|
+
height: FRAME_HEIGHT,
|
|
44
|
+
borderRadius: radius.md,
|
|
45
|
+
overflow: 'hidden',
|
|
46
|
+
borderWidth: 1,
|
|
47
|
+
borderColor: colors.border,
|
|
48
|
+
backgroundColor: colors.backgroundSecondary,
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
justifyContent: 'center',
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{previewUri ? (
|
|
54
|
+
<Image
|
|
55
|
+
source={{ uri: previewUri }}
|
|
56
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
|
|
57
|
+
resizeMode="cover"
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<>
|
|
61
|
+
<Icon name="check" size={30} color={colors.success} />
|
|
62
|
+
<View style={{ height: spacing.sm }} />
|
|
63
|
+
<MyazaText variant="bodyMedium" style={{ fontWeight: '600' }}>
|
|
64
|
+
Entrance photo added
|
|
65
|
+
</MyazaText>
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{/* Web's tag: `text-[11px] font-semibold uppercase tracking-wider
|
|
70
|
+
text-primary` with a 12px check, on a 90% background. */}
|
|
71
|
+
<View
|
|
72
|
+
style={{
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
top: 12,
|
|
75
|
+
left: 12,
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
gap: 6,
|
|
79
|
+
paddingHorizontal: spacing.sm + 2,
|
|
80
|
+
paddingVertical: 4,
|
|
81
|
+
borderRadius: radius.full,
|
|
82
|
+
...pillSurface(colors.background, colors.textDark),
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<Icon name="circle-check" size={12} color={colors.primary} />
|
|
86
|
+
<MyazaText
|
|
87
|
+
variant="bodySmall"
|
|
88
|
+
color={colors.primary}
|
|
89
|
+
style={{ fontSize: 11, fontWeight: '600', letterSpacing: 0.6, textTransform: 'uppercase' }}
|
|
90
|
+
>
|
|
91
|
+
Entrance photo
|
|
92
|
+
</MyazaText>
|
|
93
|
+
</View>
|
|
94
|
+
|
|
95
|
+
<View
|
|
96
|
+
style={{
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
right: 12,
|
|
99
|
+
bottom: 12,
|
|
100
|
+
flexDirection: 'row',
|
|
101
|
+
gap: spacing.sm,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<OverlayPill icon="refresh-ccw" label="Replace" onPress={onPick} />
|
|
105
|
+
<OverlayPill icon="x" label="Remove" accessibilityLabel="Remove photo" onPress={onRemove} />
|
|
106
|
+
</View>
|
|
107
|
+
</View>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<Pressable
|
|
113
|
+
onPress={uploading ? undefined : onPick}
|
|
114
|
+
disabled={uploading}
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel="Take or upload a photo"
|
|
117
|
+
style={{
|
|
118
|
+
height: FRAME_HEIGHT,
|
|
119
|
+
alignItems: 'center',
|
|
120
|
+
justifyContent: 'center',
|
|
121
|
+
paddingHorizontal: spacing.lg,
|
|
122
|
+
borderRadius: radius.md,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<DashedBorder color={colors.border} radius={radius.md} strokeWidth={2} />
|
|
126
|
+
{/* Web's `h-14 w-14 rounded-2xl bg-primary/10 ring-1 ring-primary/20`,
|
|
127
|
+
the box Flutter draws too. */}
|
|
128
|
+
<View
|
|
129
|
+
style={{
|
|
130
|
+
width: 56,
|
|
131
|
+
height: 56,
|
|
132
|
+
borderRadius: radius.md,
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
justifyContent: 'center',
|
|
135
|
+
backgroundColor: `${colors.primary}1A`,
|
|
136
|
+
borderWidth: 1,
|
|
137
|
+
borderColor: `${colors.primary}33`,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{uploading ? (
|
|
141
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
142
|
+
) : (
|
|
143
|
+
<Icon name="camera" size={24} color={colors.primary} />
|
|
144
|
+
)}
|
|
145
|
+
</View>
|
|
146
|
+
<View style={{ height: spacing.md }} />
|
|
147
|
+
<MyazaText variant="body" style={{ fontWeight: '600' }}>
|
|
148
|
+
{uploading ? 'Uploading photo…' : 'Take or upload a photo'}
|
|
149
|
+
</MyazaText>
|
|
150
|
+
{uploading ? null : (
|
|
151
|
+
<>
|
|
152
|
+
<MyazaText
|
|
153
|
+
variant="bodyMedium"
|
|
154
|
+
color={colors.textSecondary}
|
|
155
|
+
style={{ marginTop: spacing.xs, textAlign: 'center' }}
|
|
156
|
+
>
|
|
157
|
+
{required
|
|
158
|
+
? 'The gate, front door or the building itself.'
|
|
159
|
+
: 'The gate, front door or the building itself. Optional.'}
|
|
160
|
+
</MyazaText>
|
|
161
|
+
<View
|
|
162
|
+
style={{
|
|
163
|
+
marginTop: spacing.md,
|
|
164
|
+
paddingHorizontal: spacing.sm + 2,
|
|
165
|
+
paddingVertical: 4,
|
|
166
|
+
borderRadius: radius.full,
|
|
167
|
+
backgroundColor: colors.backgroundSecondary,
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ fontSize: 11, fontWeight: '500' }}>
|
|
171
|
+
JPEG · PNG · WebP
|
|
172
|
+
</MyazaText>
|
|
173
|
+
</View>
|
|
174
|
+
</>
|
|
175
|
+
)}
|
|
176
|
+
</Pressable>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useWindowDimensions, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { Icon } from '../../components/Icon';
|
|
7
|
+
import { MyazaText } from '../../components/Typography';
|
|
8
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
9
|
+
import { StickyActions } from '../../components/StickyActions';
|
|
10
|
+
import { mapSurfaceHeight } from '../../lib/map-tiles';
|
|
11
|
+
|
|
12
|
+
// The entrance step's SANDBOX stand-in. A SANDBOX mount keeps the step real
|
|
13
|
+
// users get, on a static placeholder (the camera steps' rule): no Google
|
|
14
|
+
// loads from a test key, the server cans the verdict regardless, and "Use
|
|
15
|
+
// this view" simply advances. Mirrors the web SDK's preview branch; the
|
|
16
|
+
// Flutter twin is address_entrance_framing.dart.
|
|
17
|
+
|
|
18
|
+
export function EntrancePlaceholder({
|
|
19
|
+
hideSkip,
|
|
20
|
+
onSkip,
|
|
21
|
+
onUse,
|
|
22
|
+
}: {
|
|
23
|
+
hideSkip: boolean;
|
|
24
|
+
onSkip: () => void;
|
|
25
|
+
onUse: () => void;
|
|
26
|
+
}): React.ReactElement {
|
|
27
|
+
const { colors } = useTheme();
|
|
28
|
+
const height = mapSurfaceHeight(useWindowDimensions());
|
|
29
|
+
return (
|
|
30
|
+
<StickyActions
|
|
31
|
+
actions={
|
|
32
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
|
33
|
+
{!hideSkip ? (
|
|
34
|
+
<View style={{ flex: 1 }}>
|
|
35
|
+
<MyazaButton label="Skip" variant="outline" onPress={onSkip} />
|
|
36
|
+
</View>
|
|
37
|
+
) : null}
|
|
38
|
+
<View style={{ flex: 1 }}>
|
|
39
|
+
<MyazaButton label="Use this view" onPress={onUse} />
|
|
40
|
+
</View>
|
|
41
|
+
</View>
|
|
42
|
+
}
|
|
43
|
+
>
|
|
44
|
+
<View
|
|
45
|
+
style={{
|
|
46
|
+
height,
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
gap: spacing.sm,
|
|
50
|
+
// Web's `rounded-xl border-2 border-dashed bg-muted/40`.
|
|
51
|
+
borderRadius: radius.sm,
|
|
52
|
+
borderWidth: 2,
|
|
53
|
+
borderStyle: 'dashed',
|
|
54
|
+
borderColor: colors.border,
|
|
55
|
+
backgroundColor: `${colors.backgroundSecondary}66`,
|
|
56
|
+
paddingHorizontal: spacing.lg,
|
|
57
|
+
}}
|
|
58
|
+
>
|
|
59
|
+
<Icon name="landmark" size={32} color={`${colors.textSecondary}99`} />
|
|
60
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', maxWidth: 288 }}>
|
|
61
|
+
Applicants pan real street imagery to frame their entrance here. It loads only for real users.
|
|
62
|
+
</MyazaText>
|
|
63
|
+
</View>
|
|
64
|
+
</StickyActions>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { Icon, type IconName } from '../../components/Icon';
|
|
7
|
+
import { MyazaText } from '../../components/Typography';
|
|
8
|
+
|
|
9
|
+
// The pills that sit over a picked entrance photo (200-line split from
|
|
10
|
+
// EntranceDropzone). Web's `bg-background/90 shadow` surface, drawn the same
|
|
11
|
+
// on Flutter.
|
|
12
|
+
|
|
13
|
+
/** Web's overlay pill surface: `bg-background/90 shadow`. */
|
|
14
|
+
export function pillSurface(background: string, ink: string) {
|
|
15
|
+
return {
|
|
16
|
+
backgroundColor: `${background}E6`,
|
|
17
|
+
shadowColor: ink,
|
|
18
|
+
shadowOpacity: 0.12,
|
|
19
|
+
shadowRadius: 6,
|
|
20
|
+
shadowOffset: { width: 0, height: 2 },
|
|
21
|
+
elevation: 2,
|
|
22
|
+
} as const;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Web's `px-3 py-1.5 text-xs font-medium` pill with a 14px icon. */
|
|
26
|
+
export function OverlayPill({
|
|
27
|
+
icon,
|
|
28
|
+
label,
|
|
29
|
+
accessibilityLabel,
|
|
30
|
+
onPress,
|
|
31
|
+
}: {
|
|
32
|
+
icon: IconName;
|
|
33
|
+
label: string;
|
|
34
|
+
accessibilityLabel?: string;
|
|
35
|
+
onPress: () => void;
|
|
36
|
+
}): React.ReactElement {
|
|
37
|
+
const { colors } = useTheme();
|
|
38
|
+
return (
|
|
39
|
+
<Pressable
|
|
40
|
+
onPress={onPress}
|
|
41
|
+
accessibilityRole="button"
|
|
42
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
43
|
+
style={({ pressed }) => ({
|
|
44
|
+
flexDirection: 'row',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
gap: 6,
|
|
47
|
+
paddingHorizontal: spacing.sm + 4,
|
|
48
|
+
paddingVertical: 6,
|
|
49
|
+
borderRadius: radius.full,
|
|
50
|
+
...pillSurface(colors.background, colors.textDark),
|
|
51
|
+
opacity: pressed ? 0.8 : 1,
|
|
52
|
+
})}
|
|
53
|
+
>
|
|
54
|
+
<Icon name={icon} size={14} color={colors.textDark} />
|
|
55
|
+
<MyazaText variant="bodySmall" style={{ fontWeight: '500' }}>
|
|
56
|
+
{label}
|
|
57
|
+
</MyazaText>
|
|
58
|
+
</Pressable>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../../config/theme';
|
|
5
|
+
import { useTheme } from '../../components/runtime';
|
|
6
|
+
import { MyazaText } from '../../components/Typography';
|
|
7
|
+
import { MyazaButton } from '../../components/MyazaButton';
|
|
8
|
+
import { StickyActions } from '../../components/StickyActions';
|
|
9
|
+
import { loadWebView } from '../../lib/webview-available';
|
|
10
|
+
import {
|
|
11
|
+
MAP_FRAME_READY_TIMEOUT_MS,
|
|
12
|
+
buildStreetViewFrameSrc,
|
|
13
|
+
parseStreetViewFrameMessage,
|
|
14
|
+
} from '../../lib/map-frame';
|
|
15
|
+
import { captureStreetViewFrame, type StreetViewFrame } from '../../lib/street-view-fov';
|
|
16
|
+
import { mapSurfaceHeight, type LatLng } from '../../lib/map-tiles';
|
|
17
|
+
import { StreetViewChrome, streetViewFrameRect } from './StreetViewChrome';
|
|
18
|
+
|
|
19
|
+
// Street View on mobile, via OUR hosted /embed/street-view page in a WebView —
|
|
20
|
+
// the same model (and the same app grant) as FramedMapPicker. The page renders
|
|
21
|
+
// the panorama and streams the current view; THIS component owns the framing
|
|
22
|
+
// chrome, the frame-subtended fov maths and the capture decision, so hosted,
|
|
23
|
+
// embedded and native applicants meet the identical instrument. Mirrors the
|
|
24
|
+
// web SDK's FramedStreetView and the Flutter twin; keep the three in lockstep.
|
|
25
|
+
// A page that never says sv-ready (no webview module, blocked script, refused
|
|
26
|
+
// grant, no coverage) hands the step to the photo fallback via onUnavailable.
|
|
27
|
+
|
|
28
|
+
type Pov = { panoId: string; heading: number; pitch: number; viewFov: number };
|
|
29
|
+
|
|
30
|
+
export function FramedStreetView({
|
|
31
|
+
frameUrl,
|
|
32
|
+
pin,
|
|
33
|
+
onCaptured,
|
|
34
|
+
onSkip,
|
|
35
|
+
hideSkip,
|
|
36
|
+
onUnavailable,
|
|
37
|
+
}: {
|
|
38
|
+
frameUrl: string;
|
|
39
|
+
pin: LatLng;
|
|
40
|
+
onCaptured: (frame: StreetViewFrame) => void;
|
|
41
|
+
/** The applicant would rather add their own photo. */
|
|
42
|
+
onSkip: () => void;
|
|
43
|
+
/** streetView 'required': the skip affordance is removed while coverage
|
|
44
|
+
* exists (no-coverage still falls back — the client-UX gate only). */
|
|
45
|
+
hideSkip?: boolean;
|
|
46
|
+
/** No coverage, no grant, or no page — fall back to the photo. */
|
|
47
|
+
onUnavailable: () => void;
|
|
48
|
+
}): React.ReactElement {
|
|
49
|
+
const WebView = useMemo(loadWebView, []);
|
|
50
|
+
const { colors, mode } = useTheme();
|
|
51
|
+
const height = mapSurfaceHeight(useWindowDimensions());
|
|
52
|
+
const [ready, setReady] = useState(false);
|
|
53
|
+
const [viewWidth, setViewWidth] = useState(0);
|
|
54
|
+
const readyRef = useRef(false);
|
|
55
|
+
const latestPov = useRef<Pov | null>(null);
|
|
56
|
+
const gone = useRef(false);
|
|
57
|
+
const unavailableRef = useRef(onUnavailable);
|
|
58
|
+
unavailableRef.current = onUnavailable;
|
|
59
|
+
const unavailable = () => {
|
|
60
|
+
if (gone.current) return;
|
|
61
|
+
gone.current = true;
|
|
62
|
+
unavailableRef.current();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Built once per mount: a src that tracked the theme would reload the page.
|
|
66
|
+
const src = useMemo(
|
|
67
|
+
() => buildStreetViewFrameSrc(frameUrl, { pin, theme: mode === 'dark' ? 'dark' : 'light' }),
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
[frameUrl],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!WebView) {
|
|
74
|
+
unavailable();
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const timeout = setTimeout(() => {
|
|
78
|
+
if (!readyRef.current) unavailable();
|
|
79
|
+
}, MAP_FRAME_READY_TIMEOUT_MS);
|
|
80
|
+
return () => clearTimeout(timeout);
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [WebView]);
|
|
83
|
+
|
|
84
|
+
const capture = () => {
|
|
85
|
+
const pov = latestPov.current;
|
|
86
|
+
if (!pov) return;
|
|
87
|
+
// The promise on screen is the FRAME, so the captured fov is the slice the
|
|
88
|
+
// frame subtends of the reported viewport fov — same maths, same frame
|
|
89
|
+
// geometry as the hosted framer.
|
|
90
|
+
const frame = streetViewFrameRect(viewWidth, height);
|
|
91
|
+
onCaptured(captureStreetViewFrame(pov, frame.width, viewWidth));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<StickyActions
|
|
96
|
+
actions={
|
|
97
|
+
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
|
|
98
|
+
{!hideSkip ? (
|
|
99
|
+
<View style={{ flex: 1 }}>
|
|
100
|
+
<MyazaButton label="Skip" variant="outline" onPress={onSkip} />
|
|
101
|
+
</View>
|
|
102
|
+
) : null}
|
|
103
|
+
<View style={{ flex: 1 }}>
|
|
104
|
+
<MyazaButton label="Use this view" disabled={!ready} onPress={capture} />
|
|
105
|
+
</View>
|
|
106
|
+
</View>
|
|
107
|
+
}
|
|
108
|
+
>
|
|
109
|
+
<View
|
|
110
|
+
onLayout={(e) => setViewWidth(e.nativeEvent.layout.width)}
|
|
111
|
+
style={{ height, borderRadius: radius.md, overflow: 'hidden', backgroundColor: colors.backgroundSecondary }}
|
|
112
|
+
>
|
|
113
|
+
{WebView ? (
|
|
114
|
+
<WebView
|
|
115
|
+
source={{ uri: src }}
|
|
116
|
+
style={{ flex: 1, backgroundColor: 'transparent' }}
|
|
117
|
+
javaScriptEnabled
|
|
118
|
+
// Android: this WebView sits inside StickyActions' ScrollView, and a
|
|
119
|
+
// ScrollView intercepts every vertical drag its children start, so the
|
|
120
|
+
// map moved a few pixels and stopped while the page scrolled instead.
|
|
121
|
+
// nestedScrollEnabled makes the WebView ask the parent not to intercept
|
|
122
|
+
// (requestDisallowInterceptTouchEvent) for the life of the touch. No-op
|
|
123
|
+
// on iOS. Reported on a Galaxy S24, 2026-09-07.
|
|
124
|
+
nestedScrollEnabled
|
|
125
|
+
onError={unavailable}
|
|
126
|
+
onMessage={(event) => {
|
|
127
|
+
const msg = parseStreetViewFrameMessage(event.nativeEvent.data);
|
|
128
|
+
if (!msg) return;
|
|
129
|
+
if (msg.type === 'sv-ready') {
|
|
130
|
+
readyRef.current = true;
|
|
131
|
+
setReady(true);
|
|
132
|
+
} else if (msg.type === 'sv-unavailable') {
|
|
133
|
+
unavailable();
|
|
134
|
+
} else {
|
|
135
|
+
latestPov.current = { panoId: msg.panoId, heading: msg.heading, pitch: msg.pitch, viewFov: msg.viewFov };
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
) : null}
|
|
140
|
+
{!ready ? (
|
|
141
|
+
<View pointerEvents="none" style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center' }}>
|
|
142
|
+
<ActivityIndicator size="small" color={colors.primary} />
|
|
143
|
+
</View>
|
|
144
|
+
) : (
|
|
145
|
+
<StreetViewChrome viewportWidth={viewWidth} viewportHeight={height} />
|
|
146
|
+
)}
|
|
147
|
+
</View>
|
|
148
|
+
{ready ? (
|
|
149
|
+
<MyazaText variant="bodySmall" color={colors.textSecondary} style={{ textAlign: 'center', marginTop: spacing.sm }}>
|
|
150
|
+
Drag to look around until your gate or front door sits inside the frame.
|
|
151
|
+
</MyazaText>
|
|
152
|
+
) : null}
|
|
153
|
+
</StickyActions>
|
|
154
|
+
);
|
|
155
|
+
}
|