tf-checkout-react 1.7.31 → 1.7.32-beta.2
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/dist/adapters/customFields.d.ts +1 -0
- package/dist/components/billing-info-container/index.d.ts +2 -0
- package/dist/tf-checkout-react.cjs.development.js +178 -50
- package/dist/tf-checkout-react.cjs.development.js.map +1 -1
- package/dist/tf-checkout-react.cjs.production.min.js +1 -1
- package/dist/tf-checkout-react.cjs.production.min.js.map +1 -1
- package/dist/tf-checkout-react.esm.js +178 -50
- package/dist/tf-checkout-react.esm.js.map +1 -1
- package/dist/types/billing-info-data.d.ts +4 -0
- package/dist/utils/customFields.d.ts +20 -0
- package/dist/utils/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/adapters/customFields.test.ts +69 -0
- package/src/adapters/customFields.ts +10 -0
- package/src/components/billing-info-container/hooks/useStripePayment.ts +63 -28
- package/src/components/billing-info-container/index.tsx +79 -35
- package/src/hoc/CustomFields/index.tsx +17 -2
- package/src/types/billing-info-data.ts +4 -0
- package/src/utils/customFields.test.ts +41 -0
- package/src/utils/customFields.ts +39 -0
- package/src/utils/index.ts +6 -1
|
@@ -8,6 +8,8 @@ export interface IGroupItem {
|
|
|
8
8
|
component?: ReactNode | JSX.Element | HTMLElement;
|
|
9
9
|
onValidate?: (value: any) => void;
|
|
10
10
|
uniqueId?: string;
|
|
11
|
+
/** Ticket custom fields only: ticket types this field is scoped to. */
|
|
12
|
+
ticketTypeIds?: string[] | null;
|
|
11
13
|
[key: string]: any;
|
|
12
14
|
}
|
|
13
15
|
export interface IFieldData {
|
|
@@ -17,6 +19,8 @@ export interface IFieldData {
|
|
|
17
19
|
groupLabelClassName?: string;
|
|
18
20
|
id?: number;
|
|
19
21
|
uniqueId?: string;
|
|
22
|
+
/** Set on the ticket-holder group built from event custom fields. */
|
|
23
|
+
customFields?: boolean;
|
|
20
24
|
}
|
|
21
25
|
export interface IBillingInfoData {
|
|
22
26
|
fields: IFieldData[];
|
|
@@ -33,3 +33,23 @@ export declare const getAddOnDataWithCustomFields: (customFields: any) => {
|
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
export declare const getFieldsKeys: (customFields: any) => any[];
|
|
36
|
+
/**
|
|
37
|
+
* Ticket type of each holder slot, in holder order, expanded from the checkout
|
|
38
|
+
* breakdown (`tickets_price_breakdown`: one line per ticket type with a quantity).
|
|
39
|
+
* Returns [] when the breakdown doesn't line up with the holder count — callers
|
|
40
|
+
* then treat every holder's ticket type as unknown and show all fields.
|
|
41
|
+
*/
|
|
42
|
+
interface ITicketsPriceBreakdownLine {
|
|
43
|
+
ticket_type_id?: string | number | null;
|
|
44
|
+
quantity?: string | number;
|
|
45
|
+
}
|
|
46
|
+
export declare const getHolderTicketTypeIds: (ticketsPriceBreakdown: ITicketsPriceBreakdownLine[] | undefined, holdersCount: number) => string[];
|
|
47
|
+
/**
|
|
48
|
+
* Whether a ticket-holder custom field applies to a ticket of `ticketTypeId`.
|
|
49
|
+
* Unscoped fields apply to every ticket; an unknown ticket type fails open so a
|
|
50
|
+
* required field is never silently dropped.
|
|
51
|
+
*/
|
|
52
|
+
export declare const customFieldAppliesToTicketType: (field: {
|
|
53
|
+
ticketTypeIds?: string[] | null;
|
|
54
|
+
}, ticketTypeId?: string | undefined) => boolean;
|
|
55
|
+
export {};
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -10,6 +10,6 @@ export { replaceVarInString } from './replaceVarInString';
|
|
|
10
10
|
export { isBrowser } from './isBrowser';
|
|
11
11
|
export { getFormInitialValues } from './form';
|
|
12
12
|
export { setLoggedUserData, logoutUser } from './auth';
|
|
13
|
-
export { getDataWithCustomFields, getFieldsKeys } from './customFields';
|
|
13
|
+
export { customFieldAppliesToTicketType, getDataWithCustomFields, getFieldsKeys, getHolderTicketTypeIds, } from './customFields';
|
|
14
14
|
export { createElementFromHTML } from './htmlNodeFromString';
|
|
15
15
|
export { isJson } from './jsonUtils';
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { customFieldsDataAdapter } from './customFields'
|
|
2
|
+
|
|
3
|
+
// `data.attributes` of GET v1/event/:id/custom_fields for an event whose only
|
|
4
|
+
// ticket field is scoped to two ticket types.
|
|
5
|
+
const scopedTicketFieldPayload = [
|
|
6
|
+
{
|
|
7
|
+
ticket: {
|
|
8
|
+
group: {
|
|
9
|
+
id: '2242',
|
|
10
|
+
label: null,
|
|
11
|
+
image: '',
|
|
12
|
+
type: 'ticket',
|
|
13
|
+
order: '0',
|
|
14
|
+
fields: [
|
|
15
|
+
{
|
|
16
|
+
id: '6766',
|
|
17
|
+
image: '',
|
|
18
|
+
type: 'select',
|
|
19
|
+
name: 'shoe_size',
|
|
20
|
+
order: '1',
|
|
21
|
+
label: 'Cycling Shoe Size',
|
|
22
|
+
defaultValue: '',
|
|
23
|
+
required: true,
|
|
24
|
+
description: null,
|
|
25
|
+
settings: {
|
|
26
|
+
showOnMyTickets: true,
|
|
27
|
+
showOnPdf: false,
|
|
28
|
+
showOnScan: false,
|
|
29
|
+
allowUpdate: false,
|
|
30
|
+
ticketTypes: [225685, 225687],
|
|
31
|
+
},
|
|
32
|
+
options: [
|
|
33
|
+
{ id: '9650', name: '34', value: '34' },
|
|
34
|
+
{ id: '9651', name: '36', value: '36' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
describe('customFieldsDataAdapter', () => {
|
|
44
|
+
it('keeps the ticket types a ticket field is scoped to', () => {
|
|
45
|
+
const { ticketsFields } = customFieldsDataAdapter(scopedTicketFieldPayload)
|
|
46
|
+
|
|
47
|
+
expect(ticketsFields).toHaveLength(1)
|
|
48
|
+
expect(ticketsFields[0]).toMatchObject({
|
|
49
|
+
name: 'shoe_size',
|
|
50
|
+
required: true,
|
|
51
|
+
ticketTypeIds: ['225685', '225687'],
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('leaves unscoped fields without ticketTypeIds', () => {
|
|
56
|
+
const payload = [
|
|
57
|
+
{
|
|
58
|
+
ticket: {
|
|
59
|
+
group: {
|
|
60
|
+
fields: [{ name: 'nickname', type: 'text', settings: { ticketTypes: [] } }],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
const { ticketsFields } = customFieldsDataAdapter(payload)
|
|
66
|
+
|
|
67
|
+
expect(ticketsFields[0]).not.toHaveProperty('ticketTypeIds')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -13,6 +13,11 @@ export const optionsAdapter = (options: any) => {
|
|
|
13
13
|
return adaptedOptions
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// `settings.ticketTypes` scopes a ticket field to those ticket types; absent or
|
|
17
|
+
// empty means it applies to every ticket. Ids arrive as numbers, compare as strings.
|
|
18
|
+
export const ticketTypeIdsAdapter = (ticketTypes: unknown): string[] | null =>
|
|
19
|
+
Array.isArray(ticketTypes) && ticketTypes.length ? ticketTypes.map(String) : null
|
|
20
|
+
|
|
16
21
|
export const fieldDataAdapter = (field: IGroupItem) => {
|
|
17
22
|
const {
|
|
18
23
|
name,
|
|
@@ -34,6 +39,11 @@ export const fieldDataAdapter = (field: IGroupItem) => {
|
|
|
34
39
|
value: _get(restValues, 'defaultValue'),
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
const ticketTypeIds = ticketTypeIdsAdapter(_get(restValues, 'settings.ticketTypes'))
|
|
43
|
+
if (ticketTypeIds) {
|
|
44
|
+
adaptedField.ticketTypeIds = ticketTypeIds
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
switch (type) {
|
|
38
48
|
case 'textarea':
|
|
39
49
|
adaptedField.type = 'text'
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { FormikHelpers } from 'formik'
|
|
2
|
+
import _get from 'lodash/get'
|
|
2
3
|
import _identity from 'lodash/identity'
|
|
3
4
|
|
|
4
5
|
import { handlePaymentMiddleWare } from '../../paymentContainer/handlePayment'
|
|
5
6
|
|
|
7
|
+
const SUCCESSFUL_INTENT_STATUSES = ['succeeded', 'processing', 'requires_capture']
|
|
8
|
+
const PAYMENT_FAILED_MESSAGE =
|
|
9
|
+
'Your payment was not completed. Please try again or use another payment method.'
|
|
10
|
+
const PAYMENT_RECEIVED_MESSAGE =
|
|
11
|
+
'Your payment was received and we are confirming your order. ' +
|
|
12
|
+
'Please check your email for your tickets before trying again.'
|
|
13
|
+
|
|
6
14
|
interface PaymentData {
|
|
7
15
|
attributes: any
|
|
8
16
|
isFreeTickets: boolean
|
|
@@ -74,43 +82,70 @@ export const useStripePayment = ({
|
|
|
74
82
|
billing_details: { phone: e164Phone },
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
let confirmResult
|
|
86
|
+
try {
|
|
87
|
+
confirmResult = await stripeRef.current.confirmPayment({
|
|
88
|
+
clientSecret:
|
|
89
|
+
paymentDataResponse.data.attributes.payment_method.stripe_client_secret,
|
|
90
|
+
elements: elementsRef.current,
|
|
91
|
+
confirmParams,
|
|
92
|
+
redirect: 'if_required',
|
|
93
|
+
})
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Stripe.js throws (instead of returning { error }) on integration errors
|
|
96
|
+
setError(_get(e, 'message') || PAYMENT_FAILED_MESSAGE)
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
84
99
|
|
|
100
|
+
const { error: confirmError, paymentIntent } = confirmResult
|
|
85
101
|
if (confirmError) {
|
|
86
102
|
setError('' + confirmError?.message)
|
|
87
103
|
return null
|
|
88
104
|
}
|
|
105
|
+
|
|
106
|
+
// Asynchronous failures (e.g. MB WAY declined, expired or number not registered)
|
|
107
|
+
// resolve without `error`, so the PaymentIntent status must be checked
|
|
108
|
+
if (paymentIntent && !SUCCESSFUL_INTENT_STATUSES.includes(paymentIntent.status)) {
|
|
109
|
+
setError(
|
|
110
|
+
_get(paymentIntent, 'last_payment_error.message') || PAYMENT_FAILED_MESSAGE
|
|
111
|
+
)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
// Handle payment middleware for non-redirect payments
|
|
92
117
|
let paymentResponse = null
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
try {
|
|
119
|
+
await handlePaymentMiddleWare(
|
|
120
|
+
null,
|
|
121
|
+
{},
|
|
122
|
+
{
|
|
123
|
+
reviewData: attributes,
|
|
124
|
+
isFreeTickets,
|
|
125
|
+
paymentPlanIsAvailable: false,
|
|
126
|
+
showPaymentPlanSection: false,
|
|
127
|
+
handlePayment: (data: any) => {
|
|
128
|
+
paymentResponse = data
|
|
129
|
+
},
|
|
130
|
+
setPaymentIsLoading: _identity,
|
|
131
|
+
setError: (message: string | null) => {
|
|
132
|
+
if (message && isFreeTickets) setError(message)
|
|
133
|
+
},
|
|
134
|
+
orderData: updatedOrderData,
|
|
135
|
+
eventId,
|
|
136
|
+
isBrowser,
|
|
137
|
+
onPaymentError: (error: any) => {
|
|
138
|
+
throw error
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (isFreeTickets) throw e
|
|
144
|
+
// Stripe has already taken the payment and the webhook will still settle the order,
|
|
145
|
+
// so never invite a retry (double charge) whatever the backend error looks like
|
|
146
|
+
setError(PAYMENT_RECEIVED_MESSAGE)
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
114
149
|
|
|
115
150
|
return paymentResponse
|
|
116
151
|
}
|
|
@@ -16,8 +16,9 @@ import _identity from 'lodash/identity'
|
|
|
16
16
|
import _isEmpty from 'lodash/isEmpty'
|
|
17
17
|
import _isEqual from 'lodash/isEqual'
|
|
18
18
|
import _map from 'lodash/map'
|
|
19
|
+
import _omit from 'lodash/omit'
|
|
19
20
|
import { nanoid } from 'nanoid'
|
|
20
|
-
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
|
|
21
|
+
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
21
22
|
|
|
22
23
|
import {
|
|
23
24
|
AttributesConfig,
|
|
@@ -34,12 +35,14 @@ import {
|
|
|
34
35
|
import { updateCheckout } from '../../api/checkout'
|
|
35
36
|
import { withCustomFields } from '../../hoc'
|
|
36
37
|
import { usePixel } from '../../hooks/usePixel'
|
|
37
|
-
import { IBillingInfoData } from '../../types'
|
|
38
|
+
import { IBillingInfoData, IGroupItem } from '../../types'
|
|
38
39
|
import { ICheckoutResponseData } from '../../types/api/checkout'
|
|
39
40
|
import {
|
|
40
41
|
createCheckoutDataBodyWithDefaultHolder,
|
|
42
|
+
customFieldAppliesToTicketType,
|
|
41
43
|
deleteCookieByName,
|
|
42
44
|
getCookieByName,
|
|
45
|
+
getHolderTicketTypeIds,
|
|
43
46
|
isBrowser,
|
|
44
47
|
setLoggedUserData,
|
|
45
48
|
} from '../../utils'
|
|
@@ -124,6 +127,8 @@ export interface IBillingInfoPage {
|
|
|
124
127
|
isCountryCodeEditable?: boolean;
|
|
125
128
|
customFieldsOrderKeys?: string[];
|
|
126
129
|
customFieldsTicketHolderKeys?: string[];
|
|
130
|
+
/** Event to load custom fields for. Defaults to `?event_id`, then the cart's event. */
|
|
131
|
+
customFieldsEventId?: string | number;
|
|
127
132
|
onPendingVerification?: () => void;
|
|
128
133
|
includeAddons?: boolean;
|
|
129
134
|
addonsProps?: IAddonContainterProps;
|
|
@@ -506,6 +511,17 @@ const BillingInfoContainer = React.memo(
|
|
|
506
511
|
useState<ICheckoutResponseData | null>(null)
|
|
507
512
|
const prevData = useRef(data)
|
|
508
513
|
|
|
514
|
+
// Ticket type of each holder slot — scopes ticket custom fields that list
|
|
515
|
+
// `ticketTypes` in their settings to the holders of those ticket types.
|
|
516
|
+
const holderTicketTypeIds = useMemo(
|
|
517
|
+
() =>
|
|
518
|
+
getHolderTicketTypeIds(
|
|
519
|
+
_get(checkoutUpdateData, 'cart_price_breakdown.tickets_price_breakdown'),
|
|
520
|
+
ticketsQuantity.length
|
|
521
|
+
),
|
|
522
|
+
[checkoutUpdateData, ticketsQuantity.length]
|
|
523
|
+
)
|
|
524
|
+
|
|
509
525
|
const addAddOnsInAttributes = useCallback((checkoutBody: any) => {
|
|
510
526
|
const selectedAddOns = window.localStorage.getItem('add_ons') || '{}'
|
|
511
527
|
const addOnDataCapture = window.localStorage.getItem('add_on_data_capture') || '{}'
|
|
@@ -756,13 +772,21 @@ const BillingInfoContainer = React.memo(
|
|
|
756
772
|
})
|
|
757
773
|
|
|
758
774
|
// Collect data_capture for Ticket Holders Fields
|
|
775
|
+
const ticketCustomGroup = _find(ticketHoldersFields.fields, 'customFields')
|
|
776
|
+
const ticketCustomFields: any[] = ticketCustomGroup?.groupItems || []
|
|
759
777
|
_forEach(checkoutBody.attributes.ticket_holders, (holder, holderIndex) => {
|
|
760
778
|
const holderDataCapture: Record<string, any> = {}
|
|
761
779
|
|
|
762
780
|
_forEach(customFieldsTicketHolderKeys, customFieldKey => {
|
|
763
781
|
const customFieldHolderName = `${customFieldKey}-${holderIndex}`
|
|
764
|
-
const
|
|
765
|
-
|
|
782
|
+
const field = _find(ticketCustomFields, ({ name }) => name === customFieldKey)
|
|
783
|
+
// Fields scoped to other ticket types were never shown to this holder.
|
|
784
|
+
if (
|
|
785
|
+
!field ||
|
|
786
|
+
customFieldAppliesToTicketType(field, holderTicketTypeIds[+holderIndex])
|
|
787
|
+
) {
|
|
788
|
+
holderDataCapture[customFieldKey] = values[customFieldHolderName] || ''
|
|
789
|
+
}
|
|
766
790
|
|
|
767
791
|
// Delete holder specific value from values list
|
|
768
792
|
delete checkoutBody.attributes[customFieldHolderName]
|
|
@@ -833,6 +857,8 @@ const BillingInfoContainer = React.memo(
|
|
|
833
857
|
lastNameLogged,
|
|
834
858
|
phoneLogged,
|
|
835
859
|
customFieldsTicketHolderKeys,
|
|
860
|
+
ticketHoldersFields,
|
|
861
|
+
holderTicketTypeIds,
|
|
836
862
|
]
|
|
837
863
|
)
|
|
838
864
|
|
|
@@ -1512,40 +1538,58 @@ const BillingInfoContainer = React.memo(
|
|
|
1512
1538
|
<div key={_item}>
|
|
1513
1539
|
<h5>Ticket {index + 1}</h5>
|
|
1514
1540
|
{_map(ticketHoldersFields.fields, group => {
|
|
1515
|
-
const { id, groupClassname
|
|
1541
|
+
const { id, groupClassname } = group
|
|
1542
|
+
const groupItems = group.customFields
|
|
1543
|
+
? group.groupItems.filter(item =>
|
|
1544
|
+
customFieldAppliesToTicketType(
|
|
1545
|
+
item,
|
|
1546
|
+
holderTicketTypeIds[index]
|
|
1547
|
+
)
|
|
1548
|
+
)
|
|
1549
|
+
: group.groupItems
|
|
1550
|
+
if (_isEmpty(groupItems)) return null
|
|
1516
1551
|
return (
|
|
1517
1552
|
<div key={id}>
|
|
1518
1553
|
<div className={groupClassname}>
|
|
1519
|
-
{_map(groupItems,
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1554
|
+
{_map(groupItems, item => {
|
|
1555
|
+
// Scoping metadata, not a Field prop.
|
|
1556
|
+
const element = _omit(item, 'ticketTypeIds') as IGroupItem
|
|
1557
|
+
return (
|
|
1558
|
+
<div
|
|
1559
|
+
className={element.className}
|
|
1560
|
+
key={element.name}
|
|
1561
|
+
>
|
|
1562
|
+
<Field
|
|
1563
|
+
{...element}
|
|
1564
|
+
type={
|
|
1565
|
+
element.type === 'radio' ||
|
|
1566
|
+
element.type === 'checkbox'
|
|
1567
|
+
? undefined
|
|
1568
|
+
: element.type
|
|
1569
|
+
}
|
|
1570
|
+
name={`${element.name}-${index}`}
|
|
1571
|
+
label={getFieldLabel(element, configs)}
|
|
1572
|
+
component={getFieldComponent(element)}
|
|
1573
|
+
validate={getValidateFunctions(
|
|
1574
|
+
element,
|
|
1575
|
+
states,
|
|
1576
|
+
props.values,
|
|
1577
|
+
props.errors
|
|
1578
|
+
)}
|
|
1579
|
+
setPhoneValidationIsLoading={
|
|
1580
|
+
setPhoneValidationIsLoading
|
|
1581
|
+
}
|
|
1582
|
+
fill={
|
|
1583
|
+
element.type === 'phone' ? true : undefined
|
|
1584
|
+
}
|
|
1585
|
+
defaultCountry={
|
|
1586
|
+
defaultCountry || element.defaultCountry
|
|
1587
|
+
}
|
|
1588
|
+
isCountryCodeEditable={isCountryCodeEditable}
|
|
1589
|
+
/>
|
|
1590
|
+
</div>
|
|
1591
|
+
)
|
|
1592
|
+
})}
|
|
1549
1593
|
</div>
|
|
1550
1594
|
</div>
|
|
1551
1595
|
)
|
|
@@ -3,7 +3,7 @@ import _get from 'lodash/get'
|
|
|
3
3
|
import _identity from 'lodash/identity'
|
|
4
4
|
import React, { useEffect, useState } from 'react'
|
|
5
5
|
|
|
6
|
-
import { getCustomFields } from '../../api'
|
|
6
|
+
import { getCheckoutPageConfigs, getCustomFields } from '../../api'
|
|
7
7
|
import {
|
|
8
8
|
getDataWithCustomFields,
|
|
9
9
|
getFieldsKeys,
|
|
@@ -19,6 +19,7 @@ const withCustomFields = (Children: any) => (props: any) => {
|
|
|
19
19
|
fields: [],
|
|
20
20
|
},
|
|
21
21
|
skipCustomFields = true,
|
|
22
|
+
customFieldsEventId: eventIdProp,
|
|
22
23
|
onGetCustomFieldsError = _identity,
|
|
23
24
|
onGetCustomFieldsSuccess = _identity,
|
|
24
25
|
...args
|
|
@@ -30,9 +31,23 @@ const withCustomFields = (Children: any) => (props: any) => {
|
|
|
30
31
|
const [customFieldsTicketHolderKeys, setCustomFieldsTicketHolderKeys] = useState([] as any)
|
|
31
32
|
const [customFieldsAddOnKeys, setCustomFieldsAddOnKeys] = useState([] as any)
|
|
32
33
|
|
|
34
|
+
// The billing page URL often carries no event id (path routes, single-page
|
|
35
|
+
// checkouts), so fall back to the event the cart session belongs to. Without
|
|
36
|
+
// any id, the request would hit `/v1/event//custom_fields` and fail.
|
|
37
|
+
const resolveEventId = async (): Promise<string> => {
|
|
38
|
+
const fromUrl = getQueryVariable('event_id') || getQueryVariable('eventId')
|
|
39
|
+
if (eventIdProp || fromUrl) return String(eventIdProp || fromUrl)
|
|
40
|
+
|
|
41
|
+
const configs = await getCheckoutPageConfigs()
|
|
42
|
+
const fromCart = _get(configs, 'data.attributes.event_id')
|
|
43
|
+
return fromCart ? String(fromCart) : ''
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
const getFields = async () => {
|
|
34
47
|
try {
|
|
35
|
-
const eventId =
|
|
48
|
+
const eventId = await resolveEventId()
|
|
49
|
+
if (!eventId) return
|
|
50
|
+
|
|
36
51
|
const customFields = await getCustomFields(eventId)
|
|
37
52
|
|
|
38
53
|
const orderFieldsKeys = getFieldsKeys(_get(customFields, 'orderFields', []))
|
|
@@ -11,6 +11,8 @@ export interface IGroupItem {
|
|
|
11
11
|
component?: ReactNode | JSX.Element | HTMLElement;
|
|
12
12
|
onValidate?: (value: any) => void;
|
|
13
13
|
uniqueId?: string;
|
|
14
|
+
/** Ticket custom fields only: ticket types this field is scoped to. */
|
|
15
|
+
ticketTypeIds?: string[] | null;
|
|
14
16
|
|
|
15
17
|
// aditional props
|
|
16
18
|
[key: string]: any;
|
|
@@ -25,6 +27,8 @@ export interface IFieldData {
|
|
|
25
27
|
groupLabelClassName?: string;
|
|
26
28
|
id?: number;
|
|
27
29
|
uniqueId?: string;
|
|
30
|
+
/** Set on the ticket-holder group built from event custom fields. */
|
|
31
|
+
customFields?: boolean;
|
|
28
32
|
}
|
|
29
33
|
export interface IBillingInfoData {
|
|
30
34
|
fields: IFieldData[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { customFieldAppliesToTicketType, getHolderTicketTypeIds } from './customFields'
|
|
2
|
+
|
|
3
|
+
describe('getHolderTicketTypeIds', () => {
|
|
4
|
+
it('expands the breakdown into one ticket type per holder, in order', () => {
|
|
5
|
+
const breakdown = [
|
|
6
|
+
{ ticket_type_id: 225685, quantity: 2 },
|
|
7
|
+
{ ticket_type_id: 225690, quantity: 1 },
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
expect(getHolderTicketTypeIds(breakdown, 3)).toEqual(['225685', '225685', '225690'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns [] when the breakdown does not match the holder count', () => {
|
|
14
|
+
expect(getHolderTicketTypeIds([{ ticket_type_id: 1, quantity: 2 }], 3)).toEqual([])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns [] without a breakdown', () => {
|
|
18
|
+
expect(getHolderTicketTypeIds(undefined, 2)).toEqual([])
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('customFieldAppliesToTicketType', () => {
|
|
23
|
+
const shoeSize = { ticketTypeIds: ['225685', '225687'] }
|
|
24
|
+
|
|
25
|
+
it('shows a scoped field to holders of a listed ticket type', () => {
|
|
26
|
+
expect(customFieldAppliesToTicketType(shoeSize, '225687')).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('hides a scoped field from holders of other ticket types', () => {
|
|
30
|
+
expect(customFieldAppliesToTicketType(shoeSize, '225690')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('shows unscoped fields to every holder', () => {
|
|
34
|
+
expect(customFieldAppliesToTicketType({}, '225690')).toBe(true)
|
|
35
|
+
expect(customFieldAppliesToTicketType({ ticketTypeIds: null }, '225690')).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('fails open when the holder ticket type is unknown', () => {
|
|
39
|
+
expect(customFieldAppliesToTicketType(shoeSize, undefined)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import _forEach from 'lodash/forEach'
|
|
1
2
|
import _isEmpty from 'lodash/isEmpty'
|
|
2
3
|
import _map from 'lodash/map'
|
|
3
4
|
|
|
@@ -78,3 +79,41 @@ export const getAddOnDataWithCustomFields = (customFields: any) => {
|
|
|
78
79
|
|
|
79
80
|
export const getFieldsKeys = (customFields: any) =>
|
|
80
81
|
_map(customFields, field => field.name)
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ticket type of each holder slot, in holder order, expanded from the checkout
|
|
85
|
+
* breakdown (`tickets_price_breakdown`: one line per ticket type with a quantity).
|
|
86
|
+
* Returns [] when the breakdown doesn't line up with the holder count — callers
|
|
87
|
+
* then treat every holder's ticket type as unknown and show all fields.
|
|
88
|
+
*/
|
|
89
|
+
interface ITicketsPriceBreakdownLine {
|
|
90
|
+
ticket_type_id?: string | number | null;
|
|
91
|
+
quantity?: string | number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const getHolderTicketTypeIds = (
|
|
95
|
+
ticketsPriceBreakdown: ITicketsPriceBreakdownLine[] | undefined,
|
|
96
|
+
holdersCount: number
|
|
97
|
+
): string[] => {
|
|
98
|
+
if (!Array.isArray(ticketsPriceBreakdown)) return []
|
|
99
|
+
const slots: string[] = []
|
|
100
|
+
_forEach(ticketsPriceBreakdown, line => {
|
|
101
|
+
for (let i = 0; i < Number(line?.quantity || 0); i++) {
|
|
102
|
+
slots.push(String(line?.ticket_type_id ?? ''))
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
return slots.length === holdersCount ? slots : []
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a ticket-holder custom field applies to a ticket of `ticketTypeId`.
|
|
110
|
+
* Unscoped fields apply to every ticket; an unknown ticket type fails open so a
|
|
111
|
+
* required field is never silently dropped.
|
|
112
|
+
*/
|
|
113
|
+
export const customFieldAppliesToTicketType = (
|
|
114
|
+
field: { ticketTypeIds?: string[] | null },
|
|
115
|
+
ticketTypeId?: string
|
|
116
|
+
) =>
|
|
117
|
+
!ticketTypeId ||
|
|
118
|
+
_isEmpty(field.ticketTypeIds) ||
|
|
119
|
+
(field.ticketTypeIds || []).includes(String(ticketTypeId))
|
package/src/utils/index.ts
CHANGED
|
@@ -10,6 +10,11 @@ export { replaceVarInString } from './replaceVarInString'
|
|
|
10
10
|
export { isBrowser } from './isBrowser'
|
|
11
11
|
export { getFormInitialValues } from './form'
|
|
12
12
|
export { setLoggedUserData, logoutUser } from './auth'
|
|
13
|
-
export {
|
|
13
|
+
export {
|
|
14
|
+
customFieldAppliesToTicketType,
|
|
15
|
+
getDataWithCustomFields,
|
|
16
|
+
getFieldsKeys,
|
|
17
|
+
getHolderTicketTypeIds,
|
|
18
|
+
} from './customFields'
|
|
14
19
|
export { createElementFromHTML } from './htmlNodeFromString'
|
|
15
20
|
export { isJson } from './jsonUtils'
|