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:
@@ -5,6 +5,7 @@
|
||||
<span :class="spanClasses" class="absolute left-2 top-0 transition-all select-none">{{ label }}</span>
|
||||
<input
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
@input="update(($event.target as HTMLInputElement).value)"
|
||||
:required="required"
|
||||
class="outline-none w-full h-full rounded-sm pt-4 p-2 border-transparent"
|
||||
@@ -40,9 +41,11 @@ const props = withDefaults(
|
||||
labelClass?: string;
|
||||
inputClass?: string;
|
||||
autocomplete?: Autocomplete;
|
||||
type?: "text" | "email";
|
||||
}>(),
|
||||
{
|
||||
autocomplete: "off"
|
||||
autocomplete: "off",
|
||||
type: "text"
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<div v-if="uuid">
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-12">
|
||||
<Input label="E-Mail-Adresse" v-model="emailAddress" :required="true" autocomplete="email" />
|
||||
<Input label="E-Mail-Adresse" v-model="emailAddress" type="email" :required="true" autocomplete="email" />
|
||||
|
||||
<label class="flex gap-4 text-sm cursor-pointer">
|
||||
<input v-model="acceptedTermsAndConditions" required type="checkbox" class="w-4 cursor-pointer" />
|
||||
@@ -44,6 +44,10 @@
|
||||
|
||||
<hr />
|
||||
|
||||
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
|
||||
<span class="text-rose-800">{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" classes="w-full" :is-pending="formSubmitIsPending">Weiter zur Lieferadresse</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -60,6 +64,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
|
||||
|
||||
const COOKIE_CONSENT_KEY = "shop:cookie-consent";
|
||||
|
||||
@@ -68,6 +73,7 @@ const cart = useCart();
|
||||
const emailAddress = ref("");
|
||||
const acceptedTermsAndConditions = ref(false);
|
||||
const formSubmitIsPending = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
const uuid = computed(() => cart.uuid.value);
|
||||
|
||||
@@ -120,6 +126,7 @@ async function submit() {
|
||||
|
||||
try {
|
||||
formSubmitIsPending.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
// Use cart.update() to sync local state after API call
|
||||
await cart.update({
|
||||
@@ -138,6 +145,7 @@ async function submit() {
|
||||
navigateTo("/checkout/2");
|
||||
} catch (error) {
|
||||
console.error("Error submitting email address", error);
|
||||
errorMessage.value = checkoutErrorMessageFor(error);
|
||||
} finally {
|
||||
formSubmitIsPending.value = false;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@
|
||||
|
||||
<hr class="my-12" />
|
||||
|
||||
<div v-if="errorMessage" class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" role="alert" data-e2e="checkout-error">
|
||||
<span class="text-rose-800">{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-12">
|
||||
<Button classes="w-full" type="submit" :is-pending="formSubmitIsPending">Weiter zur Zahlung</Button>
|
||||
</div>
|
||||
@@ -110,6 +114,7 @@
|
||||
import type { Address, StructuredAddress } from "~/types";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
import { numberFormatter } from "~/utils/numberFormatter";
|
||||
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
|
||||
|
||||
const cart = useCart();
|
||||
const shopApi = useShopApi();
|
||||
@@ -134,6 +139,7 @@ const displayState = computed(() => {
|
||||
});
|
||||
const showOptionalDeliveryAddress = ref(false);
|
||||
const formSubmitIsPending = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const deliveryMethods = ref<any[]>([]);
|
||||
const selectedDeliveryMethod = ref<number | null>(null);
|
||||
|
||||
@@ -243,6 +249,7 @@ async function submit() {
|
||||
|
||||
try {
|
||||
formSubmitIsPending.value = true;
|
||||
errorMessage.value = "";
|
||||
|
||||
// Use cart.update() to sync local state after API call
|
||||
await cart.update({
|
||||
@@ -262,6 +269,7 @@ async function submit() {
|
||||
navigateTo("/checkout/3");
|
||||
} catch (error) {
|
||||
console.error("Error submitting address form:", error);
|
||||
errorMessage.value = checkoutErrorMessageFor(error);
|
||||
} finally {
|
||||
formSubmitIsPending.value = false;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
<div v-else>
|
||||
<div v-if="!hasPaymentError" id="paypal-button-container" class="payment sticky top-4" ref="paymentContainer"></div>
|
||||
<div v-else class="p-4 lg:p-8 text-center mx-auto rounded-md bg-rose-100" data-e2e="payment-error">
|
||||
<span class="text-rose-800">Es ist ein Fehler aufgetreten. Bitte versuche es erneut.</span>
|
||||
<span class="text-rose-800">{{ paymentErrorMessage || "Es ist ein Fehler aufgetreten. Bitte versuche es erneut." }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Trust signals -->
|
||||
@@ -190,6 +190,7 @@
|
||||
<script setup lang="ts">
|
||||
import { numberFormatter } from "~/utils/numberFormatter";
|
||||
import { trackEvent } from "~/utils/trackEvent";
|
||||
import { checkoutErrorMessageFor } from "~/utils/checkoutError";
|
||||
import type { Order } from "~/types";
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
@@ -197,6 +198,7 @@ const shopApi = useShopApi();
|
||||
const CART_UUID_KEY = "shop:cart";
|
||||
|
||||
const hasPaymentError = ref(false);
|
||||
const paymentErrorMessage = ref("");
|
||||
const hasOrderError = ref(false);
|
||||
const hasAuthorisedPayment = ref(false);
|
||||
const orderData = ref<Order>({} as Order);
|
||||
@@ -298,6 +300,7 @@ async function initializePayPalButtons() {
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating PayPal order:", error);
|
||||
paymentErrorMessage.value = checkoutErrorMessageFor(error);
|
||||
hasPaymentError.value = true;
|
||||
trackEvent("checkout-payment-error", {
|
||||
stage: "create-order",
|
||||
@@ -324,6 +327,7 @@ async function initializePayPalButtons() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error capturing payment:", error);
|
||||
paymentErrorMessage.value = checkoutErrorMessageFor(error);
|
||||
hasPaymentError.value = true;
|
||||
trackEvent("checkout-payment-error", {
|
||||
stage: "capture-exception",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { CHECKOUT_ERROR_MESSAGES as M, checkoutErrorMessage, checkoutErrorMessageFor } from "../../utils/checkoutError.ts";
|
||||
|
||||
// The shop's answer for a CMS error (server/utils/cmsError.ts), as Nitro writes it for a handled error.
|
||||
const shopError = (statusCode: number, data: { message: string; errors?: string[]; missing?: string[] }) => ({
|
||||
error: true,
|
||||
url: "/api/orders/11111111-2222-4333-8444-555555555555",
|
||||
statusCode,
|
||||
statusMessage: data.message,
|
||||
message: data.message,
|
||||
data
|
||||
});
|
||||
|
||||
// The CMS's own answer, Strapi's error body.
|
||||
const strapiError = (status: number, message: string, details: Record<string, unknown> = {}) => ({
|
||||
data: null,
|
||||
error: { status, name: "Error", message, details }
|
||||
});
|
||||
|
||||
// Built like ofetch's FetchError in the browser: getters for the answer's status and parsed body.
|
||||
const fetchError = (status: number | undefined, body: unknown): Error => {
|
||||
const error = new Error(`[PUT] "/api/orders/11111111-2222-4333-8444-555555555555": ${status ?? "<no response>"}`);
|
||||
error.name = "FetchError";
|
||||
for (const [key, value] of [
|
||||
["data", body],
|
||||
["status", status],
|
||||
["statusCode", status]
|
||||
] as const) {
|
||||
Object.defineProperty(error, key, { get: () => value });
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
const ALL_MESSAGES = new Set<string>(Object.values(M));
|
||||
|
||||
test("asks for a valid e-mail address when the CMS rejects data.email", () => {
|
||||
const body = shopError(400, { message: "Invalid order update", errors: ["data.email: must be an email address of at most 254 characters"] });
|
||||
|
||||
assert.equal(checkoutErrorMessage(400, body), "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
});
|
||||
|
||||
test("asks to check the input when the CMS rejects another field", () => {
|
||||
const body = shopError(400, {
|
||||
message: "Invalid order update",
|
||||
errors: ["data.invoiceAddressStructured.postalCode: must be a string of at most 200 characters, or null"]
|
||||
});
|
||||
|
||||
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
|
||||
assert.equal(
|
||||
checkoutErrorMessage(400, shopError(400, { message: "Invalid order update", errors: ["data.delivery: no delivery method has this id"] })),
|
||||
M.invalidInput
|
||||
);
|
||||
});
|
||||
|
||||
test("asks to check the input when the shop rejects a field before the CMS", () => {
|
||||
const body = shopError(400, { message: "Invalid order update", errors: ["data.paymentAuthorised: not a field the customer may set"] });
|
||||
|
||||
assert.equal(checkoutErrorMessage(400, body), M.invalidInput);
|
||||
});
|
||||
|
||||
test("mentions the e-mail address when it is among several rejected fields", () => {
|
||||
const body = shopError(400, {
|
||||
message: "Invalid order update",
|
||||
errors: [
|
||||
"data.acceptedTermsAndConditionsAt: must be sent only to accept the terms, as true or a timestamp",
|
||||
"data.email: must be an email address"
|
||||
]
|
||||
});
|
||||
|
||||
assert.equal(checkoutErrorMessage(400, body), M.invalidEmail);
|
||||
});
|
||||
|
||||
test("says that a paid order can no longer be changed", () => {
|
||||
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order can no longer be changed" })), M.alreadyPaid);
|
||||
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Order is already paid" })), M.alreadyPaid);
|
||||
assert.equal(M.alreadyPaid, "Diese Bestellung ist bereits bezahlt und kann nicht mehr geändert werden.");
|
||||
});
|
||||
|
||||
test("says that a product is no longer available", () => {
|
||||
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "A product in the cart is no longer available" })), M.productUnavailable);
|
||||
assert.equal(M.productUnavailable, "Ein Produkt in deinem Warenkorb ist nicht mehr verfügbar. Bitte prüfe deinen Warenkorb.");
|
||||
});
|
||||
|
||||
test("names the fields an order that is not ready for checkout misses", () => {
|
||||
const body = (missing: string[]) => shopError(400, { message: "Order is not ready for checkout", missing });
|
||||
|
||||
assert.equal(checkoutErrorMessage(400, body(["email"])), "Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse.");
|
||||
assert.equal(
|
||||
checkoutErrorMessage(400, body(["email", "invoiceAddress", "delivery"])),
|
||||
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: E-Mail-Adresse, Rechnungsadresse, Versandart."
|
||||
);
|
||||
});
|
||||
|
||||
test("says that an order is not ready without naming a field the shop does not know", () => {
|
||||
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout" })), M.notReady);
|
||||
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["<b>phone</b>"] })), M.notReady);
|
||||
assert.equal(
|
||||
checkoutErrorMessage(400, shopError(400, { message: "Order is not ready for checkout", missing: ["phone", "delivery"] })),
|
||||
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
|
||||
);
|
||||
});
|
||||
|
||||
test("asks to restart the payment when it does not fit the order or the order changed", () => {
|
||||
for (const message of ["Payment does not belong to this order", "Payment does not match this order", "Order changed during the payment"]) {
|
||||
assert.equal(checkoutErrorMessage(409, shopError(409, { message })), M.restartPayment, message);
|
||||
}
|
||||
assert.match(M.restartPayment, /Bitte starte die Zahlung neu\.$/);
|
||||
});
|
||||
|
||||
test("asks to try again in a few minutes when PayPal is not reachable", () => {
|
||||
for (const message of ["Could not create the PayPal order", "PayPal could not capture the payment"]) {
|
||||
assert.equal(checkoutErrorMessage(502, shopError(502, { message })), M.paypalUnavailable, message);
|
||||
}
|
||||
assert.equal(checkoutErrorMessage(502, "<html>502 Bad Gateway</html>"), M.paypalUnavailable);
|
||||
assert.match(M.paypalUnavailable, /versuche es in ein paar Minuten noch einmal\.$/);
|
||||
});
|
||||
|
||||
test("asks not to pay again when PayPal may have taken the money but the order is not marked paid", () => {
|
||||
assert.equal(checkoutErrorMessage(502, shopError(502, { message: "Payment could not be confirmed" })), M.paymentUnconfirmed);
|
||||
assert.equal(checkoutErrorMessage(500, shopError(500, { message: "Payment could not be recorded" })), M.paymentUnconfirmed);
|
||||
});
|
||||
|
||||
test("falls back to a general message for anything else", () => {
|
||||
assert.equal(checkoutErrorMessage(404, shopError(404, { message: "Order not found" })), M.generic);
|
||||
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Missing order UUID" })), M.generic);
|
||||
assert.equal(checkoutErrorMessage(400, shopError(400, { message: "Product not found in cart" })), M.generic);
|
||||
assert.equal(checkoutErrorMessage(409, shopError(409, { message: "Something else" })), M.generic);
|
||||
assert.equal(checkoutErrorMessage(500, { error: true, statusCode: 500, statusMessage: "Server Error", message: "Server Error" }), M.generic);
|
||||
assert.equal(checkoutErrorMessage(503, shopError(503, { message: "The CMS did not answer" })), M.generic);
|
||||
assert.equal(checkoutErrorMessage(undefined, undefined), M.generic);
|
||||
assert.equal(checkoutErrorMessage(409, null), M.generic);
|
||||
assert.equal(M.generic, "Es ist ein Fehler aufgetreten. Bitte versuche es erneut.");
|
||||
});
|
||||
|
||||
test("reads the CMS's own error body as well", () => {
|
||||
assert.equal(
|
||||
checkoutErrorMessage(400, strapiError(400, "Invalid order update", { errors: ["data.email: must be an email address"] })),
|
||||
M.invalidEmail
|
||||
);
|
||||
assert.equal(
|
||||
checkoutErrorMessage(400, strapiError(400, "Order is not ready for checkout", { missing: ["delivery"] })),
|
||||
"Deine Bestellung ist noch nicht vollständig. Bitte ergänze: Versandart."
|
||||
);
|
||||
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order can no longer be changed")), M.alreadyPaid);
|
||||
assert.equal(checkoutErrorMessage(409, strapiError(409, "Order changed during the payment")), M.restartPayment);
|
||||
});
|
||||
|
||||
test("never shows the server's text or personal data", () => {
|
||||
const personal = "erika@example.org, Musterstraße 1, 70190 Stuttgart";
|
||||
const bodies = [
|
||||
shopError(400, { message: personal, errors: [`data.email: ${personal}`], missing: [personal] }),
|
||||
shopError(409, { message: personal }),
|
||||
shopError(502, { message: personal }),
|
||||
strapiError(400, personal, { errors: [personal] }),
|
||||
{ message: personal },
|
||||
personal
|
||||
];
|
||||
|
||||
for (const status of [undefined, 400, 404, 409, 500, 502, 503]) {
|
||||
for (const body of bodies) {
|
||||
const message = checkoutErrorMessage(status, body);
|
||||
assert.ok(ALL_MESSAGES.has(message) || message.startsWith("Deine Bestellung ist noch nicht vollständig."), message);
|
||||
assert.ok(!message.includes("erika") && !message.includes("Muster"), message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("reads the status and the body of the error $fetch throws", () => {
|
||||
const body = shopError(409, { message: "Order can no longer be changed" });
|
||||
|
||||
assert.equal(checkoutErrorMessageFor(fetchError(409, body)), M.alreadyPaid);
|
||||
assert.equal(checkoutErrorMessageFor(fetchError(502, shopError(502, { message: "Could not create the PayPal order" }))), M.paypalUnavailable);
|
||||
assert.equal(
|
||||
checkoutErrorMessageFor({ statusCode: 400, data: shopError(400, { message: "Invalid order update", errors: ["data.email: x"] }) }),
|
||||
M.invalidEmail
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to the general message for an error without an answer", () => {
|
||||
assert.equal(checkoutErrorMessageFor(fetchError(undefined, undefined)), M.generic);
|
||||
assert.equal(checkoutErrorMessageFor(new TypeError("Failed to fetch")), M.generic);
|
||||
assert.equal(checkoutErrorMessageFor(undefined), M.generic);
|
||||
assert.equal(checkoutErrorMessageFor("error"), M.generic);
|
||||
});
|
||||
@@ -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