All files / src/sections/PayBuilder/Checkout customFields.helpers.ts

100% Statements 46/46
74.28% Branches 26/35
100% Functions 19/19
100% Lines 44/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232                                        488x 488x 488x 488x     488x 488x   488x     488x   488x   488x             488x       12x                                 488x     488x 55x     488x                   488x             488x     55x 55x 2x   55x       4x                                                           488x     4x 4x 4x           6x                         488x     118x                     488x     385x 2x 2x                                 488x       3x 4x 4x 3x                             488x           5x                 488x     5x 6x  
import { ICheckout, ICheckoutCustomField } from "../provider/provider.type";
 
/**
 * PAY-Builder016 — Checkout-tab Custom Fields.
 *
 * The BE contract (feature/pay-builder-016-custom-fields) stores custom fields
 * on the checkout form as:
 *   - customFieldsEnabled: boolean
 *   - customFieldsSectionTitle: string
 *   - customFields: [{ id, label, isRequired, displayOrder }]  (read)
 *   - customFields: [{ id?, label, isRequired }]               (create/update — order = array position)
 *
 * The update reconciles by row id (upsert-by-id): a field that carries its `id`
 * updates in place, a field without one is inserted, and any absent field is
 * deleted — so the row id stays stable across saves (create ignores the id).
 * The FE keeps a stable *client* id (`fieldId`) for React keys / drag reordering
 * / preview answer keys, and the BE id (`serverId`) once hydrated from a read;
 * `serverId` is sent back on update so the BE can keep it (GB-21559).
 */
 
export const DEFAULT_CUSTOM_FIELDS_SECTION_TITLE = "Additional Information";
export const MAX_CUSTOM_FIELDS = 30;
export const MAX_CUSTOM_FIELD_LABEL_LEN = 300;
export const MAX_CUSTOM_FIELD_SECTION_TITLE_LEN = 300;
 
export const MAX_CUSTOM_FIELDS_TOOLTIP =
  "You've reached the maximum of 30 custom fields.";
export const CUSTOM_FIELD_LABEL_REQUIRED = "Field can't be empty.";
/** Customer-facing required error (AC021). */
export const CUSTOM_FIELD_REQUIRED_MESSAGE = "This field is required.";
 
// Publish-edit warning (AC017).
export const CUSTOM_FIELDS_EDIT_WARNING_TITLE = "Modifying Custom Fields";
export const CUSTOM_FIELDS_EDIT_WARNING_BODY =
  "Changing field labels, order, or deleting fields will create a new version of this form. Forms submitted previously will remain unchanged. Do you want to continue?";
export const CUSTOM_FIELDS_WARNING_SUPPRESS_KEY =
  "pb016_hide_custom_fields_edit_warning";
 
/**
 * Whether to warn before a structural custom-field edit (AC017). Only on a
 * published form, and only if the merchant hasn't dismissed it this session or
 * ticked "don't show again".
 */
export const shouldWarnOnCustomFieldsEdit = (
  isPublished: boolean,
  suppressed: boolean,
  shownThisSession: boolean,
): boolean => isPublished && !suppressed && !shownThisSession;
 
export interface CustomFieldConfig {
  /** Stable client id — React key, dnd-kit sortable id, preview answer key. */
  fieldId: string;
  /** BE id, present only once hydrated from a read. */
  serverId?: number;
  label: string;
  isRequired: boolean;
}
 
export interface CheckoutCustomFields {
  enabled: boolean;
  sectionTitle: string;
  fields: CustomFieldConfig[];
}
 
let clientIdCounter = 0;
 
/** Unique, session-stable client id for a newly-added custom field. */
export const nextCustomFieldClientId = (): string =>
  `cf-new-${++clientIdCounter}`;
 
/** Initial (toggle-off) custom-fields config for a fresh form. */
export const getInitialCustomFields = (): CheckoutCustomFields => ({
  enabled: false,
  sectionTitle: DEFAULT_CUSTOM_FIELDS_SECTION_TITLE,
  fields: [],
});
 
/**
 * A new blank custom field. `position` is 1-based so the default label reads
 * "Field 1" for the first field (AC004).
 */
export const createCustomField = (position: number): CustomFieldConfig => ({
  fieldId: nextCustomFieldClientId(),
  label: `Field ${position}`,
  isRequired: false,
});
 
/** Hydrate the builder form's custom-fields config from a read view. */
export const hydrateCheckoutCustomFields = (
  form?: ICheckout | null,
): CheckoutCustomFields => {
  const rawFields = form?.customFields ?? [];
  const sorted = [...rawFields].sort(
    (a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0),
  );
  return {
    enabled: Boolean(form?.customFieldsEnabled),
    sectionTitle:
      form?.customFieldsSectionTitle || DEFAULT_CUSTOM_FIELDS_SECTION_TITLE,
    fields: sorted.map((f) => ({
      fieldId: `cf-${f.id}`,
      serverId: f.id,
      label: f.label ?? "",
      isRequired: Boolean(f.isRequired),
    })),
  };
};
 
export interface SerializedCustomFields {
  customFieldsEnabled: boolean;
  customFieldsSectionTitle: string;
  customFields: ICheckoutCustomField[];
}
 
/**
 * Serialize the builder form's custom-fields config into the checkout-form
 * write payload. Order is derived from array position. When the toggle is off,
 * an empty array is sent so the BE clears prior fields.
 *
 * A hydrated field sends its server `id` so the BE update handler reconciles by
 * that id (upsert-by-id) and KEEPS the row id stable across saves. That id is
 * the only identity a custom field has — it is exactly what the transaction
 * snapshot stores as `fieldId` and what the customer-panel aggregation
 * (gs_get_customer_custom_field_answers) groups on. Dropping it made every save
 * re-mint ids, so a customer's earlier and later purchases carried different
 * ids for the same field and the "Additional Information" panel showed a new
 * row per save instead of the latest value overwriting (GB-21559). A newly-added
 * field has no server id yet and is inserted; the create endpoint ignores the id.
 */
export const serializeCheckoutCustomFields = (
  customFields?: CheckoutCustomFields,
): SerializedCustomFields => {
  const enabled = Boolean(customFields?.enabled);
  const fields = customFields?.fields ?? [];
  return {
    customFieldsEnabled: enabled,
    customFieldsSectionTitle: (
      customFields?.sectionTitle || DEFAULT_CUSTOM_FIELDS_SECTION_TITLE
    ).trim(),
    customFields: enabled
      ? fields.map((f) => ({
          ...(typeof f.serverId === "number" ? { id: f.serverId } : {}),
          label: f.label.trim(),
          isRequired: Boolean(f.isRequired),
        }))
      : [],
  };
};
 
/**
 * The rendered custom fields for the customer form / live preview: only when
 * the toggle is on and at least one field exists (AC003).
 */
export const getRenderableCustomFields = (
  customFields?: CheckoutCustomFields,
): CustomFieldConfig[] =>
  customFields?.enabled ? customFields.fields ?? [] : [];
 
/** One custom-field answer sent on POST /cart/checkout (AC023). */
export interface CustomFieldAnswerPayload {
  fieldId: number;
  label: string;
  order: number;
  value: string;
}
 
/** Blank answer-form defaults keyed by each field's client id. */
export const buildCustomFieldsAnswerDefaults = (
  fields: CustomFieldConfig[],
): Record<string, string> =>
  fields.reduce<Record<string, string>>((acc, f) => {
    acc[f.fieldId] = "";
    return acc;
  }, {});
 
/**
 * Map the rendered field config + the customer's typed answers into the
 * checkout payload. Order = configured (display) order; label/value are
 * snapshotted verbatim so the transaction preserves what the customer saw
 * (AC019). Returns [] when the feature is off / no fields (AC003).
 *
 * Only fields that carry a real BE `serverId` are emitted. The customer-panel
 * aggregation (SelectCustomerCustomFieldAnswersByCustomerIDQuery) does
 * `GROUP BY product_id, fieldId`, so `fieldId` is a merge key across a
 * customer's purchases — a `0` sentinel would collapse distinct fields into
 * one. A real (published/saved) checkout always hydrates server ids, so this
 * drops nothing there; it only excludes unsaved builder-preview fields, which
 * have no stable identity to aggregate on.
 */
export const buildCustomFieldsPayload = (
  customFields: CheckoutCustomFields | undefined,
  answers: Record<string, string> | undefined,
): CustomFieldAnswerPayload[] =>
  getRenderableCustomFields(customFields)
    .map((f, index) => ({ field: f, order: index }))
    .filter(({ field }) => typeof field.serverId === "number")
    .map(({ field, order }) => ({
      fieldId: field.serverId as number,
      label: field.label,
      order,
      value: (answers?.[field.fieldId] ?? "").trim(),
    }));
 
/**
 * AC018 — a structural custom-field edit on a *published* form must be saved as
 * a brand-new draft copy (the live form stays untouched), never PATCHed in
 * place. The builder marks the session (`customFieldsNeedNewDraft`) when such an
 * edit is confirmed; the eventual Save reads this to fork instead of patch.
 * Kept out of any per-edit handler so structural edits never trigger their own
 * network save (which raced the RHF state flush and duplicated drafts).
 */
export const shouldForkPublishedFormOnSave = ({
  isPublished,
  customFieldsNeedNewDraft,
}: {
  isPublished: boolean;
  customFieldsNeedNewDraft: boolean;
}): boolean => isPublished && customFieldsNeedNewDraft;
 
/**
 * AC005/AC-save-gate — an enabled custom field must have a non-empty label
 * before the form can be saved (the BE rejects blank labels with
 * ErrInvalidCustomFieldLabel). Used by the builder save chokepoint to block
 * a Save/Next while any enabled field's label is blank; the per-row inline
 * error (CustomFieldRow) already flags the offending field.
 */
export const hasEmptyEnabledCustomFieldLabel = (
  customFields?: CheckoutCustomFields,
): boolean =>
  Boolean(customFields?.enabled) &&
  (customFields?.fields ?? []).some((f) => !(f.label ?? "").trim());