feat(checkout): show a German message when a checkout step fails
checkoutErrorMessage (utils/checkoutError.ts) maps the status and body of a
failed request, the shop's answer or a raw CMS (Strapi) error, to one fixed
message for the customer, addressed with "du": an invalid e-mail address
(when data.email is among the rejected fields), other invalid input, an order
already paid, a product no longer available, an order not ready for checkout
(naming the missing steps), a payment that does not fit the order or an order
changed during the payment ("Bitte starte die Zahlung neu"), PayPal not
reachable ("versuche es in ein paar Minuten noch einmal"), a payment PayPal
may have taken that the CMS could not confirm or record ("Bitte bezahle nicht
noch einmal"), and a general fallback. It never shows the server's text.
Steps 1 and 2 show the message above their submit button, in the style of
step 3's payment error, instead of logging the error only. Step 3 shows it
for a failed PayPal order creation or capture. The e-mail input of step 1 is
type="email" (Input.vue takes a type).
Refs libretech/mp#71
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// The message a customer sees when a checkout request fails: German, addressed with "du" like the rest of the shop, chosen from the
|
||||
// constants below, so it never shows the server's text or personal data. The shop's order routes answer a CMS error with its status
|
||||
// and { …, data: { message, errors?, missing? } } (server/utils/cmsError.ts); a raw CMS (Strapi) error body,
|
||||
// { data: null, error: { status, name, message, details } }, is read as well.
|
||||
// Pure: no Nuxt or Vue imports, tested in tests/unit/checkoutError.test.ts.
|
||||
|
||||
// The CMS's messages (libreshop/cms src/api/order/controllers/order.ts and src/checkout/), matched but never shown.
|
||||
const INVALID_ORDER_UPDATE = "Invalid order update";
|
||||
const ORDER_LOCKED = "Order can no longer be changed";
|
||||
const ALREADY_PAID = "Order is already paid";
|
||||
const CHECKOUT_NOT_READY = "Order is not ready for checkout";
|
||||
const PRODUCT_UNAVAILABLE = "A product in the cart is no longer available";
|
||||
const PAYMENT_NOT_THIS_ORDER = "Payment does not belong to this order";
|
||||
const PAYMENT_NOT_MATCHING = "Payment does not match this order";
|
||||
const ORDER_CHANGED = "Order changed during the payment";
|
||||
// PayPal answered the capture, or captured the payment, but the CMS could not confirm or record it: the money may be taken.
|
||||
const PAYMENT_NOT_CONFIRMED = "Payment could not be confirmed";
|
||||
const PAYMENT_NOT_RECORDED = "Payment could not be recorded";
|
||||
|
||||
export const CHECKOUT_ERROR_MESSAGES = {
|
||||
invalidEmail: "Bitte gib eine gültige E-Mail-Adresse ein.",
|
||||
invalidInput: "Deine Angaben konnten nicht gespeichert werden. Bitte prüfe sie und versuche es noch einmal.",
|
||||
alreadyPaid: "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.",
|
||||
productUnavailable: "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.",
|
||||
notReady: "Deine Bestellung ist noch nicht vollständig. Bitte prüfe deine Angaben in den vorherigen Schritten.",
|
||||
restartPayment: "Die Zahlung passt nicht zu deiner Bestellung, oder deine Bestellung wurde während der Zahlung geändert. Bitte starte die Zahlung neu.",
|
||||
paypalUnavailable: "PayPal ist gerade nicht erreichbar. Bitte versuche es in ein paar Minuten noch einmal.",
|
||||
paymentUnconfirmed: "Deine Zahlung konnte nicht bestätigt werden. Bitte bezahle nicht noch einmal, sondern melde dich bei uns.",
|
||||
generic: "Es ist ein Fehler aufgetreten. Bitte versuche es erneut."
|
||||
} as const;
|
||||
|
||||
// The fields a checkout can miss (the CMS's missingCheckoutFields), as the customer knows them from the checkout's steps.
|
||||
const MISSING_FIELD_LABELS = new Map<string, string>([
|
||||
["email", "E-Mail-Adresse"],
|
||||
["invoiceAddress", "Rechnungsadresse"],
|
||||
["delivery", "Versandart"]
|
||||
]);
|
||||
|
||||
type CheckoutErrorFields = { message: string | undefined; errors: string[]; missing: string[] };
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const text = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
||||
|
||||
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
||||
|
||||
const readCheckoutError = (body: unknown): CheckoutErrorFields => {
|
||||
if (!isObject(body)) return { message: undefined, errors: [], missing: [] };
|
||||
// The shop's answer: { error: true, url, statusCode, statusMessage, message, data: { message, errors?, missing? } }.
|
||||
if (isObject(body.data)) {
|
||||
return { message: text(body.data.message) ?? text(body.message), errors: strings(body.data.errors), missing: strings(body.data.missing) };
|
||||
}
|
||||
// The CMS's answer: { data: null, error: { status, name, message, details: { errors?, missing? } } }.
|
||||
if (isObject(body.error)) {
|
||||
const details: Record<string, unknown> = isObject(body.error.details) ? body.error.details : {};
|
||||
return { message: text(body.error.message), errors: strings(details.errors), missing: strings(details.missing) };
|
||||
}
|
||||
return { message: text(body.message), errors: [], missing: [] };
|
||||
};
|
||||
|
||||
// The CMS names a rejected field first, as in "data.email: must be an email address of at most 254 characters".
|
||||
const isEmailError = (error: string): boolean => error === "data.email" || error.startsWith("data.email:");
|
||||
|
||||
const notReadyMessage = (missing: string[]): string => {
|
||||
const labels = [...new Set(missing.map((field) => MISSING_FIELD_LABELS.get(field)).filter((label) => label !== undefined))];
|
||||
return labels.length > 0 ? `Deine Bestellung ist noch nicht vollständig. Bitte ergänze: ${labels.join(", ")}.` : CHECKOUT_ERROR_MESSAGES.notReady;
|
||||
};
|
||||
|
||||
/** The customer's message for a failed checkout request, from the status and the parsed body of its answer. */
|
||||
export const checkoutErrorMessage = (status: number | undefined, body: unknown): string => {
|
||||
const { message, errors, missing } = readCheckoutError(body);
|
||||
|
||||
if (status === 400) {
|
||||
if (message === INVALID_ORDER_UPDATE || errors.length > 0) {
|
||||
return errors.some(isEmailError) ? CHECKOUT_ERROR_MESSAGES.invalidEmail : CHECKOUT_ERROR_MESSAGES.invalidInput;
|
||||
}
|
||||
if (message === CHECKOUT_NOT_READY || missing.length > 0) return notReadyMessage(missing);
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
}
|
||||
if (status === 409) {
|
||||
if (message === ORDER_LOCKED || message === ALREADY_PAID) return CHECKOUT_ERROR_MESSAGES.alreadyPaid;
|
||||
if (message === PRODUCT_UNAVAILABLE) return CHECKOUT_ERROR_MESSAGES.productUnavailable;
|
||||
if (message === PAYMENT_NOT_THIS_ORDER || message === PAYMENT_NOT_MATCHING || message === ORDER_CHANGED) {
|
||||
return CHECKOUT_ERROR_MESSAGES.restartPayment;
|
||||
}
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
}
|
||||
if (message === PAYMENT_NOT_CONFIRMED || message === PAYMENT_NOT_RECORDED) return CHECKOUT_ERROR_MESSAGES.paymentUnconfirmed;
|
||||
if (status === 502) return CHECKOUT_ERROR_MESSAGES.paypalUnavailable;
|
||||
return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
};
|
||||
|
||||
/** The customer's message for an error thrown by $fetch: ofetch's FetchError carries the answer's status and parsed body. */
|
||||
export const checkoutErrorMessageFor = (error: unknown): string => {
|
||||
if (!isObject(error)) return CHECKOUT_ERROR_MESSAGES.generic;
|
||||
const status = typeof error.status === "number" ? error.status : typeof error.statusCode === "number" ? error.statusCode : undefined;
|
||||
return checkoutErrorMessage(status, error.data);
|
||||
};
|
||||
Reference in New Issue
Block a user