@12-apps/payments-frontend 1.0.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.
Files changed (34) hide show
  1. package/eslint.config.js +34 -0
  2. package/package.json +63 -0
  3. package/src/__tests__/checkout-confirmation.test.tsx +177 -0
  4. package/src/__tests__/connection-state.test.tsx +84 -0
  5. package/src/__tests__/context.test.tsx +88 -0
  6. package/src/__tests__/controlled-provider.test.tsx +116 -0
  7. package/src/__tests__/credential-confirm.test.tsx +193 -0
  8. package/src/__tests__/initial-provider.test.tsx +81 -0
  9. package/src/__tests__/provider-priority-list.test.tsx +159 -0
  10. package/src/__tests__/provider-status-bar.test.tsx +152 -0
  11. package/src/__tests__/verification-slot.test.tsx +125 -0
  12. package/src/client.ts +200 -0
  13. package/src/components/CheckoutFlow.tsx +169 -0
  14. package/src/components/CheckoutPayment.tsx +379 -0
  15. package/src/components/ConfirmCredentialSave.tsx +106 -0
  16. package/src/components/CredentialFields.tsx +158 -0
  17. package/src/components/CredentialFormAlerts.tsx +144 -0
  18. package/src/components/EnvironmentTabs.tsx +109 -0
  19. package/src/components/PaymentProviderSettings.tsx +267 -0
  20. package/src/components/ProviderConnection.tsx +251 -0
  21. package/src/components/ProviderCredentialForm.tsx +387 -0
  22. package/src/components/ProviderList.tsx +126 -0
  23. package/src/components/ProviderPanel.tsx +300 -0
  24. package/src/components/ProviderPriorityList.tsx +293 -0
  25. package/src/components/ProviderSetupGuide.tsx +263 -0
  26. package/src/components/ProviderStatusBar.tsx +192 -0
  27. package/src/components/SetupGuideSection.tsx +190 -0
  28. package/src/components/checkout-ack.ts +106 -0
  29. package/src/components/connection-state.ts +66 -0
  30. package/src/components/credential-rules.ts +120 -0
  31. package/src/components/rich-text.tsx +27 -0
  32. package/src/components/settings-state.ts +211 -0
  33. package/src/context.tsx +144 -0
  34. package/src/index.ts +69 -0
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import { Fragment, type ReactNode } from 'react';
4
+
5
+ /**
6
+ * `**bold**`, and nothing else.
7
+ *
8
+ * Adapters author their walkthrough as plain strings — the content crosses an
9
+ * HTTP boundary, so it cannot carry markup — but the emphasis is load-bearing
10
+ * copy rather than decoration: `alterar` and `qual conta` are the words that
11
+ * say what the sentence is warning ABOUT. Shouting them in capitals was the
12
+ * workaround, and it reads as a raised voice while being harder to scan.
13
+ *
14
+ * Deliberately not a markdown library. Two asterisks is the entire grammar, an
15
+ * unmatched pair renders verbatim, and nothing here can emit HTML — the text is
16
+ * provider-authored, and a renderer that interpreted more would be an injection
17
+ * surface on a payments screen.
18
+ */
19
+ export function richText(text: string): ReactNode {
20
+ return text.split(/\*\*(.+?)\*\*/g).map((chunk, index) =>
21
+ index % 2 === 1 ? (
22
+ <strong key={index}>{chunk}</strong>
23
+ ) : (
24
+ <Fragment key={index}>{chunk}</Fragment>
25
+ ),
26
+ );
27
+ }
@@ -0,0 +1,211 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ import type {
6
+ MaskedProviderConfig,
7
+ MerchantSettingsView,
8
+ ProviderDescriptor,
9
+ ProviderSetupGuide as Guide,
10
+ } from '@12-apps/payments-backend';
11
+
12
+ import type { PaymentsSettingsClient } from '../client';
13
+ import { useCheckoutAck } from './checkout-ack';
14
+ import { CHECKOUT_CONFIRM_ACTION } from './SetupGuideSection';
15
+
16
+ /**
17
+ * Everything the settings screen KNOWS, separated from what it draws.
18
+ *
19
+ * The component had accumulated five hooks and three derivations above its
20
+ * early returns — the position where a React component is least readable,
21
+ * because none of it can be moved and all of it has to be understood before
22
+ * the first line of markup. Split out, that file is about layout and this one
23
+ * is about state.
24
+ */
25
+
26
+ /**
27
+ * Fetch the selected provider's walkthrough.
28
+ *
29
+ * `progressKey` exists because a guide is no longer a constant per provider:
30
+ * the server decides which step to show from what the store has already done,
31
+ * so the walkthrough goes stale the moment the connection changes. Keyed on
32
+ * the provider alone, saving a credential left the owner reading the step they
33
+ * had just completed — the screen said "informe sua InfiniteTag" underneath
34
+ * the tag they had saved a second earlier.
35
+ *
36
+ * A derived STRING rather than the config object: the settings view is
37
+ * replaced on every reload, so an object dependency would refetch the guide
38
+ * after any action at all, including ones that cannot change it.
39
+ */
40
+ export function useSetupGuide(
41
+ client: PaymentsSettingsClient,
42
+ provider: string | null,
43
+ progressKey: string,
44
+ ): { guide: Guide | null; loaded: boolean } {
45
+ const [state, setState] = useState<{ guide: Guide | null; loaded: boolean }>({
46
+ guide: null,
47
+ loaded: false,
48
+ });
49
+ useEffect(() => {
50
+ setState({ guide: null, loaded: false });
51
+ if (!provider) return;
52
+ client
53
+ .getSetupGuide(provider)
54
+ // `loaded` is not cosmetic. "No guide yet" and "this provider ships no
55
+ // guide" are the same `null`, and the difference decides whether a REAL
56
+ // payment button is on screen — so for one frame after every load, a
57
+ // store mid-setup was offered the charge before anything knew which step
58
+ // it was on.
59
+ .then((guide) => setState({ guide, loaded: true }))
60
+ .catch(() => setState({ guide: null, loaded: true }));
61
+ }, [client, provider, progressKey]);
62
+ return state;
63
+ }
64
+
65
+ /**
66
+ * The facts a guide branches on, flattened to a comparable string: which
67
+ * credential fields are filled in the active environment, whether the probe
68
+ * passed, and whether a charge has proved it.
69
+ */
70
+ export function progressKeyOf(config: MaskedProviderConfig | null): string {
71
+ if (!config) return 'none';
72
+ const configured = Object.entries(config.environments[config.environment] ?? {})
73
+ .filter(([, field]) => field.configured)
74
+ .map(([key]) => key)
75
+ .sort()
76
+ .join(',');
77
+ return `${config.environment}|${config.status}|${config.chargeVerifiedAt ?? ''}|${configured}`;
78
+ }
79
+
80
+ /**
81
+ * Is the walkthrough still waiting on the owner to confirm something?
82
+ *
83
+ * The one input to `blocked`. It lives with the guide rather than inside the
84
+ * activation step because the guide is what knows a confirmable section is
85
+ * still on screen; the step only knows about charges.
86
+ */
87
+ export function guideAwaitsConfirmation(
88
+ guide: Guide | null,
89
+ confirmed: boolean,
90
+ loaded: boolean,
91
+ ): boolean {
92
+ if (confirmed) return false;
93
+ // Withheld until we know. Erring towards blocked costs at most a moment's
94
+ // wait on a button; erring the other way puts "Pagar e ativar" in front of an
95
+ // owner whose setup we have not read yet, and that one is paid for in money.
96
+ if (!loaded) return true;
97
+ if (!guide) return false;
98
+ return guide.sections.some((section) =>
99
+ section.steps.some((step) => step.action === CHECKOUT_CONFIRM_ACTION),
100
+ );
101
+ }
102
+
103
+ /**
104
+ * Has the store settled the setup step that only it can see?
105
+ *
106
+ * Two sources, in order of authority. A charge that LANDED proves the
107
+ * provider-side switch is on far better than any answer could, so a store that
108
+ * reconnects from a machine holding no stored confirmation is never sent back
109
+ * through step 2. Failing that, the owner's own word — scoped to this client's
110
+ * tenant-specific base URL, so an owner who runs two stores answers for each.
111
+ */
112
+ export function useSetupConfirmation(
113
+ client: PaymentsSettingsClient,
114
+ active: ProviderDescriptor | null,
115
+ config: MaskedProviderConfig | null,
116
+ ): { confirmed: boolean; confirm: () => void; withdraw: () => void } {
117
+ const ack = useCheckoutAck(client.baseUrl, active ? active.name : null);
118
+ const proven = Boolean(config?.chargeVerifiedAt);
119
+ return { ...ack, confirmed: proven ? true : ack.confirmed };
120
+ }
121
+
122
+ /**
123
+ * The provider the owner has open, and the config row that belongs to it.
124
+ *
125
+ * One hook rather than two `useMemo`s inline, because both have to be resolved
126
+ * BEFORE the loading and error returns — the guide hook depends on them — and
127
+ * that is exactly the position where a component accumulates the branches that
128
+ * make it unreadable.
129
+ */
130
+ export function useOpenProvider(
131
+ view: MerchantSettingsView | null,
132
+ selected: string | null,
133
+ ): { active: ProviderDescriptor | null; activeConfig: MaskedProviderConfig | null } {
134
+ // Nothing is open until the owner picks: the landing view is the list of
135
+ // providers, not one arbitrary provider's configuration.
136
+ const active = useMemo(() => {
137
+ if (!view || !selected) return null;
138
+ return view.providers.find((p) => p.name === selected) ?? null;
139
+ }, [view, selected]);
140
+
141
+ // A stored connection is what makes the manual walkthrough steps redundant —
142
+ // and, since the guide branches on it, what decides which step is shown.
143
+ const activeConfig = useMemo(() => {
144
+ if (!view || !active) return null;
145
+ return view.configs.find((c) => c.provider === active.name) ?? null;
146
+ }, [view, active]);
147
+
148
+ return { active, activeConfig };
149
+ }
150
+
151
+ export function useSettingsState(client: PaymentsSettingsClient) {
152
+ const [view, setView] = useState<MerchantSettingsView | null>(null);
153
+ const [error, setError] = useState<string | null>(null);
154
+ const reload = useCallback(async () => {
155
+ try {
156
+ setView(await client.getSettings());
157
+ setError(null);
158
+ } catch (err) {
159
+ setError(err instanceof Error ? err.message : String(err));
160
+ }
161
+ }, [client]);
162
+ useEffect(() => {
163
+ void reload();
164
+ }, [reload]);
165
+ return { view, error, reload };
166
+ }
167
+
168
+ /**
169
+ * Which provider is open, and the once-only handover from the host.
170
+ *
171
+ * Seeding `useState` with `initialProvider` is not enough: the host reads the
172
+ * provider from the OAuth callback's query string in an EFFECT, so on first
173
+ * render it is still null — and `useState` keeps that null forever. The owner
174
+ * therefore landed on the list after authorizing, which is the bug this fixed
175
+ * twice.
176
+ *
177
+ * Applied ONCE. The host holds the outcome for the page's lifetime, so
178
+ * re-applying it would make "Voltar aos provedores" bounce straight back into
179
+ * the provider and trap the owner on one screen.
180
+ *
181
+ * None of that applies when the host is CONTROLLING the selection: there is no
182
+ * handover to make, because the host's value already is the answer on every
183
+ * render. `controlled === undefined` is the only test for that — `null` is a
184
+ * controlled host legitimately saying "the list", and treating it as absent
185
+ * would strand the owner in whichever provider they last opened.
186
+ */
187
+ export function useSelectedProvider(
188
+ initialProvider: string | null,
189
+ controlled: string | null | undefined,
190
+ onProviderChange: ((provider: string | null) => void) | undefined,
191
+ ) {
192
+ const isControlled = controlled !== undefined;
193
+ const [internal, setInternal] = useState<string | null>(initialProvider);
194
+ const honoredInitial = useRef(false);
195
+
196
+ useEffect(() => {
197
+ if (isControlled || honoredInitial.current || !initialProvider) return;
198
+ honoredInitial.current = true;
199
+ setInternal(initialProvider);
200
+ }, [initialProvider, isControlled]);
201
+
202
+ const setSelected = useCallback(
203
+ (provider: string | null) => {
204
+ if (!isControlled) setInternal(provider);
205
+ onProviderChange?.(provider);
206
+ },
207
+ [isControlled, onProviderChange],
208
+ );
209
+
210
+ return { selected: isControlled ? controlled : internal, setSelected };
211
+ }
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from 'react';
12
+
13
+ import type { ClientChargeView } from '@12-apps/payments-backend';
14
+
15
+ import { isSettled, type ClientChargeRequest, type PaymentsClient } from './client';
16
+
17
+ /**
18
+ * Headless React bindings — state and flow only, ZERO UI. Visual checkout
19
+ * components belong to each host's design system (`@12-apps/ui` here); these
20
+ * hooks make every such UI provider-agnostic: the same `useCreateCharge` +
21
+ * `useChargeStatus` pair drives a PIX QR screen, a card form, or an
22
+ * InfinitePay redirect, switching on `charge.method` / `hostedCheckoutUrl`.
23
+ */
24
+
25
+ const PaymentsContext = createContext<PaymentsClient | null>(null);
26
+
27
+ export interface PaymentsProviderProps {
28
+ client: PaymentsClient;
29
+ children: ReactNode;
30
+ }
31
+
32
+ export function PaymentsProvider({ client, children }: PaymentsProviderProps): ReactNode {
33
+ return <PaymentsContext.Provider value={client}>{children}</PaymentsContext.Provider>;
34
+ }
35
+
36
+ export function usePaymentsClient(): PaymentsClient {
37
+ const client = useContext(PaymentsContext);
38
+ if (!client) {
39
+ throw new Error('usePaymentsClient must be used inside a <PaymentsProvider>');
40
+ }
41
+ return client;
42
+ }
43
+
44
+ export interface CreateChargeState {
45
+ charge: ClientChargeView | null;
46
+ loading: boolean;
47
+ error: Error | null;
48
+ create(request: ClientChargeRequest): Promise<ClientChargeView | null>;
49
+ reset(): void;
50
+ }
51
+
52
+ /** Imperative charge creation with loading/error state. */
53
+ export function useCreateCharge(): CreateChargeState {
54
+ const client = usePaymentsClient();
55
+ const [charge, setCharge] = useState<ClientChargeView | null>(null);
56
+ const [loading, setLoading] = useState(false);
57
+ const [error, setError] = useState<Error | null>(null);
58
+
59
+ const create = useCallback(
60
+ async (request: ClientChargeRequest) => {
61
+ setLoading(true);
62
+ setError(null);
63
+ try {
64
+ const created = await client.createCharge(request);
65
+ setCharge(created);
66
+ return created;
67
+ } catch (err) {
68
+ setError(err instanceof Error ? err : new Error(String(err)));
69
+ return null;
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ },
74
+ [client],
75
+ );
76
+
77
+ const reset = useCallback(() => {
78
+ setCharge(null);
79
+ setError(null);
80
+ }, []);
81
+
82
+ return { charge, loading, error, create, reset };
83
+ }
84
+
85
+ export interface ChargeStatusOptions {
86
+ /** Poll interval; default 4000ms. Polling stops on settled states. */
87
+ intervalMs?: number;
88
+ }
89
+
90
+ export interface ChargeStatusState {
91
+ charge: ClientChargeView | null;
92
+ /** True once the charge reached a state that will not change by waiting. */
93
+ settled: boolean;
94
+ error: Error | null;
95
+ }
96
+
97
+ /** Identifies one charge to poll: which provider, which provider-side id. */
98
+ export interface ChargeRef {
99
+ provider: string;
100
+ providerChargeId: string;
101
+ }
102
+
103
+ /**
104
+ * Polls the host for a charge until it settles (webhooks update the server;
105
+ * the browser polls the server — never the provider). Pass `null` to idle.
106
+ */
107
+ export function useChargeStatus(
108
+ ref: ChargeRef | null,
109
+ options: ChargeStatusOptions = {},
110
+ ): ChargeStatusState {
111
+ const client = usePaymentsClient();
112
+ const intervalMs = options.intervalMs ?? 4000;
113
+ const [charge, setCharge] = useState<ClientChargeView | null>(null);
114
+ const [error, setError] = useState<Error | null>(null);
115
+ const settled = charge !== null && isSettled(charge.status);
116
+ const settledRef = useRef(settled);
117
+ settledRef.current = settled;
118
+ const provider = ref?.provider ?? null;
119
+ const providerChargeId = ref?.providerChargeId ?? null;
120
+
121
+ useEffect(() => {
122
+ if (!provider || !providerChargeId) return undefined;
123
+ let disposed = false;
124
+
125
+ const tick = async (): Promise<void> => {
126
+ if (disposed || settledRef.current) return;
127
+ try {
128
+ const fresh = await client.getCharge(provider, providerChargeId);
129
+ if (!disposed) setCharge(fresh);
130
+ } catch (err) {
131
+ if (!disposed) setError(err instanceof Error ? err : new Error(String(err)));
132
+ }
133
+ };
134
+
135
+ void tick();
136
+ const timer = setInterval(() => void tick(), intervalMs);
137
+ return () => {
138
+ disposed = true;
139
+ clearInterval(timer);
140
+ };
141
+ }, [client, provider, providerChargeId, intervalMs]);
142
+
143
+ return { charge, settled, error };
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `@12-apps/payments-frontend` — the browser half of the payments platform.
3
+ *
4
+ * Plug-and-play MUI components for the two payment surfaces (per-provider
5
+ * settings page, checkout payment step), plus the headless hooks and fetch
6
+ * clients they are built on for hosts that want their own pixels. Imports
7
+ * only TYPES from `@12-apps/payments-backend`; talks exclusively to the host's
8
+ * mounted payments HTTP surface.
9
+ */
10
+ export {
11
+ createPaymentsClient,
12
+ createPaymentsSettingsClient,
13
+ isSettled,
14
+ PaymentsClientError,
15
+ type ClientChargeRequest,
16
+ type ClientPaymentsConfig,
17
+ type PaymentsClient,
18
+ type PaymentsClientOptions,
19
+ type PaymentsSettingsClient,
20
+ } from './client';
21
+
22
+ export {
23
+ PaymentsProvider,
24
+ useChargeStatus,
25
+ useCreateCharge,
26
+ usePaymentsClient,
27
+ type ChargeRef,
28
+ type ChargeStatusOptions,
29
+ type ChargeStatusState,
30
+ type CreateChargeState,
31
+ type PaymentsProviderProps,
32
+ } from './context';
33
+
34
+ export {
35
+ CheckoutPayment,
36
+ type CardFormValues,
37
+ type CheckoutPaymentProps,
38
+ type SavedCardOption,
39
+ } from './components/CheckoutPayment';
40
+ export { CheckoutFlow, type CheckoutFlowProps } from './components/CheckoutFlow';
41
+ export {
42
+ ProviderConnection,
43
+ type ProviderConnectionProps,
44
+ } from './components/ProviderConnection';
45
+ export {
46
+ ProviderPriorityList,
47
+ type ProviderPriorityListProps,
48
+ } from './components/ProviderPriorityList';
49
+ export {
50
+ ProviderSetupGuide,
51
+ type ProviderSetupGuideProps,
52
+ } from './components/ProviderSetupGuide';
53
+ export {
54
+ CHECKOUT_CONFIRM_ACTION,
55
+ SetupGuideSection,
56
+ type SetupGuideSectionProps,
57
+ } from './components/SetupGuideSection';
58
+ export { ProviderStatusBar, statusBadge } from './components/ProviderStatusBar';
59
+ export {
60
+ PaymentProviderSettings,
61
+ type PaymentProviderSettingsProps,
62
+ } from './components/PaymentProviderSettings';
63
+
64
+ /**
65
+ * Re-exported because it appears in the `prepareConnect` prop a host must
66
+ * implement: without it the host could not type its own callback without
67
+ * taking a direct dependency on the backend package.
68
+ */
69
+ export type { PaymentEnvironment } from '@12-apps/payments-backend';