@heyama/pay-react-native 1.0.2 → 1.0.4

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.
@@ -6,7 +6,7 @@ import { CountryEnum } from "../models/domain.enum.js";
6
6
  import { cardLabel } from "../models/checkout.card.utils.js";
7
7
  import { formatMoney } from "../models/checkout.money.utils.js";
8
8
  import { maskE164 } from "../models/checkout.phone.utils.js";
9
- import { countriesOf, deriveCheckout, initialUi, logoOf, screenOf, waitLeftOf, } from "../models/checkout.view.utils.js";
9
+ import { countriesOf, deriveCheckout, initialUi, logoOf, pickedPatch, screenOf, waitLeftOf, } from "../models/checkout.view.utils.js";
10
10
  import { useNow, useReturnCountdown, useToast } from "./use-checkout-timers.js";
11
11
  import { usePhoneNumber } from "./use-phone-number.js";
12
12
  /** Before the payment has loaded, or for a link that matches none. */
@@ -22,6 +22,7 @@ const NO_PAYMENT_UI = {
22
22
  submitting: false,
23
23
  dismissed: null,
24
24
  redirecting: false,
25
+ methodMissing: 0,
25
26
  };
26
27
  /**
27
28
  * The whole checkout for one payment, on any platform: the gateway's live
@@ -62,6 +63,7 @@ export function useCheckoutController(view, { clues, mutations, platform }) {
62
63
  cards: [],
63
64
  selected: null,
64
65
  canPay: false,
66
+ detected: null,
65
67
  mismatch: null,
66
68
  suggestion: null,
67
69
  };
@@ -86,6 +88,39 @@ export function useCheckoutController(view, { clues, mutations, platform }) {
86
88
  update({ dismissed: attemptReference });
87
89
  showToast(noAnswer, CheckoutToastToneEnum.WARNING);
88
90
  }, [waiting, waitLeft, attemptReference, noAnswer, showToast, update]);
91
+ // A number that tells its method picks it when none is picked: once per
92
+ // number, so Change keeps the list open until the number changes. The app's
93
+ // own number picks without a word. Off the form, or on the Card tab, the
94
+ // number is not looked at: it counts once the payer is back on Mobile Money.
95
+ const detectedMethod = derived.detected;
96
+ const numberE164 = phone.e164;
97
+ const onMobileForm = screen === CheckoutScreenEnum.FORM &&
98
+ ui.tab === CheckoutTabEnum.MOBILE_MONEY;
99
+ const nonePicked = !derived.selected;
100
+ const lastKey = useRef('');
101
+ useEffect(() => {
102
+ if (!onMobileForm)
103
+ return;
104
+ const key = detectedMethod ? `${detectedMethod.id}|${numberE164}` : '';
105
+ if (key === lastKey.current)
106
+ return;
107
+ lastKey.current = key;
108
+ if (!detectedMethod || !nonePicked)
109
+ return;
110
+ update(pickedPatch(detectedMethod.id));
111
+ if (numberE164 !== prefill) {
112
+ showToast(t('toast.switched', { method: detectedMethod.name }), CheckoutToastToneEnum.SUCCESS, logoOf(detectedMethod));
113
+ }
114
+ }, [
115
+ onMobileForm,
116
+ detectedMethod,
117
+ numberE164,
118
+ nonePicked,
119
+ prefill,
120
+ t,
121
+ showToast,
122
+ update,
123
+ ]);
89
124
  const paymentId = view?.id;
90
125
  // A payer opened the link, said once per page (the dashboard's preview has no mutations).
91
126
  const opened = useRef(null);
@@ -191,12 +226,7 @@ export function useCheckoutController(view, { clues, mutations, platform }) {
191
226
  toast.show(method.note ?? t('toast.unavailable', { method: method.name }), CheckoutToastToneEnum.WARNING, logoOf(method));
192
227
  }
193
228
  else {
194
- update({
195
- method: method.id,
196
- pickerOpen: false,
197
- code: '',
198
- cardComplete: false,
199
- });
229
+ update(pickedPatch(method.id));
200
230
  }
201
231
  },
202
232
  comingSoon,
@@ -209,8 +239,10 @@ export function useCheckoutController(view, { clues, mutations, platform }) {
209
239
  },
210
240
  pay: () => {
211
241
  const { selected } = derived;
212
- if (!selected)
242
+ if (!selected) {
243
+ update({ methodMissing: ui.methodMissing + 1 });
213
244
  return;
245
+ }
214
246
  // The preview offers no card: it shows Mobile Money only.
215
247
  if (selected.flow === MethodFlowEnum.CARD_FIELDS) {
216
248
  // The fields page pays with its provider, then says how it went.
@@ -63,6 +63,7 @@ declare const _default: {
63
63
  };
64
64
  country: string;
65
65
  change: string;
66
+ method_missing: string;
66
67
  secured_by: string;
67
68
  help: string;
68
69
  terms: string;
@@ -54,6 +54,7 @@ export default {
54
54
  },
55
55
  country: 'Country',
56
56
  change: 'Change',
57
+ method_missing: 'Choose a payment method',
57
58
  secured_by: 'Payment secured by',
58
59
  help: 'Need help?',
59
60
  terms: 'Terms',
@@ -55,6 +55,7 @@ const messages = {
55
55
  },
56
56
  country: 'Pays',
57
57
  change: 'Modifier',
58
+ method_missing: 'Choisissez un moyen de paiement',
58
59
  secured_by: 'Paiement sécurisé par',
59
60
  help: "Besoin d'aide ?",
60
61
  terms: 'Conditions',
@@ -123,6 +123,8 @@ export declare enum TerminalKindEnum {
123
123
  export declare enum CheckoutButtonVariantEnum {
124
124
  PRIMARY = "primary",
125
125
  SECONDARY = "secondary",
126
+ /** The second choice under Pay or Try again: the secondary's look, no border. */
127
+ PLAIN = "plain",
126
128
  /** Gives something up: cancelling the payment. */
127
129
  DANGER = "danger"
128
130
  }
@@ -139,6 +139,8 @@ export var CheckoutButtonVariantEnum;
139
139
  (function (CheckoutButtonVariantEnum) {
140
140
  CheckoutButtonVariantEnum["PRIMARY"] = "primary";
141
141
  CheckoutButtonVariantEnum["SECONDARY"] = "secondary";
142
+ /** The second choice under Pay or Try again: the secondary's look, no border. */
143
+ CheckoutButtonVariantEnum["PLAIN"] = "plain";
142
144
  /** Gives something up: cancelling the payment. */
143
145
  CheckoutButtonVariantEnum["DANGER"] = "danger";
144
146
  })(CheckoutButtonVariantEnum || (CheckoutButtonVariantEnum = {}));
@@ -186,6 +186,11 @@ export interface CheckoutUi {
186
186
  /** An attempt the payer walked away from: the form shows again. */
187
187
  dismissed: string | null;
188
188
  redirecting: boolean;
189
+ /**
190
+ * Pay pressed with no method picked, counted: the list turns red and
191
+ * shakes once per press. Back to 0 once a method is picked.
192
+ */
193
+ methodMissing: number;
189
194
  }
190
195
  /** Everything the screens show that follows from the payment and the page, worked out once. */
191
196
  export interface CheckoutDerived {
@@ -197,6 +202,8 @@ export interface CheckoutDerived {
197
202
  /** The picked method, while it can still be used. */
198
203
  selected: CheckoutMethod | null;
199
204
  canPay: boolean;
205
+ /** The available method of the country the typed number belongs to. */
206
+ detected: CheckoutMethod | null;
200
207
  /** The number belongs to another method of the country than the one picked. */
201
208
  mismatch: CheckoutMethod | null;
202
209
  /** After a failure: another method of the same country the payer can use now. */
@@ -22,6 +22,8 @@ export declare function initialUi(view: CheckoutView, clues: CountryClues): Chec
22
22
  export declare function screenOf(view: CheckoutView | null | undefined, ui: CheckoutUi): CheckoutScreenEnum;
23
23
  /** The brand of a Mobile Money method, which a number's first digits can reveal. */
24
24
  export declare function brandOf(method: CheckoutMethod): string;
25
+ /** What the page keeps when a method is picked: the list closes, the rest starts over. */
26
+ export declare function pickedPatch(id: string): Partial<CheckoutUi>;
25
27
  /** The tile of a method or an attempt: the generic card for cards, whatever networks they take. */
26
28
  export declare function logoOf(of: Pick<CheckoutMethod, 'flow' | 'logos'>): ProviderLogoEnum;
27
29
  /** Works out what the screens show from the payment, the page and the number typed. */
@@ -52,6 +52,7 @@ export function initialUi(view, clues) {
52
52
  submitting: false,
53
53
  dismissed: null,
54
54
  redirecting: false,
55
+ methodMissing: 0,
55
56
  };
56
57
  if (preferred?.kind !== MethodKindEnum.MOBILE_MONEY)
57
58
  return base;
@@ -98,6 +99,16 @@ export function screenOf(view, ui) {
98
99
  export function brandOf(method) {
99
100
  return method.logos[0];
100
101
  }
102
+ /** What the page keeps when a method is picked: the list closes, the rest starts over. */
103
+ export function pickedPatch(id) {
104
+ return {
105
+ method: id,
106
+ pickerOpen: false,
107
+ code: '',
108
+ cardComplete: false,
109
+ methodMissing: 0,
110
+ };
111
+ }
101
112
  /** Everything the picked method needs is in: the card, or the number and its payment code. */
102
113
  function readyToPay(method, ui, phone) {
103
114
  if (method.flow === MethodFlowEnum.CARD_FIELDS)
@@ -138,6 +149,7 @@ export function deriveCheckout(view, ui, phone) {
138
149
  readyToPay(selected, ui, phone) &&
139
150
  !ui.submitting &&
140
151
  !ui.cardPaying,
152
+ detected,
141
153
  // Only a Mobile Money method has a number to belong to another one.
142
154
  mismatch: selected && !onCard && detected && detected.id !== selected.id
143
155
  ? detected
@@ -1,3 +1,3 @@
1
1
  import type { SheetButtonProps } from '../models/sheet.interface.js';
2
- /** The sheet's buttons: filled in the app colour for the main action, outlined for the rest. */
2
+ /** The sheet's buttons: filled in the app colour for the main action, outlined for the rest, plain (no border) for a quiet second choice. */
3
3
  export declare function SheetButton({ label, onPress, variant, busy, disabled, inactive, }: SheetButtonProps): import("react").JSX.Element;
@@ -4,7 +4,8 @@ import { CheckoutButtonVariantEnum } from "../checkout-core/models/checkout.enum
4
4
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
5
5
  import { FigtreeEnum } from "../models/sheet.enum.js";
6
6
  import { SheetText } from "./SheetText.js";
7
- /** The sheet's buttons: filled in the app colour for the main action, outlined for the rest. */
7
+ import { FIELD_HEIGHT, FIELD_RADIUS } from "../models/sheet.constants.js";
8
+ /** The sheet's buttons: filled in the app colour for the main action, outlined for the rest, plain (no border) for a quiet second choice. */
8
9
  export function SheetButton({ label, onPress, variant = CheckoutButtonVariantEnum.PRIMARY, busy = false, disabled = false, inactive = false, }) {
9
10
  const { colors, brand } = useSheetTheme();
10
11
  const primary = variant === CheckoutButtonVariantEnum.PRIMARY;
@@ -14,14 +15,17 @@ export function SheetButton({ label, onPress, variant = CheckoutButtonVariantEnu
14
15
  styles.button,
15
16
  primary
16
17
  ? { backgroundColor: brand }
17
- : { borderWidth: 1, borderColor: colors.line3 },
18
+ : variant !== CheckoutButtonVariantEnum.PLAIN && {
19
+ borderWidth: 1,
20
+ borderColor: colors.line3,
21
+ },
18
22
  (disabled || inactive) && styles.disabled,
19
23
  ], children: [busy && _jsx(ActivityIndicator, { color: ink }), _jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 16, color: ink, style: styles.label, children: label })] }));
20
24
  }
21
25
  const styles = StyleSheet.create({
22
26
  button: {
23
- minHeight: 50,
24
- borderRadius: 14,
27
+ minHeight: FIELD_HEIGHT,
28
+ borderRadius: FIELD_RADIUS,
25
29
  flexDirection: 'row',
26
30
  alignItems: 'center',
27
31
  justifyContent: 'center',
@@ -6,6 +6,7 @@ import { useSheetTheme } from "../hooks/use-sheet-theme.js";
6
6
  import { FigtreeEnum } from "../models/sheet.enum.js";
7
7
  import { SheetLogo } from "./SheetLogo.js";
8
8
  import { SheetText } from "./SheetText.js";
9
+ import { FIELD_HEIGHT, FIELD_RADIUS } from "../models/sheet.constants.js";
9
10
  /** The method picked, on one row, with Change back to the list: as on the web checkout. */
10
11
  export function SheetChosenMethod({ logo, name, sub, onChange, }) {
11
12
  const t = useTranslations('checkout');
@@ -23,10 +24,10 @@ const styles = StyleSheet.create({
23
24
  flexDirection: 'row',
24
25
  alignItems: 'center',
25
26
  gap: 12,
26
- height: 48,
27
+ height: FIELD_HEIGHT,
27
28
  marginTop: 12,
28
29
  borderWidth: 1,
29
- borderRadius: 12,
30
+ borderRadius: FIELD_RADIUS,
30
31
  paddingLeft: 8,
31
32
  paddingRight: 6,
32
33
  },
@@ -7,6 +7,7 @@ import { telHref } from "../checkout-core/models/checkout.phone.utils.js";
7
7
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
8
8
  import { FigtreeEnum } from "../models/sheet.enum.js";
9
9
  import { SheetInput, SheetText } from "./SheetText.js";
10
+ import { FIELD_HEIGHT, FIELD_RADIUS } from "../models/sheet.constants.js";
10
11
  /**
11
12
  * OTP methods: Dial opens the network's code in the phone dialler for a
12
13
  * one-time payment code (on Android; on iOS it says to dial it by hand);
@@ -58,9 +59,9 @@ const styles = StyleSheet.create({
58
59
  section: { marginTop: 10 },
59
60
  label: { marginBottom: 8 },
60
61
  input: {
61
- height: 50,
62
+ height: FIELD_HEIGHT,
62
63
  borderWidth: 1,
63
- borderRadius: 13,
64
+ borderRadius: FIELD_RADIUS,
64
65
  paddingHorizontal: 14,
65
66
  fontSize: 18,
66
67
  letterSpacing: 5,
@@ -27,7 +27,7 @@ export function SheetFailed({ core }) {
27
27
  ? t('sending')
28
28
  : attempt.flow === MethodFlowEnum.OTP
29
29
  ? t('retry_code')
30
- : t('retry'), busy: ui.submitting, onPress: actions.retry }), _jsx(SheetButton, { variant: CheckoutButtonVariantEnum.SECONDARY, label: suggestion
30
+ : t('retry'), busy: ui.submitting, onPress: actions.retry }), _jsx(SheetButton, { variant: CheckoutButtonVariantEnum.PLAIN, label: suggestion
31
31
  ? t('try_with', { method: suggestion.name })
32
32
  : t('use_another'), onPress: actions.useAnother })] }), children: _jsxs(View, { style: styles.failed, children: [_jsx(SheetIcon, { kind: SheetIconEnum.CROSS, color: colors.danger, background: colors.dangerSoft }), _jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 22, accessibilityRole: "header", style: styles.title, children: t(`failure.${reason}.title`, values) }), _jsx(SheetText, { paragraph: true, size: 15, color: colors.inkStrong, children: t(`failure.${reason}.body`, values) }), _jsx(SheetText, { paragraph: true, color: colors.ink2, style: styles.next, children: t(`failure.${reason}.next`, values) }), _jsxs(View, { style: [styles.rows, { borderColor: colors.line }], children: [_jsx(SheetRow, { label: t('amount'), children: formatMoney(view.amount, view.currency) }), _jsxs(SheetRow, { label: t('method'), children: [_jsx(SheetLogo, { logo: logoOf(attempt) }), _jsx(SheetText, { weight: FigtreeEnum.SEMI_BOLD, children: attempt.methodName })] }), _jsx(SheetRow, { label: t('tries_left'), children: t('tries_value', {
33
33
  left: view.attemptsLeft,
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Pressable, StyleSheet, View } from 'react-native';
2
+ import { Animated, Pressable, StyleSheet, View } from 'react-native';
3
3
  import { useTranslations } from 'use-intl';
4
4
  import { CheckoutTabEnum, MethodFlowEnum, MethodStateEnum, } from "../checkout-core/models/checkout.enum.js";
5
5
  import { logoOf } from "../checkout-core/models/checkout.view.utils.js";
6
+ import { useShake } from "../hooks/use-shake.js";
6
7
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
7
8
  import { FigtreeEnum } from "../models/sheet.enum.js";
8
9
  import { flagOf } from "../models/sheet.utils.js";
@@ -12,13 +13,17 @@ import { SheetCodeField } from "./SheetCodeField.js";
12
13
  import { SheetMethodCard } from "./SheetMethodCard.js";
13
14
  import { SheetPhoneField } from "./SheetPhoneField.js";
14
15
  import { SheetText } from "./SheetText.js";
15
- /** Two cards a row, the last one alone when they are odd. */
16
- function MethodGrid({ cards }) {
16
+ import { FIELD_HEIGHT, FIELD_RADIUS, TABS_INSET, } from "../models/sheet.constants.js";
17
+ /** Two cards a row, the last one alone when they are odd; red with a hint, and shaken, after Pay with none picked. */
18
+ function MethodGrid({ cards, missing }) {
19
+ const t = useTranslations('checkout');
20
+ const { colors } = useSheetTheme();
21
+ const shake = useShake(missing);
17
22
  const rows = [];
18
23
  for (let index = 0; index < cards.length; index += 2) {
19
24
  rows.push(cards.slice(index, index + 2));
20
25
  }
21
- return (_jsx(View, { accessibilityRole: "radiogroup", style: styles.grid, children: rows.map((row) => (_jsxs(View, { style: styles.gridRow, children: [row.map(({ id, ...card }) => (_jsx(SheetMethodCard, { ...card }, id))), row.length === 1 && _jsx(View, { style: styles.gridSpacer })] }, row[0].id))) }));
26
+ return (_jsxs(Animated.View, { testID: "method-grid", accessibilityRole: "radiogroup", style: [styles.grid, { transform: [{ translateX: shake }] }], children: [rows.map((row) => (_jsxs(View, { style: styles.gridRow, children: [row.map(({ id, ...card }) => (_jsx(SheetMethodCard, { ...card, missing: missing > 0, alone: cards.length === 1 }, id))), row.length === 1 && cards.length > 1 && (_jsx(View, { style: styles.gridSpacer }))] }, row[0].id))), missing > 0 && (_jsx(SheetText, { accessibilityRole: "alert", weight: FigtreeEnum.MEDIUM, size: 13, color: colors.danger, children: t('method_missing') }))] }));
22
27
  }
23
28
  /**
24
29
  * The form of design A: the country when there are several, the Mobile
@@ -61,7 +66,7 @@ export function SheetForm({ core, phoneRef, cardFields }) {
61
66
  ? { backgroundColor: colors.ink, borderColor: colors.ink }
62
67
  : { borderColor: colors.line3 },
63
68
  ], children: [_jsx(SheetText, { size: 14, children: flagOf(country) }), _jsx(SheetText, { weight: FigtreeEnum.SEMI_BOLD, size: 13, color: on ? colors.surface : colors.ink, children: t(`countries.${country}`) })] }, country));
64
- }) })), _jsxs(View, { accessibilityRole: "tablist", style: [styles.tabs, { backgroundColor: colors.fill }], children: [tab(CheckoutTabEnum.MOBILE_MONEY, t('tab_wallet')), tab(CheckoutTabEnum.CARD, t('card'))] }), derived.selected ? (_jsx(SheetChosenMethod, { logo: logoOf(derived.selected), name: derived.selected.name, sub: derived.selected.network, onChange: actions.openPicker })) : (_jsx(MethodGrid, { cards: cards })), !onCard && _jsx(SheetPhoneField, { core: core, phoneRef: phoneRef }), !onCard && derived.selected?.flow === MethodFlowEnum.OTP && (_jsx(SheetCodeField, { core: core })), onCard && derived.selected?.flow === MethodFlowEnum.CARD_FIELDS && (_jsx(SheetCardFields, { core: core, bridge: cardFields }))] }));
69
+ }) })), _jsxs(View, { testID: "sheet-tabs", accessibilityRole: "tablist", style: [styles.tabs, { backgroundColor: colors.fill }], children: [tab(CheckoutTabEnum.MOBILE_MONEY, t('tab_wallet')), tab(CheckoutTabEnum.CARD, t('card'))] }), derived.selected ? (_jsx(SheetChosenMethod, { logo: logoOf(derived.selected), name: derived.selected.name, sub: derived.selected.network, onChange: actions.openPicker })) : (_jsx(MethodGrid, { cards: cards, missing: ui.methodMissing })), !onCard && _jsx(SheetPhoneField, { core: core, phoneRef: phoneRef }), !onCard && derived.selected?.flow === MethodFlowEnum.OTP && (_jsx(SheetCodeField, { core: core })), onCard && derived.selected?.flow === MethodFlowEnum.CARD_FIELDS && (_jsx(SheetCardFields, { core: core, bridge: cardFields }))] }));
65
70
  }
66
71
  const styles = StyleSheet.create({
67
72
  form: { paddingHorizontal: 18, paddingTop: 14, paddingBottom: 8 },
@@ -80,13 +85,17 @@ const styles = StyleSheet.create({
80
85
  paddingHorizontal: 12,
81
86
  paddingVertical: 6,
82
87
  },
83
- tabs: { flexDirection: 'row', borderRadius: 12, padding: 3 },
88
+ tabs: {
89
+ flexDirection: 'row',
90
+ height: FIELD_HEIGHT,
91
+ borderRadius: FIELD_RADIUS,
92
+ padding: TABS_INSET,
93
+ },
84
94
  tab: {
85
95
  flex: 1,
86
96
  alignItems: 'center',
87
97
  justifyContent: 'center',
88
- minHeight: 36,
89
- borderRadius: 9,
98
+ borderRadius: FIELD_RADIUS - TABS_INSET,
90
99
  },
91
100
  tabOn: {
92
101
  shadowColor: 'black',
@@ -3,6 +3,7 @@ import { Image, Pressable, StyleSheet, View } from 'react-native';
3
3
  import { useTranslations } from 'use-intl';
4
4
  import { MEDIA_LINE } from "../checkout-core/models/checkout.constants.js";
5
5
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
6
+ import { SHEET_BADGE_RADIUS, SHEET_BADGE_SIZE, } from "../models/sheet.constants.js";
6
7
  import { FigtreeEnum } from "../models/sheet.enum.js";
7
8
  import { SheetBone } from "./SheetBone.js";
8
9
  import { SheetText } from "./SheetText.js";
@@ -14,8 +15,8 @@ export function SheetHeader({ view, loading, onClose, dragHandlers, }) {
14
15
  const t = useTranslations('checkout');
15
16
  const { colors, brand } = useSheetTheme();
16
17
  const app = view?.app;
17
- return (_jsxs(View, { ...dragHandlers, children: [_jsx(View, { style: [styles.grabber, { backgroundColor: colors.line3 }] }), _jsxs(View, { style: styles.row, children: [loading ? (_jsxs(_Fragment, { children: [_jsx(SheetBone, { width: 34, height: 34, radius: 9 }), _jsx(View, { style: styles.name, children: _jsx(SheetBone, { width: 84, height: 14 }) })] })) : (app &&
18
- (app.logoUrl ? (_jsx(Image, { source: { uri: app.logoUrl }, accessibilityIgnoresInvertColors: true, style: styles.logo })) : (_jsx(View, { testID: "app-initial", style: [styles.initial, { backgroundColor: brand }], children: _jsx(SheetText, { weight: FigtreeEnum.EXTRA_BOLD, size: 15, color: "white", style: styles.centred, children: app.name.charAt(0) }) })))), !loading && (_jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 16, numberOfLines: 1, accessibilityRole: "header", style: [styles.name, styles.centred], children: app?.name ?? 'Brissoto Pay' })), !loading && view?.test && (_jsx(View, { style: [styles.badge, { backgroundColor: colors.warnSoft }], children: _jsx(SheetText, { weight: FigtreeEnum.EXTRA_BOLD, size: 10.5, color: colors.warnInk, style: [styles.badgeText, styles.centred], children: t('test_badge') }) })), _jsxs(Pressable, { onPress: onClose, accessibilityRole: "button", accessibilityLabel: t('close'), hitSlop: 8, style: [styles.close, { backgroundColor: colors.fill }], children: [_jsx(View, { style: [styles.bar, styles.barUp, { backgroundColor: colors.ink2 }] }), _jsx(View, { style: [
18
+ return (_jsxs(View, { ...dragHandlers, children: [_jsx(View, { style: [styles.grabber, { backgroundColor: colors.line3 }] }), _jsxs(View, { style: styles.row, children: [loading ? (_jsxs(_Fragment, { children: [_jsx(SheetBone, { width: SHEET_BADGE_SIZE, height: SHEET_BADGE_SIZE, radius: SHEET_BADGE_RADIUS }), _jsx(View, { style: styles.name, children: _jsx(SheetBone, { width: 84, height: 14 }) })] })) : (app &&
19
+ (app.logoUrl ? (_jsx(Image, { testID: "app-logo", source: { uri: app.logoUrl }, accessibilityIgnoresInvertColors: true, style: styles.logo })) : (_jsx(View, { testID: "app-initial", style: [styles.initial, { backgroundColor: brand }], children: _jsx(SheetText, { weight: FigtreeEnum.EXTRA_BOLD, size: 18, color: "white", style: styles.centred, children: app.name.charAt(0) }) })))), !loading && (_jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 16, numberOfLines: 1, accessibilityRole: "header", style: [styles.name, styles.centred], children: app?.name ?? 'Brissoto Pay' })), !loading && view?.test && (_jsx(View, { style: [styles.badge, { backgroundColor: colors.warnSoft }], children: _jsx(SheetText, { weight: FigtreeEnum.EXTRA_BOLD, size: 10.5, color: colors.warnInk, style: [styles.badgeText, styles.centred], children: t('test_badge') }) })), _jsxs(Pressable, { onPress: onClose, accessibilityRole: "button", accessibilityLabel: t('close'), hitSlop: 8, style: [styles.close, { backgroundColor: colors.fill }], children: [_jsx(View, { style: [styles.bar, styles.barUp, { backgroundColor: colors.ink2 }] }), _jsx(View, { style: [
19
20
  styles.bar,
20
21
  styles.barDown,
21
22
  { backgroundColor: colors.ink2 },
@@ -37,16 +38,16 @@ const styles = StyleSheet.create({
37
38
  paddingTop: 10,
38
39
  },
39
40
  logo: {
40
- width: 34,
41
- height: 34,
42
- borderRadius: 9,
41
+ width: SHEET_BADGE_SIZE,
42
+ height: SHEET_BADGE_SIZE,
43
+ borderRadius: SHEET_BADGE_RADIUS,
43
44
  borderWidth: 1,
44
45
  borderColor: MEDIA_LINE,
45
46
  },
46
47
  initial: {
47
- width: 34,
48
- height: 34,
49
- borderRadius: 9,
48
+ width: SHEET_BADGE_SIZE,
49
+ height: SHEET_BADGE_SIZE,
50
+ borderRadius: SHEET_BADGE_RADIUS,
50
51
  alignItems: 'center',
51
52
  justifyContent: 'center',
52
53
  },
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StyleSheet, View } from 'react-native';
3
+ import { SHEET_BADGE_SIZE } from "../models/sheet.constants.js";
3
4
  import { SheetIconEnum } from "../models/sheet.enum.js";
4
5
  /** A sign in a disc, drawn with views: the sheet ships no icon font or SVG. */
5
- export function SheetIcon({ kind, color, background, size = 48, }) {
6
+ export function SheetIcon({ kind, color, background, size = SHEET_BADGE_SIZE, }) {
6
7
  const scale = size / 48;
7
8
  const ink = { backgroundColor: color };
8
9
  const line = { borderColor: color };
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react';
3
3
  import { Keyboard, Platform, ScrollView, StyleSheet, View } from 'react-native';
4
4
  import { useTranslations } from 'use-intl';
5
5
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
6
+ import { SHEET_FOOT_GAP, SHEET_FOOT_TOP } from "../models/sheet.constants.js";
6
7
  import { FigtreeEnum } from "../models/sheet.enum.js";
7
8
  import { SheetText } from "./SheetText.js";
8
9
  /**
@@ -39,10 +40,11 @@ const styles = StyleSheet.create({
39
40
  foot: {
40
41
  borderTopWidth: 1,
41
42
  paddingHorizontal: 18,
42
- paddingTop: 12,
43
+ paddingTop: SHEET_FOOT_TOP,
43
44
  paddingBottom: 28,
44
- gap: 10,
45
+ gap: SHEET_FOOT_GAP,
45
46
  },
46
47
  footOnKeyboard: { paddingBottom: 12 },
47
- secure: { textAlign: 'center' },
48
+ // Android's extra font padding would push it off the middle.
49
+ secure: { textAlign: 'center', includeFontPadding: false },
48
50
  });
@@ -1,3 +1,3 @@
1
1
  import type { MethodCardProps } from '../models/sheet.interface.js';
2
- /** One method of the grid: logo and radio dot, then its name and network, or why it is greyed. Picked, it becomes the chosen row. */
3
- export declare function SheetMethodCard({ logo, name, sub, muted, onSelect, }: MethodCardProps): import("react").JSX.Element;
2
+ /** One method of the grid: logo and radio dot, then its name and network, or why it is greyed. Alone, one full-width row. Picked, it becomes the chosen row. */
3
+ export declare function SheetMethodCard({ logo, name, sub, muted, missing, alone, onSelect, }: MethodCardProps): import("react").JSX.Element;
@@ -1,32 +1,50 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { StyleSheet, TouchableOpacity, View } from 'react-native';
3
3
  import { LOGO_SIZE } from "../checkout-core/models/checkout.constants.js";
4
4
  import { useSheetTheme } from "../hooks/use-sheet-theme.js";
5
5
  import { FigtreeEnum } from "../models/sheet.enum.js";
6
6
  import { SheetLogo } from "./SheetLogo.js";
7
7
  import { SheetText } from "./SheetText.js";
8
- /** One method of the grid: logo and radio dot, then its name and network, or why it is greyed. Picked, it becomes the chosen row. */
9
- export function SheetMethodCard({ logo, name, sub, muted, onSelect, }) {
8
+ import { FIELD_RADIUS } from "../models/sheet.constants.js";
9
+ /** One method of the grid: logo and radio dot, then its name and network, or why it is greyed. Alone, one full-width row. Picked, it becomes the chosen row. */
10
+ export function SheetMethodCard({ logo, name, sub, muted, missing, alone, onSelect, }) {
10
11
  const { colors } = useSheetTheme();
11
- return (_jsxs(TouchableOpacity, { onPress: onSelect, disabled: muted, activeOpacity: 0.7, accessibilityRole: "radio", accessibilityLabel: `${name}, ${sub}`, accessibilityState: { checked: false, disabled: muted }, style: [styles.card, { borderColor: colors.line2 }], children: [_jsxs(View, { style: styles.top, children: [_jsx(SheetLogo, { logo: logo, size: LOGO_SIZE.ROW, muted: muted }), _jsx(View, { style: [
12
+ const red = missing && !muted;
13
+ const texts = (_jsxs(_Fragment, { children: [_jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 15, numberOfLines: 1, color: muted ? colors.muted : colors.ink, children: name }), _jsx(SheetText, { size: 12.5, numberOfLines: 1, color: muted ? colors.warnInk : colors.muted, children: sub })] }));
14
+ return (_jsxs(TouchableOpacity, { onPress: onSelect, disabled: muted, activeOpacity: 0.7, accessibilityRole: "radio", accessibilityLabel: `${name}, ${sub}`, accessibilityState: { checked: false, disabled: muted }, style: [
15
+ styles.card,
16
+ red
17
+ ? [styles.cardMissing, { borderColor: colors.danger }]
18
+ : { borderColor: colors.line2 },
19
+ ], children: [_jsxs(View, { style: alone ? styles.row : styles.top, children: [_jsx(SheetLogo, { logo: logo, size: LOGO_SIZE.ROW, muted: muted }), alone && _jsx(View, { style: styles.text, children: texts }), _jsx(View, { style: [
12
20
  styles.radio,
13
- { borderColor: muted ? colors.line2 : colors.line4 },
14
- ] })] }), _jsx(SheetText, { weight: FigtreeEnum.BOLD, size: 15, numberOfLines: 1, color: muted ? colors.muted : colors.ink, children: name }), _jsx(SheetText, { size: 12.5, numberOfLines: 1, color: muted ? colors.warnInk : colors.muted, children: sub })] }));
21
+ {
22
+ borderColor: muted
23
+ ? colors.line2
24
+ : red
25
+ ? colors.danger
26
+ : colors.line4,
27
+ },
28
+ ] })] }), !alone && texts] }));
15
29
  }
16
30
  const styles = StyleSheet.create({
17
31
  card: {
18
32
  flex: 1,
19
33
  minWidth: 0,
20
34
  borderWidth: 1,
21
- borderRadius: 14,
35
+ borderRadius: FIELD_RADIUS,
22
36
  padding: 12,
23
37
  gap: 2,
24
38
  },
39
+ // 1.5 like the web's ring; the padding gives back the half point.
40
+ cardMissing: { borderWidth: 1.5, padding: 11.5 },
25
41
  top: {
26
42
  flexDirection: 'row',
27
43
  alignItems: 'flex-start',
28
44
  justifyContent: 'space-between',
29
45
  marginBottom: 10,
30
46
  },
47
+ row: { flexDirection: 'row', alignItems: 'center', gap: 12 },
48
+ text: { flex: 1, minWidth: 0 },
31
49
  radio: { width: 20, height: 20, borderRadius: 10, borderWidth: 1.5 },
32
50
  });
@@ -6,6 +6,7 @@ import { FigtreeEnum } from "../models/sheet.enum.js";
6
6
  import { flagOf } from "../models/sheet.utils.js";
7
7
  import { SheetLogo } from "./SheetLogo.js";
8
8
  import { SheetInput, SheetText } from "./SheetText.js";
9
+ import { FIELD_HEIGHT, FIELD_RADIUS } from "../models/sheet.constants.js";
9
10
  /** The Mobile Money number on the phone keyboard: the country's dial code, the number grouped as typed, its check. */
10
11
  export function SheetPhoneField({ core, phoneRef }) {
11
12
  const t = useTranslations('checkout');
@@ -32,9 +33,9 @@ const styles = StyleSheet.create({
32
33
  field: {
33
34
  flexDirection: 'row',
34
35
  alignItems: 'center',
35
- height: 50,
36
+ height: FIELD_HEIGHT,
36
37
  borderWidth: 1,
37
- borderRadius: 13,
38
+ borderRadius: FIELD_RADIUS,
38
39
  overflow: 'hidden',
39
40
  },
40
41
  dial: {
@@ -30,7 +30,7 @@ export function SheetToast({ toast }) {
30
30
  const styles = StyleSheet.create({
31
31
  place: {
32
32
  position: 'absolute',
33
- top: 12,
33
+ top: 24,
34
34
  left: 18,
35
35
  right: 18,
36
36
  alignItems: 'center',
@@ -0,0 +1,3 @@
1
+ import { Animated } from 'react-native';
2
+ /** A side-to-side shake each time `count` grows past 0: the offset to put in a `translateX`. */
3
+ export declare function useShake(count: number): Animated.Value;
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Animated, Easing } from 'react-native';
3
+ import { SHAKE_MS, SHAKE_OFFSETS } from "../models/sheet.constants.js";
4
+ /** A side-to-side shake each time `count` grows past 0: the offset to put in a `translateX`. */
5
+ export function useShake(count) {
6
+ const [offset] = useState(() => new Animated.Value(0));
7
+ useEffect(() => {
8
+ if (count === 0)
9
+ return;
10
+ Animated.sequence(SHAKE_OFFSETS.map((toValue) => Animated.timing(offset, {
11
+ toValue,
12
+ duration: SHAKE_MS / SHAKE_OFFSETS.length,
13
+ easing: Easing.out(Easing.quad),
14
+ useNativeDriver: true,
15
+ }))).start();
16
+ }, [count, offset]);
17
+ return offset;
18
+ }
@@ -39,3 +39,24 @@ export declare const SHEET_FLICK_SPEED = 0.8;
39
39
  export declare const SHEET_RESIZE: LayoutAnimationConfig;
40
40
  /** A paragraph's line height, as a share of its size: the web checkout's 1.5, a touch tighter for Figtree on phones. */
41
41
  export declare const PARAGRAPH_LINE_HEIGHT = 1.45;
42
+ /** A field the payer still has to fill, shaken once when Pay is pressed: its offsets, then back. */
43
+ export declare const SHAKE_MS = 300;
44
+ export declare const SHAKE_OFFSETS: number[];
45
+ /** The form's one row height and one corner, Pay's: the tabs, the method picked, the fields and the buttons line up. */
46
+ export declare const FIELD_HEIGHT = 50;
47
+ export declare const FIELD_RADIUS = 14;
48
+ /** The tabs' inset around the tab on: its corner follows the bar's. */
49
+ export declare const TABS_INSET = 3;
50
+ /**
51
+ * Over and under the secured line, between the foot's separator and Pay.
52
+ * Figtree leaves 2pt more room over its letters than under them at this
53
+ * size, so the space over is 2 less: the letters sit in the middle.
54
+ */
55
+ export declare const SHEET_FOOT_TOP = 11;
56
+ export declare const SHEET_FOOT_GAP = 13;
57
+ /**
58
+ * The app's logo in the header and a screen's disc under it (failed, closed)
59
+ * share one size: one under the other, their centres line up.
60
+ */
61
+ export declare const SHEET_BADGE_SIZE = 44;
62
+ export declare const SHEET_BADGE_RADIUS = 12;
@@ -78,3 +78,24 @@ export const SHEET_RESIZE = {
78
78
  };
79
79
  /** A paragraph's line height, as a share of its size: the web checkout's 1.5, a touch tighter for Figtree on phones. */
80
80
  export const PARAGRAPH_LINE_HEIGHT = 1.45;
81
+ /** A field the payer still has to fill, shaken once when Pay is pressed: its offsets, then back. */
82
+ export const SHAKE_MS = 300;
83
+ export const SHAKE_OFFSETS = [-6, 6, -3, 0];
84
+ /** The form's one row height and one corner, Pay's: the tabs, the method picked, the fields and the buttons line up. */
85
+ export const FIELD_HEIGHT = 50;
86
+ export const FIELD_RADIUS = 14;
87
+ /** The tabs' inset around the tab on: its corner follows the bar's. */
88
+ export const TABS_INSET = 3;
89
+ /**
90
+ * Over and under the secured line, between the foot's separator and Pay.
91
+ * Figtree leaves 2pt more room over its letters than under them at this
92
+ * size, so the space over is 2 less: the letters sit in the middle.
93
+ */
94
+ export const SHEET_FOOT_TOP = 11;
95
+ export const SHEET_FOOT_GAP = 13;
96
+ /**
97
+ * The app's logo in the header and a screen's disc under it (failed, closed)
98
+ * share one size: one under the other, their centres line up.
99
+ */
100
+ export const SHEET_BADGE_SIZE = 44;
101
+ export const SHEET_BADGE_RADIUS = 12;
@@ -179,6 +179,10 @@ export interface MethodCardProps {
179
179
  sub: string;
180
180
  /** Greyed and not pressable: in maintenance, or not offered yet. */
181
181
  muted: boolean;
182
+ /** Pay was pressed with no method picked: the card is ringed in red. */
183
+ missing?: boolean;
184
+ /** The only method of the tab: a full-width row. */
185
+ alone?: boolean;
182
186
  onSelect?: () => void;
183
187
  }
184
188
  export interface ChosenMethodProps {
@@ -194,6 +198,8 @@ export interface MethodGridCard extends MethodCardProps {
194
198
  }
195
199
  export interface MethodGridProps {
196
200
  cards: Array<MethodGridCard>;
201
+ /** Pay presses with no method picked: red, and a shake per press. */
202
+ missing: number;
197
203
  }
198
204
  export interface SheetIconProps {
199
205
  kind: SheetIconEnum;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heyama/pay-react-native",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Brissoto Pay for Expo and React Native apps: the payment sheet.",
5
5
  "license": "MIT",
6
6
  "sideEffects": false,