airwallexInit.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import { init, type InitResult,createElement, type Payment } from '@airwallex/components-sdk';
  2. import { env } from "@/env";
  3. // 文档地址 https://www.airwallex.com/docs/js/payments/card-number
  4. // ============================================================
  5. // Types
  6. // ============================================================
  7. export type AirwallexCartNumberElementType = Payment.CardNumberElementType;
  8. export type AirwallexCvcElementType = Payment.CvcElementType;
  9. export type AirwallexExpiryDateElementType = Payment.ExpiryDateElementType;
  10. export interface AirwallexCardElements{
  11. cardNumber?: AirwallexCartNumberElementType;
  12. cvc?: AirwallexCvcElementType;
  13. expiry?: AirwallexExpiryDateElementType;
  14. }
  15. type ElementType =
  16. | AirwallexCartNumberElementType
  17. | AirwallexCvcElementType
  18. | AirwallexExpiryDateElementType;
  19. // ============================================================
  20. // Snapshot
  21. // ============================================================
  22. export interface AirwallexCardSnapshot {
  23. cardNumberReady: boolean;
  24. cvcReady: boolean;
  25. expiryReady: boolean;
  26. cardNumberValid: boolean;
  27. cvcValid: boolean;
  28. expiryValid: boolean;
  29. cardNumberErrorMsg: string;
  30. cvcErrorMsg: string;
  31. expiryErrorMsg: string;
  32. }
  33. // ============================================================
  34. // Initial Snapshot
  35. // ============================================================
  36. const initialSnapshot: AirwallexCardSnapshot = {
  37. cardNumberReady: false,
  38. cvcReady: false,
  39. expiryReady: false,
  40. cardNumberValid: false,
  41. cvcValid: false,
  42. expiryValid: false,
  43. cardNumberErrorMsg: '',
  44. cvcErrorMsg: '',
  45. expiryErrorMsg: '',
  46. };
  47. // ============================================================
  48. // Airwallex SDK init
  49. // ============================================================
  50. let initPromise: Promise<InitResult> | null = null;
  51. export function airwallexInit() {
  52. if (!initPromise) {
  53. initPromise = init({
  54. env: env.NEXT_PUBLIC_APP_ENV !== 'production' ? 'demo' : 'prod',
  55. enabledElements: ['payments'],
  56. }).catch((err) => {
  57. initPromise = null;
  58. throw err;
  59. });
  60. }
  61. return initPromise;
  62. }
  63. // ============================================================
  64. // Manager
  65. // ============================================================
  66. // 创建的cardNumber,cvc,expiry输入框挂载到页面上的某个元素上才能渲染出来
  67. // 修改购物车数据后,需要重新获取支付方式列表,要想实现销毁-->重建卡支付输入框,必须要在销毁后把挂载的那个元素也删除,否则新创建的输入框上的绑定的事件会重发触发
  68. // 所以进行此次优化
  69. class AirwallexManager {
  70. // --------------------------------------------------------
  71. // Airwallex Elements
  72. // --------------------------------------------------------
  73. private elements: AirwallexCardElements = {};
  74. private createPromise: Promise<AirwallexCardElements> | null = null;
  75. // 引入使用者计数(现在是全局单例模式,这里的全局指的是以页面的生命周期为准,对于卡支付输入框,全局只有一个地方可以初始化,每次初始化后,全局只有一个实例)
  76. // 所以这里引入的refCount是一个过度防御
  77. // 当把airwallex的卡支付初始化与卡支付输入框的状态订阅拆分后,这个refCount已不那么需要(可以删除),但还是保留
  78. private refCount = 0;
  79. // --------------------------------------------------------
  80. // React external store
  81. // --------------------------------------------------------
  82. private snapshot: AirwallexCardSnapshot = {
  83. ...initialSnapshot,
  84. };
  85. private listeners = new Set<() => void>(); // 集合类型,用来保存监听器
  86. // 参数listener是react内部的监听函数
  87. // 当把subscribe传给useSyncExternalStore后,react在内部调用subscribe时把自己的监听函数传给subscribe
  88. subscribe = (listener: () => void) => {
  89. // 把react的监听函数保存到集合
  90. this.listeners.add(listener);
  91. // 取消订阅时(比如组件卸载时),删除保存的监听函数
  92. return () => {
  93. this.listeners.delete(listener);
  94. };
  95. };
  96. // react传给subscribe的监听函数里会调用getSnapshot重新获取数据快照
  97. getSnapshot = () => {
  98. return this.snapshot;
  99. };
  100. private emit() {
  101. this.listeners.forEach((listener) => {
  102. listener();
  103. });
  104. }
  105. private updateSnapshot( patch: Partial<AirwallexCardSnapshot> ) {
  106. this.snapshot = {
  107. ...this.snapshot,
  108. ...patch,
  109. };
  110. // 修改数据后,触发react的监听器,监听器里重现获取数据快照,数据变了,react重新渲染组件
  111. this.emit();
  112. }
  113. private resetSnapshot() {
  114. this.snapshot = {
  115. ...initialSnapshot,
  116. };
  117. this.emit();
  118. }
  119. //
  120. setRefCount() {
  121. this.refCount++;
  122. console.log(
  123. '[Airwallex] acquire:',
  124. this.refCount
  125. );
  126. }
  127. release() {
  128. if (this.refCount === 0) {
  129. return;
  130. }
  131. this.refCount--;
  132. console.log(
  133. '[Airwallex] release:',
  134. this.refCount
  135. );
  136. if (this.refCount === 0) {
  137. console.log('_____________________________AirwallexCard destory ___________________________________')
  138. this.destroy();
  139. }
  140. }
  141. async createElements() {
  142. if (this.elements.cardNumber) {
  143. return this.elements;
  144. }
  145. if (this.createPromise) {
  146. return this.createPromise;
  147. }
  148. this.createPromise = Promise.all([
  149. createElement("cardNumber",{
  150. placeholder: '1234 1234 1234 1234',
  151. style:{
  152. base: {
  153. 'fontSize': '1rem',
  154. '::placeholder': {'color': '#a6a6a6'} as any // 这里不得不断言为any, 因为airwallex sdk的类型定义有问题,属于官方bug
  155. }
  156. }
  157. }),
  158. createElement("cvc",{
  159. placeholder: 'CVC',
  160. style:{
  161. base: {
  162. 'fontSize': '1rem',
  163. '::placeholder': {'color': '#a6a6a6'} as any // 这里不得不断言为any, 因为airwallex sdk的类型定义有问题,属于官方bug
  164. }
  165. }
  166. }),
  167. createElement("expiry",{
  168. placeholder: 'Month/Year',
  169. style:{
  170. base: {
  171. 'fontSize': '1rem',
  172. '::placeholder': {'color': '#a6a6a6'} as any // 这里不得不断言为any, 因为airwallex sdk的类型定义有问题,属于官方bug
  173. }
  174. }
  175. })
  176. ]).then(([cardNumber,cvc,expiry]) => {
  177. this.elements = {
  178. cardNumber,
  179. cvc,
  180. expiry
  181. };
  182. return this.elements;
  183. }).catch((error) => {
  184. // 创建失败,允许下一次重新创建
  185. this.createPromise = null;
  186. throw error;
  187. });
  188. return this.createPromise;
  189. }
  190. mount(containerIds: {
  191. cardNumber: string;
  192. cvc: string;
  193. expiry: string;
  194. }) {
  195. this.elements.cardNumber?.mount(containerIds.cardNumber);
  196. this.elements.cvc?.mount(containerIds.cvc);
  197. this.elements.expiry?.mount(containerIds.expiry);
  198. }
  199. on<EventCode extends Payment.SplitElementEventCode>(
  200. el: ElementType,
  201. event: EventCode,
  202. handler: Payment.SplitElementEventHandler<EventCode>
  203. ) {
  204. el.on(event, handler);
  205. }
  206. elementsBindHandler() {
  207. const { cardNumber, cvc, expiry } = this.elements;
  208. if(!cardNumber || !cvc || !expiry) {
  209. throw new Error('Airwallex elements have not been created!');
  210. }
  211. cardNumber.on("focus", (e) => {
  212. this.handleFocus(e)
  213. });
  214. cardNumber.on("blur", (e) => {
  215. this.handleBlur(e);
  216. });
  217. cardNumber.on("change", (e) => {
  218. this.handleChange(e);
  219. });
  220. cardNumber.on("ready", (e) => {
  221. this.handleReady(e);
  222. });
  223. cvc.on("focus", (e) => {
  224. this.handleFocus(e);
  225. });
  226. cvc.on("blur", (e) => {
  227. this.handleBlur(e);
  228. });
  229. cvc.on("change", (e) => {
  230. this.handleChange(e);
  231. });
  232. cvc.on("ready", (e) => {
  233. this.handleReady(e);
  234. });
  235. expiry.on("focus", (e) => {
  236. this.handleFocus(e);
  237. });
  238. expiry.on("blur", (e) => {
  239. this.handleBlur(e);
  240. });
  241. expiry.on("change", (e) => {
  242. this.handleChange(e);
  243. });
  244. expiry.on("ready", (e) => {
  245. this.handleReady(e);
  246. });
  247. }
  248. // ========================================================
  249. // Airwallex event handlers
  250. // ========================================================
  251. handleReady(event: any) {
  252. console.log('-----------------airwallex card ready-----------------');
  253. const { type } = event.detail;
  254. if (type === 'cardNumber') {
  255. this.updateSnapshot({
  256. cardNumberReady: true,
  257. });
  258. return;
  259. }
  260. if (type === 'cvc') {
  261. this.updateSnapshot({
  262. cvcReady: true,
  263. });
  264. return;
  265. }
  266. if (type === 'expiry') {
  267. this.updateSnapshot({
  268. expiryReady: true,
  269. });
  270. return;
  271. }
  272. }
  273. handleChange(event: any) {
  274. const {
  275. type,
  276. complete,
  277. error,
  278. } = event.detail;
  279. const message = error?.message ?? '';
  280. if (type === 'cardNumber') {
  281. this.updateSnapshot({
  282. cardNumberValid: complete,
  283. cardNumberErrorMsg: message,
  284. });
  285. return;
  286. }
  287. if (type === 'cvc') {
  288. this.updateSnapshot({
  289. cvcValid: complete,
  290. cvcErrorMsg: message,
  291. });
  292. return;
  293. }
  294. if (type === 'expiry') {
  295. this.updateSnapshot({
  296. expiryValid: complete,
  297. expiryErrorMsg: message,
  298. });
  299. return;
  300. }
  301. }
  302. handleFocus(event: any) {
  303. const { type } = event.detail;
  304. if (type === 'cardNumber') {
  305. this.updateSnapshot({
  306. cardNumberErrorMsg: '',
  307. });
  308. return;
  309. }
  310. if (type === 'cvc') {
  311. this.updateSnapshot({
  312. cvcErrorMsg: '',
  313. });
  314. return;
  315. }
  316. if (type === 'expiry') {
  317. this.updateSnapshot({
  318. expiryErrorMsg: '',
  319. });
  320. return;
  321. }
  322. }
  323. handleBlur(event: any) {
  324. const {
  325. type,
  326. error,
  327. } = event.detail;
  328. const message = error?.message ?? JSON.stringify(error);
  329. if (type === 'cardNumber') {
  330. this.updateSnapshot({
  331. cardNumberErrorMsg: message,
  332. });
  333. return;
  334. }
  335. if (type === 'cvc') {
  336. this.updateSnapshot({
  337. cvcErrorMsg: message,
  338. });
  339. return;
  340. }
  341. if (type === 'expiry') {
  342. this.updateSnapshot({
  343. expiryErrorMsg: message,
  344. });
  345. return;
  346. }
  347. }
  348. // ========================================================
  349. // Validation
  350. // ========================================================
  351. validateCard(): {valid: boolean; msg: string} {
  352. const state = this.snapshot;
  353. if (!state.cardNumberReady) {
  354. return {
  355. valid: false,
  356. msg: 'Card number input has not ready. Please wait a minute.'
  357. };
  358. }
  359. if (!state.expiryReady) {
  360. return {
  361. valid: false,
  362. msg: 'Card expiry date input has not ready. Please wait a minute.'
  363. };
  364. }
  365. if (!state.cvcReady) {
  366. return {
  367. valid: false,
  368. msg: 'Card cvc input has not ready. Please wait a minute.'
  369. };
  370. }
  371. if (!state.cardNumberValid) {
  372. return {
  373. valid: false,
  374. msg: state.cardNumberErrorMsg || 'Please input your card number.'
  375. };
  376. }
  377. if (!state.expiryValid) {
  378. return {
  379. valid: false,
  380. msg: state.expiryErrorMsg || 'Please input expiry date.'
  381. };
  382. }
  383. if (!state.cvcValid) {
  384. return {
  385. valid: false,
  386. msg: state.cvcErrorMsg || 'Please input cvc.'
  387. };
  388. }
  389. return {
  390. valid: true,
  391. msg: ''
  392. };
  393. }
  394. destroy() {
  395. const { cardNumber, cvc, expiry } = this.elements;
  396. [cardNumber, cvc, expiry].forEach((el) => {
  397. if (!el) return;
  398. el.unmount();
  399. el.destroy();
  400. });
  401. this.elements = {};
  402. this.createPromise=null;
  403. this.resetSnapshot();
  404. }
  405. getElements() {
  406. return this.elements;
  407. }
  408. getCardNumberElement() {
  409. return this.elements.cardNumber;
  410. }
  411. }
  412. export const airwallexManager = new AirwallexManager();