@whop/elements-react-native 0.1.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 +179 -0
- package/android/build.gradle +64 -0
- package/android/consumer-rules.pro +3 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/java/com/whop/elements/WhopAddressSuggestionsModule.kt +102 -0
- package/android/src/main/java/com/whop/elements/WhopAuthSessionModule.kt +126 -0
- package/android/src/main/java/com/whop/elements/WhopElementsPackage.kt +39 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonManager.kt +74 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonPressEvent.kt +17 -0
- package/android/src/main/java/com/whop/elements/WhopPayButtonView.kt +178 -0
- package/android/src/main/java/com/whop/elements/WhopPlatformPayModule.kt +212 -0
- package/assets/whop-wordmark-dark.png +0 -0
- package/assets/whop-wordmark-dark@2x.png +0 -0
- package/assets/whop-wordmark-dark@3x.png +0 -0
- package/assets/whop-wordmark-light.png +0 -0
- package/assets/whop-wordmark-light@2x.png +0 -0
- package/assets/whop-wordmark-light@3x.png +0 -0
- package/ios/Package.swift +59 -0
- package/ios/Sources/ElementsReactNative/WhopAddressSuggestionsImpl.swift +189 -0
- package/ios/Sources/ElementsReactNative/WhopAuthSessionImpl.swift +106 -0
- package/ios/Sources/ElementsReactNative/WhopPayButtonImpl.swift +89 -0
- package/ios/Sources/ElementsReactNative/WhopPlatformPayImpl.swift +181 -0
- package/ios/generated/WhopAddressSuggestionsModule.h +11 -0
- package/ios/generated/WhopAddressSuggestionsModule.mm +46 -0
- package/ios/generated/WhopAuthSessionModule.h +11 -0
- package/ios/generated/WhopAuthSessionModule.mm +45 -0
- package/ios/generated/WhopElementsShims.h +35 -0
- package/ios/generated/WhopPayButtonComponentView.h +12 -0
- package/ios/generated/WhopPayButtonComponentView.mm +82 -0
- package/ios/generated/WhopPlatformPayModule.h +11 -0
- package/ios/generated/WhopPlatformPayModule.mm +51 -0
- package/lib/module/api/client.js +139 -0
- package/lib/module/api/client.js.map +1 -0
- package/lib/module/api/client.test.js +137 -0
- package/lib/module/api/client.test.js.map +1 -0
- package/lib/module/api/types.js +2 -0
- package/lib/module/api/types.js.map +1 -0
- package/lib/module/controller/actions.js +148 -0
- package/lib/module/controller/actions.js.map +1 -0
- package/lib/module/controller/actions.test.js +102 -0
- package/lib/module/controller/actions.test.js.map +1 -0
- package/lib/module/controller/store.js +158 -0
- package/lib/module/controller/store.js.map +1 -0
- package/lib/module/core/address/country-data.js +1370 -0
- package/lib/module/core/address/country-data.js.map +1 -0
- package/lib/module/core/address/country-name.js +38 -0
- package/lib/module/core/address/country-name.js.map +1 -0
- package/lib/module/core/address/country-names.generated.js +249 -0
- package/lib/module/core/address/country-names.generated.js.map +1 -0
- package/lib/module/core/address/engine.js +245 -0
- package/lib/module/core/address/engine.js.map +1 -0
- package/lib/module/core/address/engine.test.js +216 -0
- package/lib/module/core/address/engine.test.js.map +1 -0
- package/lib/module/core/address/form-state.js +409 -0
- package/lib/module/core/address/form-state.js.map +1 -0
- package/lib/module/core/address/form-state.test.js +516 -0
- package/lib/module/core/address/form-state.test.js.map +1 -0
- package/lib/module/core/address/places.js +36 -0
- package/lib/module/core/address/places.js.map +1 -0
- package/lib/module/core/address/required-coverage.test.js +57 -0
- package/lib/module/core/address/required-coverage.test.js.map +1 -0
- package/lib/module/core/address/suggest.js +102 -0
- package/lib/module/core/address/suggest.js.map +1 -0
- package/lib/module/core/address/suggest.test.js +156 -0
- package/lib/module/core/address/suggest.test.js.map +1 -0
- package/lib/module/core/address/use-suggestions.js +33 -0
- package/lib/module/core/address/use-suggestions.js.map +1 -0
- package/lib/module/core/payments/address-change.js +91 -0
- package/lib/module/core/payments/address-change.js.map +1 -0
- package/lib/module/core/payments/billing-merge.js +62 -0
- package/lib/module/core/payments/billing-merge.js.map +1 -0
- package/lib/module/core/payments/browser-info.js +42 -0
- package/lib/module/core/payments/browser-info.js.map +1 -0
- package/lib/module/core/payments/bt.js +151 -0
- package/lib/module/core/payments/bt.js.map +1 -0
- package/lib/module/core/payments/bt.test.js +23 -0
- package/lib/module/core/payments/bt.test.js.map +1 -0
- package/lib/module/core/payments/currency.js +37 -0
- package/lib/module/core/payments/currency.js.map +1 -0
- package/lib/module/core/payments/detail.js +159 -0
- package/lib/module/core/payments/detail.js.map +1 -0
- package/lib/module/core/payments/detail.test.js +225 -0
- package/lib/module/core/payments/detail.test.js.map +1 -0
- package/lib/module/core/payments/errors.js +115 -0
- package/lib/module/core/payments/errors.js.map +1 -0
- package/lib/module/core/payments/mint.js +89 -0
- package/lib/module/core/payments/mint.js.map +1 -0
- package/lib/module/core/payments/plan.js +131 -0
- package/lib/module/core/payments/plan.js.map +1 -0
- package/lib/module/core/payments/refusals.js +17 -0
- package/lib/module/core/payments/refusals.js.map +1 -0
- package/lib/module/core/payments/secure-fields.js +87 -0
- package/lib/module/core/payments/secure-fields.js.map +1 -0
- package/lib/module/core/payments/tax-id-catalog.js +616 -0
- package/lib/module/core/payments/tax-id-catalog.js.map +1 -0
- package/lib/module/core/payments/wallet-contact.js +42 -0
- package/lib/module/core/payments/wallet-contact.js.map +1 -0
- package/lib/module/core/payments/wallet-contact.test.js +77 -0
- package/lib/module/core/payments/wallet-contact.test.js.map +1 -0
- package/lib/module/elements/address.js +467 -0
- package/lib/module/elements/address.js.map +1 -0
- package/lib/module/elements/branding.js +114 -0
- package/lib/module/elements/branding.js.map +1 -0
- package/lib/module/elements/card-fields/context.js +254 -0
- package/lib/module/elements/card-fields/context.js.map +1 -0
- package/lib/module/elements/card-fields/fields.js +309 -0
- package/lib/module/elements/card-fields/fields.js.map +1 -0
- package/lib/module/elements/card-fields/index.js +30 -0
- package/lib/module/elements/card-fields/index.js.map +1 -0
- package/lib/module/elements/card.js +107 -0
- package/lib/module/elements/card.js.map +1 -0
- package/lib/module/elements/email.js +84 -0
- package/lib/module/elements/email.js.map +1 -0
- package/lib/module/elements/payment/declared-fields.js +186 -0
- package/lib/module/elements/payment/declared-fields.js.map +1 -0
- package/lib/module/elements/payment/index.js +448 -0
- package/lib/module/elements/payment/index.js.map +1 -0
- package/lib/module/elements/payment/wallet.js +132 -0
- package/lib/module/elements/payment/wallet.js.map +1 -0
- package/lib/module/elements/tax-id.js +100 -0
- package/lib/module/elements/tax-id.js.map +1 -0
- package/lib/module/i18n/catalogs.js +103 -0
- package/lib/module/i18n/catalogs.js.map +1 -0
- package/lib/module/i18n/index.js +45 -0
- package/lib/module/i18n/index.js.map +1 -0
- package/lib/module/index.js +27 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/namespace.js +174 -0
- package/lib/module/namespace.js.map +1 -0
- package/lib/module/native/index.js +128 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/provider.js +56 -0
- package/lib/module/provider.js.map +1 -0
- package/lib/module/specs/NativeWhopAddressSuggestions.js +6 -0
- package/lib/module/specs/NativeWhopAddressSuggestions.js.map +1 -0
- package/lib/module/specs/NativeWhopAuthSession.js +10 -0
- package/lib/module/specs/NativeWhopAuthSession.js.map +1 -0
- package/lib/module/specs/NativeWhopPlatformPay.js +10 -0
- package/lib/module/specs/NativeWhopPlatformPay.js.map +1 -0
- package/lib/module/specs/WhopPayButtonNativeComponent.ts +22 -0
- package/lib/module/theme/appearance.js +85 -0
- package/lib/module/theme/appearance.js.map +1 -0
- package/lib/module/theme/appearance.test.js +124 -0
- package/lib/module/theme/appearance.test.js.map +1 -0
- package/lib/module/theme/provider.js +25 -0
- package/lib/module/theme/provider.js.map +1 -0
- package/lib/module/theme/resolve.js +69 -0
- package/lib/module/theme/resolve.js.map +1 -0
- package/lib/module/theme/scales.generated.js +419 -0
- package/lib/module/theme/scales.generated.js.map +1 -0
- package/lib/module/ui/element-frame.js +83 -0
- package/lib/module/ui/element-frame.js.map +1 -0
- package/lib/module/ui/payment-method-icon.js +69 -0
- package/lib/module/ui/payment-method-icon.js.map +1 -0
- package/lib/module/ui/primitives.js +266 -0
- package/lib/module/ui/primitives.js.map +1 -0
- package/lib/module/ui/select.js +281 -0
- package/lib/module/ui/select.js.map +1 -0
- package/lib/module/ui/suggestions.js +71 -0
- package/lib/module/ui/suggestions.js.map +1 -0
- package/lib/module/ui/whop-logo.js +42 -0
- package/lib/module/ui/whop-logo.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/api/client.d.ts +87 -0
- package/lib/typescript/src/api/client.d.ts.map +1 -0
- package/lib/typescript/src/api/types.d.ts +175 -0
- package/lib/typescript/src/api/types.d.ts.map +1 -0
- package/lib/typescript/src/controller/actions.d.ts +42 -0
- package/lib/typescript/src/controller/actions.d.ts.map +1 -0
- package/lib/typescript/src/controller/store.d.ts +90 -0
- package/lib/typescript/src/controller/store.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-data.d.ts +4 -0
- package/lib/typescript/src/core/address/country-data.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-name.d.ts +2 -0
- package/lib/typescript/src/core/address/country-name.d.ts.map +1 -0
- package/lib/typescript/src/core/address/country-names.generated.d.ts +2 -0
- package/lib/typescript/src/core/address/country-names.generated.d.ts.map +1 -0
- package/lib/typescript/src/core/address/engine.d.ts +97 -0
- package/lib/typescript/src/core/address/engine.d.ts.map +1 -0
- package/lib/typescript/src/core/address/form-state.d.ts +144 -0
- package/lib/typescript/src/core/address/form-state.d.ts.map +1 -0
- package/lib/typescript/src/core/address/places.d.ts +23 -0
- package/lib/typescript/src/core/address/places.d.ts.map +1 -0
- package/lib/typescript/src/core/address/suggest.d.ts +47 -0
- package/lib/typescript/src/core/address/suggest.d.ts.map +1 -0
- package/lib/typescript/src/core/address/use-suggestions.d.ts +3 -0
- package/lib/typescript/src/core/address/use-suggestions.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/address-change.d.ts +52 -0
- package/lib/typescript/src/core/payments/address-change.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/billing-merge.d.ts +39 -0
- package/lib/typescript/src/core/payments/billing-merge.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/browser-info.d.ts +12 -0
- package/lib/typescript/src/core/payments/browser-info.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/bt.d.ts +55 -0
- package/lib/typescript/src/core/payments/bt.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/currency.d.ts +7 -0
- package/lib/typescript/src/core/payments/currency.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/detail.d.ts +53 -0
- package/lib/typescript/src/core/payments/detail.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/errors.d.ts +62 -0
- package/lib/typescript/src/core/payments/errors.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/mint.d.ts +74 -0
- package/lib/typescript/src/core/payments/mint.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/plan.d.ts +69 -0
- package/lib/typescript/src/core/payments/plan.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/refusals.d.ts +12 -0
- package/lib/typescript/src/core/payments/refusals.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/secure-fields.d.ts +33 -0
- package/lib/typescript/src/core/payments/secure-fields.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/tax-id-catalog.d.ts +481 -0
- package/lib/typescript/src/core/payments/tax-id-catalog.d.ts.map +1 -0
- package/lib/typescript/src/core/payments/wallet-contact.d.ts +40 -0
- package/lib/typescript/src/core/payments/wallet-contact.d.ts.map +1 -0
- package/lib/typescript/src/elements/address.d.ts +55 -0
- package/lib/typescript/src/elements/address.d.ts.map +1 -0
- package/lib/typescript/src/elements/branding.d.ts +18 -0
- package/lib/typescript/src/elements/branding.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/context.d.ts +67 -0
- package/lib/typescript/src/elements/card-fields/context.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/fields.d.ts +15 -0
- package/lib/typescript/src/elements/card-fields/fields.d.ts.map +1 -0
- package/lib/typescript/src/elements/card-fields/index.d.ts +21 -0
- package/lib/typescript/src/elements/card-fields/index.d.ts.map +1 -0
- package/lib/typescript/src/elements/card.d.ts +21 -0
- package/lib/typescript/src/elements/card.d.ts.map +1 -0
- package/lib/typescript/src/elements/email.d.ts +31 -0
- package/lib/typescript/src/elements/email.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/declared-fields.d.ts +43 -0
- package/lib/typescript/src/elements/payment/declared-fields.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/index.d.ts +37 -0
- package/lib/typescript/src/elements/payment/index.d.ts.map +1 -0
- package/lib/typescript/src/elements/payment/wallet.d.ts +39 -0
- package/lib/typescript/src/elements/payment/wallet.d.ts.map +1 -0
- package/lib/typescript/src/elements/tax-id.d.ts +41 -0
- package/lib/typescript/src/elements/tax-id.d.ts.map +1 -0
- package/lib/typescript/src/i18n/catalogs.d.ts +7 -0
- package/lib/typescript/src/i18n/catalogs.d.ts.map +1 -0
- package/lib/typescript/src/i18n/index.d.ts +17 -0
- package/lib/typescript/src/i18n/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +42 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/namespace.d.ts +36 -0
- package/lib/typescript/src/namespace.d.ts.map +1 -0
- package/lib/typescript/src/native/index.d.ts +62 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/provider.d.ts +37 -0
- package/lib/typescript/src/provider.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopAddressSuggestions.d.ts +13 -0
- package/lib/typescript/src/specs/NativeWhopAddressSuggestions.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopAuthSession.d.ts +20 -0
- package/lib/typescript/src/specs/NativeWhopAuthSession.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeWhopPlatformPay.d.ts +24 -0
- package/lib/typescript/src/specs/NativeWhopPlatformPay.d.ts.map +1 -0
- package/lib/typescript/src/specs/WhopPayButtonNativeComponent.d.ts +21 -0
- package/lib/typescript/src/specs/WhopPayButtonNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/theme/appearance.d.ts +71 -0
- package/lib/typescript/src/theme/appearance.d.ts.map +1 -0
- package/lib/typescript/src/theme/provider.d.ts +11 -0
- package/lib/typescript/src/theme/provider.d.ts.map +1 -0
- package/lib/typescript/src/theme/resolve.d.ts +19 -0
- package/lib/typescript/src/theme/resolve.d.ts.map +1 -0
- package/lib/typescript/src/theme/scales.generated.d.ts +18 -0
- package/lib/typescript/src/theme/scales.generated.d.ts.map +1 -0
- package/lib/typescript/src/ui/element-frame.d.ts +32 -0
- package/lib/typescript/src/ui/element-frame.d.ts.map +1 -0
- package/lib/typescript/src/ui/payment-method-icon.d.ts +21 -0
- package/lib/typescript/src/ui/payment-method-icon.d.ts.map +1 -0
- package/lib/typescript/src/ui/primitives.d.ts +94 -0
- package/lib/typescript/src/ui/primitives.d.ts.map +1 -0
- package/lib/typescript/src/ui/select.d.ts +25 -0
- package/lib/typescript/src/ui/select.d.ts.map +1 -0
- package/lib/typescript/src/ui/suggestions.d.ts +15 -0
- package/lib/typescript/src/ui/suggestions.d.ts.map +1 -0
- package/lib/typescript/src/ui/whop-logo.d.ts +4 -0
- package/lib/typescript/src/ui/whop-logo.d.ts.map +1 -0
- package/package.json +121 -0
- package/scripts/check-swift.sh +27 -0
- package/scripts/codegen-shims.mjs +409 -0
- package/scripts/gen-country-names.mjs +58 -0
- package/scripts/gen-theme.mjs +124 -0
- package/scripts/gen-wordmark.mjs +68 -0
- package/scripts/mirror.mjs +112 -0
- package/src/api/client.ts +202 -0
- package/src/api/types.ts +166 -0
- package/src/controller/actions.ts +197 -0
- package/src/controller/store.ts +201 -0
- package/src/core/address/country-data.ts +927 -0
- package/src/core/address/country-name.ts +36 -0
- package/src/core/address/country-names.generated.ts +246 -0
- package/src/core/address/engine.ts +349 -0
- package/src/core/address/form-state.ts +479 -0
- package/src/core/address/places.ts +39 -0
- package/src/core/address/suggest.ts +153 -0
- package/src/core/address/use-suggestions.ts +37 -0
- package/src/core/payments/address-change.ts +121 -0
- package/src/core/payments/billing-merge.ts +91 -0
- package/src/core/payments/browser-info.ts +43 -0
- package/src/core/payments/bt.ts +176 -0
- package/src/core/payments/currency.ts +54 -0
- package/src/core/payments/detail.ts +204 -0
- package/src/core/payments/errors.ts +171 -0
- package/src/core/payments/mint.ts +130 -0
- package/src/core/payments/plan.ts +198 -0
- package/src/core/payments/refusals.ts +24 -0
- package/src/core/payments/secure-fields.ts +85 -0
- package/src/core/payments/tax-id-catalog.ts +320 -0
- package/src/core/payments/wallet-contact.ts +56 -0
- package/src/elements/address.tsx +570 -0
- package/src/elements/branding.tsx +87 -0
- package/src/elements/card-fields/context.tsx +310 -0
- package/src/elements/card-fields/fields.tsx +281 -0
- package/src/elements/card-fields/index.tsx +37 -0
- package/src/elements/card.tsx +101 -0
- package/src/elements/email.tsx +100 -0
- package/src/elements/payment/declared-fields.tsx +238 -0
- package/src/elements/payment/index.tsx +497 -0
- package/src/elements/payment/wallet.tsx +155 -0
- package/src/elements/tax-id.tsx +143 -0
- package/src/i18n/catalogs.ts +100 -0
- package/src/i18n/index.tsx +46 -0
- package/src/index.ts +68 -0
- package/src/namespace.tsx +231 -0
- package/src/native/index.ts +173 -0
- package/src/provider.tsx +76 -0
- package/src/specs/NativeWhopAddressSuggestions.ts +14 -0
- package/src/specs/NativeWhopAuthSession.ts +21 -0
- package/src/specs/NativeWhopPlatformPay.ts +25 -0
- package/src/specs/WhopPayButtonNativeComponent.ts +22 -0
- package/src/theme/appearance.ts +150 -0
- package/src/theme/provider.tsx +28 -0
- package/src/theme/resolve.ts +68 -0
- package/src/theme/scales.generated.ts +427 -0
- package/src/ui/element-frame.tsx +94 -0
- package/src/ui/payment-method-icon.tsx +55 -0
- package/src/ui/primitives.tsx +269 -0
- package/src/ui/select.tsx +255 -0
- package/src/ui/suggestions.tsx +69 -0
- package/src/ui/whop-logo.tsx +37 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// ported verbatim from frontend/apps/web/src/elements/address-form/form-state.ts — keep in sync when the web element changes
|
|
2
|
+
|
|
3
|
+
import type { CountryFormat, EmittedAddress } from './engine';
|
|
4
|
+
/**
|
|
5
|
+
* The address form's ONE state atom (useReducer shape) + the schema-driven central validator.
|
|
6
|
+
* Country lives IN the atom; hydration is ONE seed (defaultValues > detected IP country >
|
|
7
|
+
* currency-country hint > US) computed before first paint; reveal-vs-autofill is reducer
|
|
8
|
+
* state, not component-local flags. Pure module — the jsdom suites drive it directly.
|
|
9
|
+
*/
|
|
10
|
+
import type { ParsedPlaceAddress } from './places';
|
|
11
|
+
|
|
12
|
+
import { emitAddress, normalizeCountry, postalCodeMatches } from './engine';
|
|
13
|
+
|
|
14
|
+
export type AddressValueKey =
|
|
15
|
+
| 'name'
|
|
16
|
+
| 'first_name'
|
|
17
|
+
| 'last_name'
|
|
18
|
+
| 'organization'
|
|
19
|
+
| 'line1'
|
|
20
|
+
| 'line2'
|
|
21
|
+
| 'city'
|
|
22
|
+
| 'state'
|
|
23
|
+
| 'postal_code';
|
|
24
|
+
|
|
25
|
+
export type AddressValues = Partial<Record<AddressValueKey, string>>;
|
|
26
|
+
|
|
27
|
+
export interface CustomFieldSpec {
|
|
28
|
+
key: string;
|
|
29
|
+
label: string;
|
|
30
|
+
type: 'text' | 'select' | 'date' | 'phone';
|
|
31
|
+
position: 'after_name' | 'after_organization' | 'before_country' | 'after_address';
|
|
32
|
+
required?: boolean;
|
|
33
|
+
options?: string[];
|
|
34
|
+
format?: string;
|
|
35
|
+
autocomplete?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AddressFormConfig {
|
|
39
|
+
mode: 'billing' | 'shipping';
|
|
40
|
+
layout: 'compact' | 'full';
|
|
41
|
+
scope: 'minimal' | 'full';
|
|
42
|
+
name: 'combined' | 'split' | 'none';
|
|
43
|
+
organization: 'none' | 'name' | 'name_with_type';
|
|
44
|
+
line2: 'toggle' | 'always' | 'never';
|
|
45
|
+
autocomplete: boolean;
|
|
46
|
+
allowedCountries?: string[];
|
|
47
|
+
customFields?: CustomFieldSpec[];
|
|
48
|
+
/** Composed-mode method floor: contract keys the selected payment method additionally
|
|
49
|
+
* requires (unioned into the visible + required set). */
|
|
50
|
+
requiredFloor?: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AddressSeedInput {
|
|
54
|
+
defaultValues?: { name?: string; address?: Partial<EmittedAddress> };
|
|
55
|
+
detectedCountry?: string;
|
|
56
|
+
countryHint?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AddressFormState {
|
|
60
|
+
country: string;
|
|
61
|
+
values: AddressValues;
|
|
62
|
+
organizationType: 'business' | 'individual' | '';
|
|
63
|
+
custom: Record<string, string>;
|
|
64
|
+
revealed: boolean;
|
|
65
|
+
line2Open: boolean;
|
|
66
|
+
selecting: boolean;
|
|
67
|
+
touched: Partial<Record<string, boolean>>;
|
|
68
|
+
showAllErrors: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type AddressFormAction =
|
|
72
|
+
| { type: 'edit'; field: AddressValueKey; value: string; autofilled?: boolean }
|
|
73
|
+
| { type: 'edit-custom'; key: string; value: string }
|
|
74
|
+
| { type: 'set-organization-type'; value: 'business' | 'individual' | '' }
|
|
75
|
+
| { type: 'set-country'; country: string }
|
|
76
|
+
| { type: 'begin-select' }
|
|
77
|
+
| { type: 'select-failed' }
|
|
78
|
+
| { type: 'apply-place'; parsed: ParsedPlaceAddress; allowed: string[] }
|
|
79
|
+
| { type: 'reveal'; autofill?: boolean }
|
|
80
|
+
| { type: 'toggle-line2' }
|
|
81
|
+
| { type: 'blur'; field: string }
|
|
82
|
+
| { type: 'show-errors' };
|
|
83
|
+
|
|
84
|
+
export function allowedCountryList(config: AddressFormConfig, catalogCountries: string[]): string[] {
|
|
85
|
+
const raw = config.allowedCountries ?? [];
|
|
86
|
+
const allowed = raw.map((code) => normalizeCountry(code)).filter((code): code is string => Boolean(code));
|
|
87
|
+
// Fail LOUD, never open: a list where every entry failed ISO2 normalization (['USA']) is a
|
|
88
|
+
// config error — silently widening to the whole catalog would undo the merchant's restriction
|
|
89
|
+
// on a typo. Same refusal bar as the charge config's plan-XOR gate.
|
|
90
|
+
if (raw.length > 0 && allowed.length === 0) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`allowedCountries has no valid ISO2 entries (got ${JSON.stringify(raw)}) — use two-letter codes like 'US'`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return allowed.length > 0 ? allowed : catalogCountries;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function clampCountry(candidate: string | undefined, allowed: string[]): string | undefined {
|
|
99
|
+
return candidate && allowed.includes(candidate) ? candidate : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function seedAddressForm(
|
|
103
|
+
config: AddressFormConfig,
|
|
104
|
+
seed: AddressSeedInput,
|
|
105
|
+
catalogCountries: string[],
|
|
106
|
+
): AddressFormState {
|
|
107
|
+
const allowed = allowedCountryList(config, catalogCountries);
|
|
108
|
+
const country =
|
|
109
|
+
clampCountry(normalizeCountry(seed.defaultValues?.address?.country), allowed) ??
|
|
110
|
+
clampCountry(normalizeCountry(seed.detectedCountry), allowed) ??
|
|
111
|
+
clampCountry(normalizeCountry(seed.countryHint), allowed) ??
|
|
112
|
+
clampCountry('US', allowed) ??
|
|
113
|
+
allowed.at(0) ??
|
|
114
|
+
'US';
|
|
115
|
+
const preset = seed.defaultValues?.address ?? {};
|
|
116
|
+
const values: AddressValues = {};
|
|
117
|
+
const putSeed = (key: AddressValueKey, value: string | undefined) => {
|
|
118
|
+
if (value?.trim()) values[key] = value;
|
|
119
|
+
};
|
|
120
|
+
putSeed('name', seed.defaultValues?.name ?? preset.name);
|
|
121
|
+
putSeed('first_name', preset.first_name);
|
|
122
|
+
putSeed('last_name', preset.last_name);
|
|
123
|
+
putSeed('organization', preset.organization);
|
|
124
|
+
putSeed('line1', preset.line1);
|
|
125
|
+
putSeed('line2', preset.line2);
|
|
126
|
+
putSeed('city', preset.city);
|
|
127
|
+
putSeed('state', preset.state);
|
|
128
|
+
putSeed('postal_code', preset.postal_code);
|
|
129
|
+
const seededBeyondLine1 = Boolean(values.city || values.state || values.postal_code || values.line2);
|
|
130
|
+
return {
|
|
131
|
+
country,
|
|
132
|
+
values,
|
|
133
|
+
organizationType: preset.organization_type ?? '',
|
|
134
|
+
custom: {},
|
|
135
|
+
revealed: config.scope === 'minimal' || seededBeyondLine1,
|
|
136
|
+
line2Open: config.line2 === 'always' || Boolean(values.line2),
|
|
137
|
+
selecting: false,
|
|
138
|
+
touched: {},
|
|
139
|
+
showAllErrors: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const GRID_FIELDS: ReadonlySet<AddressValueKey> = new Set(['city', 'state', 'postal_code', 'line2']);
|
|
144
|
+
|
|
145
|
+
export function addressFormReducer(state: AddressFormState, action: AddressFormAction): AddressFormState {
|
|
146
|
+
switch (action.type) {
|
|
147
|
+
case 'edit': {
|
|
148
|
+
const values = { ...state.values, [action.field]: action.value };
|
|
149
|
+
// Two autofill-reveal rails: a value landing in a still-hidden grid field (Chrome fills
|
|
150
|
+
// hidden SELECTS despite skipping hidden inputs), and an `autofilled` edit — an
|
|
151
|
+
// inputType-less/insertReplacementText input event on a visible field, the shape every
|
|
152
|
+
// engine's fill produces and typing never does.
|
|
153
|
+
const landedHidden = !state.revealed && GRID_FIELDS.has(action.field) && action.value.trim().length > 0;
|
|
154
|
+
const autofillReveal = Boolean(action.autofilled) && action.value.trim().length > 0;
|
|
155
|
+
// An autofill commit also opens line2 in toggle mode, in the SAME state commit that
|
|
156
|
+
// reveals the grid: a conditionally-rendered line2 has no control for Chrome's refill
|
|
157
|
+
// pass to land in, so an autofilled line2 value would be silently dropped. Once open it
|
|
158
|
+
// stays open — re-collapsing on an empty settle would be another control-list mutation.
|
|
159
|
+
const autofillCommit = landedHidden || autofillReveal;
|
|
160
|
+
return {
|
|
161
|
+
...state,
|
|
162
|
+
values,
|
|
163
|
+
revealed: state.revealed || autofillCommit,
|
|
164
|
+
line2Open: state.line2Open || autofillCommit || (action.field === 'line2' && action.value.trim().length > 0),
|
|
165
|
+
// ANY value activity cancels a pending pick — a fill landing on another cell (autofill
|
|
166
|
+
// probe, refill pass) mid-resolve invalidates the resolve at the line1 cell, and a
|
|
167
|
+
// stranded `selecting` would block the suggestions popover until the next line1 edit.
|
|
168
|
+
selecting: false,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
case 'edit-custom':
|
|
172
|
+
return { ...state, custom: { ...state.custom, [action.key]: action.value } };
|
|
173
|
+
case 'set-organization-type':
|
|
174
|
+
return { ...state, organizationType: action.value };
|
|
175
|
+
case 'set-country': {
|
|
176
|
+
const country = normalizeCountry(action.country);
|
|
177
|
+
if (!country || country === state.country) return state;
|
|
178
|
+
// A country switch keeps line1/line2/city but drops the state selection (subdivision
|
|
179
|
+
// keys are country-scoped) — postal stays and revalidates under the new zipRegex. It also
|
|
180
|
+
// cancels an in-flight place selection: the buyer's country choice supersedes the pick
|
|
181
|
+
// (the line1 cell drops the resolve on the distribution clock).
|
|
182
|
+
return { ...state, country, values: { ...state.values, state: undefined }, selecting: false };
|
|
183
|
+
}
|
|
184
|
+
case 'begin-select':
|
|
185
|
+
return { ...state, selecting: true };
|
|
186
|
+
case 'select-failed':
|
|
187
|
+
return { ...state, selecting: false };
|
|
188
|
+
case 'apply-place': {
|
|
189
|
+
const { parsed, allowed } = action;
|
|
190
|
+
if (!placeAllowed(allowed, parsed)) return { ...state, selecting: false };
|
|
191
|
+
// Locality grid = WHOLE-REPLACE on purpose: a chosen place is one address, and mixing a
|
|
192
|
+
// prior postal/city with the new street is a franken-address AVS hazard (a place-omitted
|
|
193
|
+
// cell stays empty and focus-first-empty lands there). line1 keeps the typed entry only
|
|
194
|
+
// when the parse has no street line; line2 (subpremise) is place-data almost never, so
|
|
195
|
+
// the user's unit number survives the pick.
|
|
196
|
+
const values: AddressValues = {
|
|
197
|
+
...state.values,
|
|
198
|
+
line1: parsed.line1 ?? state.values.line1,
|
|
199
|
+
line2: parsed.line2 ?? state.values.line2,
|
|
200
|
+
city: parsed.city ?? '',
|
|
201
|
+
state: parsed.state ?? '',
|
|
202
|
+
postal_code: parsed.postal_code ?? '',
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
...state,
|
|
206
|
+
country: parsed.country ?? state.country,
|
|
207
|
+
values,
|
|
208
|
+
revealed: true,
|
|
209
|
+
line2Open: state.line2Open || Boolean(parsed.line2),
|
|
210
|
+
selecting: false,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
case 'reveal': {
|
|
214
|
+
const line2Open = state.line2Open || Boolean(action.autofill);
|
|
215
|
+
if (state.revealed && line2Open === state.line2Open) return state;
|
|
216
|
+
return { ...state, revealed: true, line2Open };
|
|
217
|
+
}
|
|
218
|
+
case 'toggle-line2':
|
|
219
|
+
return { ...state, line2Open: !state.line2Open };
|
|
220
|
+
case 'blur': {
|
|
221
|
+
const touched = { ...state.touched, [action.field]: true };
|
|
222
|
+
const revealOnLine1 = action.field === 'line1' && Boolean(state.values.line1?.trim());
|
|
223
|
+
return { ...state, touched, revealed: state.revealed || revealOnLine1 };
|
|
224
|
+
}
|
|
225
|
+
case 'show-errors':
|
|
226
|
+
return { ...state, showAllErrors: true };
|
|
227
|
+
default:
|
|
228
|
+
return state;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type AddressErrorCode = 'required' | 'invalid';
|
|
233
|
+
|
|
234
|
+
export interface AddressValidation {
|
|
235
|
+
errors: Partial<Record<string, AddressErrorCode>>;
|
|
236
|
+
complete: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface FieldPlan {
|
|
240
|
+
/** Address-grid fields in the country format's order, filtered to the config scope. */
|
|
241
|
+
gridFields: Array<'line1' | 'city' | 'state' | 'postal_code'>;
|
|
242
|
+
required: ReadonlySet<string>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The commit-point allow gate ([] = unrestricted): a restrictive list declines a foreign OR
|
|
246
|
+
* country-less parse — unknown provenance must not land foreign locality data under the locked
|
|
247
|
+
* ISO2. Shared by the reducer arm and the placeSelected reaction (which must know BEFORE
|
|
248
|
+
* reducing whether to mark the commit `placed` — a declined pick must not bump placeSeq). */
|
|
249
|
+
/** The Places-gate allow list: [] = unrestricted. DISTINCT from allowedCountryList (the
|
|
250
|
+
* country-select options, which falls back to the whole catalog) — feeding that catalog
|
|
251
|
+
* fallback into placeAllowed would wrongly decline catalog-missing territories on
|
|
252
|
+
* unrestricted configs. */
|
|
253
|
+
/**
|
|
254
|
+
* The floor a scope implies, which is what makes `scope="minimal"` mean anything.
|
|
255
|
+
*
|
|
256
|
+
* Minimal renders EXACTLY the floor, so an empty floor renders an empty grid: the element shipped a
|
|
257
|
+
* country select and nothing else, and a buyer could complete that form while the card confirm then
|
|
258
|
+
* refused at the AVS check for a postal code never collected. Web wires the same value at
|
|
259
|
+
* apps/web/src/elements/payments/address.tsx:317.
|
|
260
|
+
*/
|
|
261
|
+
export function requiredFloorFor(scope: AddressFormConfig['scope']): string[] | undefined {
|
|
262
|
+
return scope === 'minimal' ? ['postal_code'] : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function placeAllowList(config: AddressFormConfig, catalogCountries: string[]): string[] {
|
|
266
|
+
return config.allowedCountries?.length ? allowedCountryList(config, catalogCountries) : [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function placeAllowed(allowed: string[], parsed: ParsedPlaceAddress): boolean {
|
|
270
|
+
if (allowed.length === 0) return true;
|
|
271
|
+
return Boolean(parsed.country && allowed.includes(parsed.country));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const CONTRACT_GRID_KEYS = new Set(['line1', 'city', 'state', 'postal_code']);
|
|
275
|
+
|
|
276
|
+
/** The schema for a (country, config) pair: which grid fields exist and which fields are
|
|
277
|
+
* required. Full scope = the country format's fields (its `require` flags) ∪ the floor;
|
|
278
|
+
* minimal scope = EXACTLY the floor (plus country, the form's spine). */
|
|
279
|
+
export function planFields(config: AddressFormConfig, format: CountryFormat): FieldPlan {
|
|
280
|
+
const floor = new Set((config.requiredFloor ?? []).filter((key) => key !== 'country'));
|
|
281
|
+
const required = new Set<string>(['country']);
|
|
282
|
+
const gridFields: FieldPlan['gridFields'] = [];
|
|
283
|
+
|
|
284
|
+
const formatHas = (type: 'line1' | 'city' | 'state' | 'postal_code') =>
|
|
285
|
+
format.fields.some((field) => field.type === type);
|
|
286
|
+
const formatRequires = (type: string) => format.fields.some((field) => field.type === type && field.required);
|
|
287
|
+
|
|
288
|
+
// Minimal = EXACTLY the floor (the method-declared payment minimum — no universal
|
|
289
|
+
// postal/AVS baseline); full = the country format, widened by the floor.
|
|
290
|
+
const includeGrid = (type: 'line1' | 'city' | 'state' | 'postal_code') => {
|
|
291
|
+
if (config.scope === 'minimal') return floor.has(type);
|
|
292
|
+
return formatHas(type) || floor.has(type);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const orderedTypes: Array<'line1' | 'city' | 'state' | 'postal_code'> = [];
|
|
296
|
+
for (const field of format.fields) {
|
|
297
|
+
if (field.type === 'line1' || field.type === 'city' || field.type === 'state' || field.type === 'postal_code') {
|
|
298
|
+
orderedTypes.push(field.type);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const key of ['line1', 'city', 'state', 'postal_code'] as const) {
|
|
302
|
+
if (!orderedTypes.includes(key)) orderedTypes.push(key);
|
|
303
|
+
}
|
|
304
|
+
for (const type of orderedTypes) {
|
|
305
|
+
if (includeGrid(type)) gridFields.push(type);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const type of gridFields) {
|
|
309
|
+
if (config.scope === 'minimal') {
|
|
310
|
+
required.add(type);
|
|
311
|
+
} else if (formatRequires(type) || floor.has(type)) {
|
|
312
|
+
required.add(type);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (config.scope === 'full' && gridFields.includes('line1')) required.add('line1');
|
|
316
|
+
|
|
317
|
+
if (config.name === 'combined') required.add('name');
|
|
318
|
+
if (config.name === 'split') {
|
|
319
|
+
required.add('first_name');
|
|
320
|
+
required.add('last_name');
|
|
321
|
+
}
|
|
322
|
+
if (config.name === 'none' && floor.has('name')) required.add('name');
|
|
323
|
+
if (config.organization !== 'none' && floor.has('organization')) required.add('organization');
|
|
324
|
+
for (const field of config.customFields ?? []) {
|
|
325
|
+
if (field.required) required.add(`custom:${field.key}`);
|
|
326
|
+
}
|
|
327
|
+
return { gridFields, required };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Custom-field values are short by nature (tax ids, VAT numbers) — anything past this bound is
|
|
331
|
+
* invalid WITHOUT running the merchant's pattern, which also removes the buyer-controlled half
|
|
332
|
+
* of a catastrophic-backtracking blowup (cost scales with tested input length; the pattern
|
|
333
|
+
* author is the embedding page's owner, so the residual risk is self-scoped jank). */
|
|
334
|
+
const CUSTOM_FORMAT_INPUT_CAP = 320;
|
|
335
|
+
|
|
336
|
+
const customFormatCache = new Map<string, RegExp | null>();
|
|
337
|
+
|
|
338
|
+
function customFieldPattern(format: string): RegExp | null {
|
|
339
|
+
let compiled = customFormatCache.get(format);
|
|
340
|
+
if (compiled === undefined) {
|
|
341
|
+
try {
|
|
342
|
+
compiled = new RegExp(format);
|
|
343
|
+
} catch {
|
|
344
|
+
compiled = null;
|
|
345
|
+
}
|
|
346
|
+
customFormatCache.set(format, compiled);
|
|
347
|
+
}
|
|
348
|
+
return compiled;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function customFieldMatches(spec: CustomFieldSpec, value: string): boolean {
|
|
352
|
+
if (!spec.format || !value) return true;
|
|
353
|
+
if (value.length > CUSTOM_FORMAT_INPUT_CAP) return false;
|
|
354
|
+
return customFieldPattern(spec.format)?.test(value) ?? true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function validateAddressForm(
|
|
358
|
+
config: AddressFormConfig,
|
|
359
|
+
format: CountryFormat,
|
|
360
|
+
state: AddressFormState,
|
|
361
|
+
): AddressValidation {
|
|
362
|
+
const plan = planFields(config, format);
|
|
363
|
+
const errors: Partial<Record<string, AddressErrorCode>> = {};
|
|
364
|
+
const has = (key: AddressValueKey) => Boolean(state.values[key]?.trim());
|
|
365
|
+
|
|
366
|
+
for (const key of plan.required) {
|
|
367
|
+
if (key === 'country') continue;
|
|
368
|
+
if (key.startsWith('custom:')) {
|
|
369
|
+
if (!state.custom[key.slice('custom:'.length)]?.trim()) errors[key] = 'required';
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (CONTRACT_GRID_KEYS.has(key) && !plan.gridFields.includes(key as FieldPlan['gridFields'][number])) continue;
|
|
373
|
+
if (!has(key as AddressValueKey)) errors[key] = 'required';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const postal = state.values.postal_code?.trim() ?? '';
|
|
377
|
+
if (plan.gridFields.includes('postal_code') && postal && !postalCodeMatches(format.zipRegex, postal)) {
|
|
378
|
+
errors.postal_code = 'invalid';
|
|
379
|
+
}
|
|
380
|
+
for (const spec of config.customFields ?? []) {
|
|
381
|
+
const key = `custom:${spec.key}`;
|
|
382
|
+
if (errors[key]) continue;
|
|
383
|
+
const value = state.custom[spec.key]?.trim() ?? '';
|
|
384
|
+
if (value && !customFieldMatches(spec, value)) errors[key] = 'invalid';
|
|
385
|
+
}
|
|
386
|
+
return { errors, complete: Object.keys(errors).length === 0 };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Compact = city and postal share ONE row (format order within it), state below. */
|
|
390
|
+
export function pairedGridRows(gridFields: FieldPlan['gridFields']): Array<Array<'city' | 'state' | 'postal_code'>> {
|
|
391
|
+
const grid = gridFields.filter((key): key is 'city' | 'state' | 'postal_code' => key !== 'line1');
|
|
392
|
+
const pair = grid.filter((key) => key !== 'state');
|
|
393
|
+
const rows: Array<Array<'city' | 'state' | 'postal_code'>> = [];
|
|
394
|
+
if (pair.length > 0) rows.push(pair);
|
|
395
|
+
if (grid.includes('state')) rows.push(['state']);
|
|
396
|
+
return rows;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const TOP_CORNERS = { solo: 'rounded-t-[7px]', first: 'rounded-tl-[7px]', last: 'rounded-tr-[7px]' };
|
|
400
|
+
const BOTTOM_CORNERS = { solo: 'rounded-b-[7px]', first: 'rounded-bl-[7px]', last: 'rounded-br-[7px]' };
|
|
401
|
+
|
|
402
|
+
/** Box-shadows follow the element's OWN border-radius, so the compact group's rounded
|
|
403
|
+
* container cannot round the focus/invalid rings its cells paint — first- and last-row cells
|
|
404
|
+
* carry their corner radii themselves (7px = the container's 8px radius minus its 1px
|
|
405
|
+
* border), each cell of a two-column row rounding only its outer corner. Derived from the
|
|
406
|
+
* same config + field plan that orders the rows: country formats drop and reorder fields,
|
|
407
|
+
* and until the grid reveals the visible group ends at line1. */
|
|
408
|
+
export function compactCellCorners(
|
|
409
|
+
config: AddressFormConfig,
|
|
410
|
+
gridFields: FieldPlan['gridFields'],
|
|
411
|
+
visible: { line2: boolean; revealed: boolean },
|
|
412
|
+
): Record<string, string> {
|
|
413
|
+
const customRows = (position: CustomFieldSpec['position']) =>
|
|
414
|
+
(config.customFields ?? []).filter((field) => field.position === position).map((field) => [`custom:${field.key}`]);
|
|
415
|
+
const rows: string[][] = [];
|
|
416
|
+
if (config.name === 'split') rows.push(['first_name', 'last_name']);
|
|
417
|
+
else if (config.name === 'combined') rows.push(['name']);
|
|
418
|
+
rows.push(...customRows('after_name'));
|
|
419
|
+
if (config.organization === 'name_with_type') rows.push(['organization', 'organization_type']);
|
|
420
|
+
else if (config.organization === 'name') rows.push(['organization']);
|
|
421
|
+
rows.push(...customRows('after_organization'), ...customRows('before_country'));
|
|
422
|
+
rows.push(['country']);
|
|
423
|
+
if (gridFields.includes('line1')) rows.push(['line1']);
|
|
424
|
+
if (visible.revealed) {
|
|
425
|
+
if (visible.line2) rows.push(['line2']);
|
|
426
|
+
rows.push(...pairedGridRows(gridFields), ...customRows('after_address'));
|
|
427
|
+
}
|
|
428
|
+
const classes: Record<string, string> = {};
|
|
429
|
+
const mark = (row: string[] | undefined, corner: typeof TOP_CORNERS) => {
|
|
430
|
+
row?.forEach((key, index) => {
|
|
431
|
+
const cls = row.length === 1 ? corner.solo : index === 0 ? corner.first : corner.last;
|
|
432
|
+
classes[key] = classes[key] ? `${classes[key]} ${cls}` : cls;
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
mark(rows[0], TOP_CORNERS);
|
|
436
|
+
mark(rows[rows.length - 1], BOTTOM_CORNERS);
|
|
437
|
+
return classes;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export interface AddressSnapshot {
|
|
441
|
+
complete: boolean;
|
|
442
|
+
address: EmittedAddress;
|
|
443
|
+
custom: Record<string, string>;
|
|
444
|
+
errors: Partial<Record<string, AddressErrorCode>>;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function snapshotAddressForm(
|
|
448
|
+
config: AddressFormConfig,
|
|
449
|
+
format: CountryFormat,
|
|
450
|
+
state: AddressFormState,
|
|
451
|
+
): AddressSnapshot {
|
|
452
|
+
const validation = validateAddressForm(config, format, state);
|
|
453
|
+
const custom: Record<string, string> = {};
|
|
454
|
+
for (const spec of config.customFields ?? []) {
|
|
455
|
+
const value = state.custom[spec.key]?.trim();
|
|
456
|
+
if (value) custom[spec.key] = value;
|
|
457
|
+
}
|
|
458
|
+
// The payload carries only what the CURRENT plan collects — a scope narrowing must not keep
|
|
459
|
+
// emitting fields the form no longer shows. State retains the values, so widening back
|
|
460
|
+
// restores them.
|
|
461
|
+
const planned = new Set<string>(planFields(config, format).gridFields);
|
|
462
|
+
return {
|
|
463
|
+
complete: validation.complete,
|
|
464
|
+
address: emitAddress(format, {
|
|
465
|
+
name: config.name === 'combined' ? state.values.name : undefined,
|
|
466
|
+
first_name: config.name === 'split' ? state.values.first_name : undefined,
|
|
467
|
+
last_name: config.name === 'split' ? state.values.last_name : undefined,
|
|
468
|
+
organization: config.organization === 'none' ? undefined : state.values.organization,
|
|
469
|
+
organization_type: config.organization === 'name_with_type' ? state.organizationType : '',
|
|
470
|
+
line1: planned.has('line1') ? state.values.line1 : undefined,
|
|
471
|
+
line2: planned.has('line1') && config.line2 !== 'never' ? state.values.line2 : undefined,
|
|
472
|
+
city: planned.has('city') ? state.values.city : undefined,
|
|
473
|
+
state: planned.has('state') ? state.values.state : undefined,
|
|
474
|
+
postal_code: planned.has('postal_code') ? state.values.postal_code : undefined,
|
|
475
|
+
}),
|
|
476
|
+
custom,
|
|
477
|
+
errors: validation.errors,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// the shape ported from frontend/apps/web/src/elements/address-form/places.ts; the Google Places
|
|
2
|
+
// loader around it is browser-only and has no counterpart here
|
|
3
|
+
|
|
4
|
+
/** A resolved suggestion, in the same wire keys the address form emits. */
|
|
5
|
+
export interface ParsedPlaceAddress {
|
|
6
|
+
line1?: string;
|
|
7
|
+
line2?: string;
|
|
8
|
+
city?: string;
|
|
9
|
+
state?: string;
|
|
10
|
+
postal_code?: string;
|
|
11
|
+
country?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The country the buyer is dialling from, used as the address form's default before the buyer picks
|
|
16
|
+
* one. A miss falls through to `countryHint` and then US.
|
|
17
|
+
*
|
|
18
|
+
* Cloudflare's trace endpoint, which is what the web element reads — it uses the same-origin
|
|
19
|
+
* `/cdn-cgi/trace` because every rail it ships on sits behind Cloudflare. A native app has no
|
|
20
|
+
* origin, so this asks whop.com for the same thing.
|
|
21
|
+
*
|
|
22
|
+
* The previous port invented `https://whop.com/api/geo/`, which has never existed: it 404s (and
|
|
23
|
+
* `/api/geo` merely redirects to the same 404), so detection silently failed for every buyer and
|
|
24
|
+
* the form always opened on US.
|
|
25
|
+
*/
|
|
26
|
+
export async function detectIpCountry(timeoutMs = 1500): Promise<string | undefined> {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch('https://whop.com/cdn-cgi/trace', { signal: controller.signal });
|
|
31
|
+
if (!response.ok) return undefined;
|
|
32
|
+
const country = /(?:^|\n)loc=([A-Za-z]{2})/.exec(await response.text())?.[1];
|
|
33
|
+
return country ? country.toUpperCase() : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
} finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ParsedPlaceAddress } from './places';
|
|
2
|
+
|
|
3
|
+
/** One row in the suggestion list. Display only — components arrive from `resolve`. */
|
|
4
|
+
export interface AddressSuggestion {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The raw components a resolved pick carries, before per-country composition. */
|
|
10
|
+
export interface RawComponents {
|
|
11
|
+
streetNumber?: string;
|
|
12
|
+
street?: string;
|
|
13
|
+
/** Set only when the platform had no street components but did name the place. */
|
|
14
|
+
line1?: string;
|
|
15
|
+
city?: string;
|
|
16
|
+
state?: string;
|
|
17
|
+
postalCode?: string;
|
|
18
|
+
country?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the house number leads the street name. Everywhere else it trails, and emitting
|
|
23
|
+
* "77 Unter den Linden" for a Berlin address is both wrong and an AVS mismatch.
|
|
24
|
+
*
|
|
25
|
+
* Both platform geocoders hand back the number and the street as separate components without an
|
|
26
|
+
* opinion on order, so this table is the only thing that decides it — which is why it lives here
|
|
27
|
+
* once rather than in Swift and Kotlin twice.
|
|
28
|
+
*/
|
|
29
|
+
const NUMBER_FIRST = new Set([
|
|
30
|
+
'US',
|
|
31
|
+
'CA',
|
|
32
|
+
'GB',
|
|
33
|
+
'IE',
|
|
34
|
+
'AU',
|
|
35
|
+
'NZ',
|
|
36
|
+
'IN',
|
|
37
|
+
'PH',
|
|
38
|
+
'MY',
|
|
39
|
+
'SG',
|
|
40
|
+
'ZA',
|
|
41
|
+
'JP',
|
|
42
|
+
'CN',
|
|
43
|
+
'KR',
|
|
44
|
+
'TW',
|
|
45
|
+
'HK',
|
|
46
|
+
'TH',
|
|
47
|
+
'VN',
|
|
48
|
+
'ID',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Empty string is NOT absence, and the reducer cannot tell the difference: `apply-place` does
|
|
53
|
+
* `line1: parsed.line1 ?? state.values.line1`, so an empty line1 would REPLACE the street the
|
|
54
|
+
* buyer already typed, and `country: parsed.country ?? state.country` would blank the country.
|
|
55
|
+
* Both geocoders return "" for missing components, so this normalization is load-bearing.
|
|
56
|
+
*/
|
|
57
|
+
function present(value: string | undefined): string | undefined {
|
|
58
|
+
const trimmed = value?.trim();
|
|
59
|
+
return trimmed ? trimmed : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function composeLine1(
|
|
63
|
+
streetNumber: string | undefined,
|
|
64
|
+
street: string | undefined,
|
|
65
|
+
country: string | undefined,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const number = present(streetNumber);
|
|
68
|
+
const name = present(street);
|
|
69
|
+
if (!name) return number;
|
|
70
|
+
if (!number) return name;
|
|
71
|
+
return NUMBER_FIRST.has((country ?? '').toUpperCase()) ? `${number} ${name}` : `${name} ${number}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The suggestion rows a `suggest` call returned. */
|
|
75
|
+
export function parseSuggestions(json: string): AddressSuggestion[] {
|
|
76
|
+
let rows: unknown;
|
|
77
|
+
try {
|
|
78
|
+
rows = JSON.parse(json);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(rows)) return [];
|
|
83
|
+
return rows
|
|
84
|
+
.filter(
|
|
85
|
+
(row): row is AddressSuggestion =>
|
|
86
|
+
typeof row === 'object' && row !== null && typeof row.id === 'string' && typeof row.label === 'string',
|
|
87
|
+
)
|
|
88
|
+
.filter((row) => row.id.length > 0 && row.label.length > 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A resolved pick, in the wire keys the address form emits.
|
|
93
|
+
*
|
|
94
|
+
* `formCountry` backfills a resolve that names no country, which both geocoders do occasionally.
|
|
95
|
+
* Without it `composeLine1` orders the street as if it were number-last, and `placeAllowed` rejects
|
|
96
|
+
* a country-less parse whenever an allow list is active — so the pick silently no-ops.
|
|
97
|
+
*/
|
|
98
|
+
export function parseComponents(json: string, formCountry?: string): ParsedPlaceAddress | null {
|
|
99
|
+
let raw: unknown;
|
|
100
|
+
try {
|
|
101
|
+
raw = JSON.parse(json);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (typeof raw !== 'object' || raw === null) return null;
|
|
106
|
+
const row = raw as RawComponents;
|
|
107
|
+
|
|
108
|
+
const country = present(row.country)?.toUpperCase() ?? present(formCountry)?.toUpperCase();
|
|
109
|
+
const line1 = composeLine1(row.streetNumber, row.street, country) ?? present(row.line1);
|
|
110
|
+
// Without a street the whole-replace in `apply-place` would clear the grid in exchange for
|
|
111
|
+
// nothing, so a componentless resolve declines instead.
|
|
112
|
+
if (!line1) return null;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
line1,
|
|
116
|
+
city: present(row.city),
|
|
117
|
+
state: present(row.state),
|
|
118
|
+
postal_code: present(row.postalCode),
|
|
119
|
+
country,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A geocoder's subdivision, as the catalog key the address engine validates against.
|
|
125
|
+
*
|
|
126
|
+
* The engine keys subdivisions by `sub_key` and builds each option's display name by joining the
|
|
127
|
+
* native and latin names — `[names[i] || key, latinNames[i]].join(' — ')` at
|
|
128
|
+
* core/address/engine.ts:141, so Tokyo's option is key `東京都` and name `東京都 — Tokyo`. A geocoder
|
|
129
|
+
* answers with ONE of those halves and never the joined form, so matching the whole `name` string
|
|
130
|
+
* misses every country whose catalog carries both scripts.
|
|
131
|
+
*
|
|
132
|
+
* An unmatched value resolves to undefined rather than to itself: it would render the state Select
|
|
133
|
+
* on its placeholder while still being emitted, so `apply-place` writing '' and surfacing the
|
|
134
|
+
* required-state error is the honest outcome.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveSubdivision(
|
|
137
|
+
value: string | undefined,
|
|
138
|
+
options: { key: string; name: string; iso?: string }[],
|
|
139
|
+
): string | undefined {
|
|
140
|
+
const wanted = present(value)?.toLowerCase();
|
|
141
|
+
if (!wanted) return undefined;
|
|
142
|
+
if (options.length === 0) return present(value);
|
|
143
|
+
return options.find((option) => subdivisionAliases(option).includes(wanted))?.key;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Kept identical to the separator engine.ts:141 joins with. */
|
|
147
|
+
const NAME_SEPARATOR = ' — ';
|
|
148
|
+
|
|
149
|
+
function subdivisionAliases(option: { key: string; name: string; iso?: string }): string[] {
|
|
150
|
+
return [option.key, option.iso, option.name, ...option.name.split(NAME_SEPARATOR)]
|
|
151
|
+
.map((part) => present(part)?.toLowerCase())
|
|
152
|
+
.filter((part): part is string => part !== undefined);
|
|
153
|
+
}
|