diff --git a/woonuxt_base/app/app.config.ts b/woonuxt_base/app/app.config.ts index 77546e7c..95623df2 100644 --- a/woonuxt_base/app/app.config.ts +++ b/woonuxt_base/app/app.config.ts @@ -20,6 +20,7 @@ export default defineAppConfig({ showBreadcrumbOnSingleProduct: true, showMoveToWishlist: true, hideBillingAddressForVirtualProducts: false, + stripePaymentMethod: 'card', // 'card' || 'payment' -> ( 'card': shows CardElement, 'payment': shows payment tabs from stripe ) initStoreOnUserActionToReduceServerLoad: true, saleBadge: 'percent', // 'percent', 'onSale' or 'hidden' }, diff --git a/woonuxt_base/app/components/shopElements/StripeElement.vue b/woonuxt_base/app/components/shopElements/StripeElement.vue index ebb04793..edceab4c 100644 --- a/woonuxt_base/app/components/shopElements/StripeElement.vue +++ b/woonuxt_base/app/components/shopElements/StripeElement.vue @@ -1,32 +1,55 @@ diff --git a/woonuxt_base/app/composables/useAuth.ts b/woonuxt_base/app/composables/useAuth.ts index 6bf9f4ef..353e8609 100644 --- a/woonuxt_base/app/composables/useAuth.ts +++ b/woonuxt_base/app/composables/useAuth.ts @@ -12,6 +12,13 @@ export const useAuth = () => { const orders = useState('orders', () => null); const downloads = useState('downloads', () => null); + onMounted(() => { + const savedCustomer = localStorage.getItem('WooNuxtCustomer'); + if (savedCustomer) { + customer.value = JSON.parse(savedCustomer); + } + }); + // Log in the user const loginUser = async (credentials: CreateAccountInput): Promise<{ success: boolean; error: any }> => { isPending.value = true; diff --git a/woonuxt_base/app/composables/useCheckout.ts b/woonuxt_base/app/composables/useCheckout.ts index ed873cd7..2b48e3a2 100644 --- a/woonuxt_base/app/composables/useCheckout.ts +++ b/woonuxt_base/app/composables/useCheckout.ts @@ -1,6 +1,12 @@ import type { CheckoutInput, UpdateCustomerInput, CreateAccountInput } from '#gql'; +import { StripePaymentMethodEnum } from '#gql/default'; +import type { CreateSourceData, Stripe, StripeCardElement, StripeElements } from '@stripe/stripe-js'; +import { CheckoutInlineError } from '../types/CheckoutInlineError'; export function useCheckout() { + const { t } = useI18n(); + const { storeSettings } = useAppConfig(); + const errorMessage = useState('errorMessage', () => null); const orderInput = useState('orderInput', () => { return { customerNote: '', @@ -10,6 +16,13 @@ export function useCheckout() { }; }); + onMounted(() => { + const savedOrderInput = localStorage.getItem('WooNuxtOrderInput'); + if (savedOrderInput) { + orderInput.value = JSON.parse(savedOrderInput); + } + }); + const isProcessingOrder = useState('isProcessingOrder', () => false); // if Country or State are changed, calculate the shipping rates again @@ -88,11 +101,11 @@ export function useCheckout() { const orderId = checkout?.order?.databaseId; const orderKey = checkout?.order?.orderKey; - const orderInputPaymentId = orderInput.value.paymentMethod.id; - const isPayPal = orderInputPaymentId === 'paypal' || orderInputPaymentId === 'ppcp-gateway'; + const paymentMethodId = orderInput.value.paymentMethod.id; + const isPayPal = paymentMethodId === 'paypal' || paymentMethodId === 'ppcp-gateway'; // PayPal redirect - if ((await checkout?.redirect) && isPayPal) { + if (checkout?.redirect && isPayPal) { const frontEndUrl = window.location.origin; let redirectUrl = checkout?.redirect ?? ''; @@ -112,34 +125,164 @@ export function useCheckout() { router.push(`/checkout/order-received/${orderId}/?key=${orderKey}`); } - if ((await checkout?.result) !== 'success') { - alert('There was an error processing your order. Please try again.'); + if (checkout?.result !== 'success') { + alert(t('messages.error.orderFailed')); window.location.reload(); - return checkout; } else { await emptyCart(); await refreshCart(); } } catch (error: any) { - isProcessingOrder.value = false; - const errorMessage = error?.gqlErrors?.[0].message; if (errorMessage?.includes('An account is already registered with your email address')) { alert('An account is already registered with your email address'); - return null; + } else { + alert(errorMessage); } + } finally { + manageCheckoutLocalStorage(false); + isProcessingOrder.value = false; + } + }; + + const stripeCheckout = async (stripe: Stripe, elements: StripeElements) => { + let isPaid: boolean; + + if (storeSettings.stripePaymentMethod === 'card') { + isPaid = await stripeCardCheckout(stripe, elements); + + } else if (storeSettings.stripePaymentMethod === 'payment') { + isPaid = await stripePaymentCheckout(stripe, elements); + } else { + throw new Error("Invalid storeSettings.stripePaymentMethod"); + } + + if (isPaid) { + await proccessCheckout(true); + } else { + throw new Error(t('messages.error.orderFailed')); + } + } + + const stripeCardCheckout = async (stripe: Stripe, elements: StripeElements) => { + const cardElement = elements.getElement('card') as StripeCardElement; + const { stripePaymentIntent } = await GqlGetStripePaymentIntent({ stripePaymentMethod: StripePaymentMethodEnum.SETUP }); + const clientSecret = stripePaymentIntent?.clientSecret; + if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); + + const { setupIntent, error } = await stripe.confirmCardSetup(clientSecret, { payment_method: { card: cardElement } }); + if (error) { + throw new CheckoutInlineError(error.message); + } + + const { source } = await stripe.createSource(cardElement as CreateSourceData); + + if (source) orderInput.value.metaData.push({ key: '_stripe_source_id', value: source.id }); + if (setupIntent) orderInput.value.metaData.push({ key: '_stripe_intent_id', value: setupIntent.id }); + + orderInput.value.transactionId = setupIntent?.id || stripePaymentIntent.id; + + return setupIntent?.status === 'succeeded' || false; + }; + + const stripePaymentCheckout = async (stripe: Stripe, elements: StripeElements) => { - alert(errorMessage); - return null; + const { error: submitError } = await elements.submit(); + if (submitError) { + throw new CheckoutInlineError(submitError.message); } - isProcessingOrder.value = false; + const { stripePaymentIntent } = await GqlGetStripePaymentIntent({ stripePaymentMethod: StripePaymentMethodEnum.PAYMENT }); + const clientSecret = stripePaymentIntent?.clientSecret; + if (!clientSecret) throw new Error('Stripe PaymentIntent client secret missing!'); + if (!stripePaymentIntent.id) throw new Error('Stripe PaymentIntent id missing!'); + + orderInput.value.metaData.push({ key: '_stripe_intent_id', value: stripePaymentIntent.id }); + orderInput.value.transactionId = stripePaymentIntent.id; + + // Let's save checkout orderInput & customer to maintain state after redirect + // We are not sure whether the confirmSetup will redirect if needed or continue code execution + manageCheckoutLocalStorage(true); + + const { paymentIntent, error } = await stripe.confirmPayment({ + elements, + clientSecret, + confirmParams: { + return_url: `${window.location.origin}/checkout`, + }, + redirect: 'if_required', + }); + + if (error) { + throw new CheckoutInlineError(error.message); + } + + return paymentIntent.status === 'succeeded' || false; + }; + + const validateStripePaymentFromRedirect = async (stripe: Stripe, clientSecret: string, redirectStatus: string) => { + try { + if (redirectStatus !== 'succeeded') throw new CheckoutInlineError(t('messages.error.paymentFailed')); + + isProcessingOrder.value = true; + const { paymentIntent, error } = await stripe.retrievePaymentIntent(clientSecret); + if (error) { + throw new Error(error.message); + } + + switch (paymentIntent?.status) { + case "succeeded": + await proccessCheckout(true); + break; + case "processing": + await proccessCheckout(false); + break; + case "requires_payment_method": + // If the payment attempt fails (for example due to a decline), + // the PaymentIntent’s status returns to requires_payment_method so that the payment can be retried. + throw new CheckoutInlineError(t('messages.error.paymentFailed')); + default: + throw new Error("Something went wrong. ('" + paymentIntent?.status + "')"); + } + } catch (error: any) { + isProcessingOrder.value = false; + console.error(error); + + useRouter().push({ query: {} }); + manageCheckoutLocalStorage(false); + + if (error instanceof CheckoutInlineError) { + errorMessage.value = error.message; + } else { + alert(error); + } + } + }; + + /** + * Manages the local storage for checkout data, specifically saving and removing + * the 'WooNuxtOrderInput' and 'WooNuxtCustomer' items. This is necessary to maintain + * the state after a redirect, ensuring the orderInput and customer information persist. + * + * @param {boolean} shouldStore - Indicates whether to save or remove the data in local storage. + */ + const manageCheckoutLocalStorage = (shouldStore: boolean) => { + if (shouldStore) { + localStorage.setItem('WooNuxtOrderInput', JSON.stringify(orderInput.value)); + localStorage.setItem('WooNuxtCustomer', JSON.stringify(useAuth().customer.value)); + } else { + localStorage.removeItem('WooNuxtOrderInput'); + localStorage.removeItem('WooNuxtCustomer'); + } }; return { orderInput, isProcessingOrder, + errorMessage, + stripeCheckout, + validateStripePaymentFromRedirect, proccessCheckout, updateShippingLocation, }; diff --git a/woonuxt_base/app/locales/de-DE.json b/woonuxt_base/app/locales/de-DE.json index 6ded0fff..10c1415c 100644 --- a/woonuxt_base/app/locales/de-DE.json +++ b/woonuxt_base/app/locales/de-DE.json @@ -159,6 +159,8 @@ "invalidPassword": "Ungültiges Passwort. Bitte versuchen Sie es erneut.", "passwordMismatch": "Die Passwörter stimmen nicht überein. Bitte versuche es erneut.", "invalidPasswordResetLink": "Der Passwort-Reset-Link ist ungültig.", + "orderFailed": "Bei der Bearbeitung Ihrer Bestellung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", + "paymentFailed": "Zahlung fehlgeschlagen. Bitte versuchen Sie es mit einer anderen Zahlungsmethode.", "general": "Etwas ist schief gelaufen", "noOrder": "Wir konnten Ihre Bestellung nicht finden. Bitte versuchen Sie es später noch einmal." } diff --git a/woonuxt_base/app/locales/en-US.json b/woonuxt_base/app/locales/en-US.json index f0295d45..29de386d 100644 --- a/woonuxt_base/app/locales/en-US.json +++ b/woonuxt_base/app/locales/en-US.json @@ -159,6 +159,8 @@ "incorrectPassword": "Incorrect password. Please try again.", "passwordMismatch": "Passwords do not match. Please try again.", "invalidPasswordResetLink": "Password reset link is invalid.", + "orderFailed": "There was an error processing your order. Please try again.", + "paymentFailed": "Payment failed. Please try another payment method.", "general": "Something went wrong", "noOrder": "We could not find your order. Please try again later." } diff --git a/woonuxt_base/app/locales/es-ES.json b/woonuxt_base/app/locales/es-ES.json index 534515a7..f72a31e9 100644 --- a/woonuxt_base/app/locales/es-ES.json +++ b/woonuxt_base/app/locales/es-ES.json @@ -159,6 +159,8 @@ "invalidPassword": "Contraseña inválida. Por favor, inténtalo de nuevo.", "passwordMismatch": "Las contraseñas no coinciden. Por favor, inténtalo de nuevo.", "invalidPasswordResetLink": "El enlace de restablecimiento de contraseña no es válido.", + "orderFailed": "Hubo un error al procesar su pedido. Por favor, inténtelo de nuevo.", + "paymentFailed": "El pago ha fallado. Por favor, intenta con otro método de pago.", "general": "Ha ocurrido un error", "noOrder": "No hemos podido encontrar tu pedido. Por favor, inténtalo de nuevo más tarde." } diff --git a/woonuxt_base/app/locales/fr-FR.json b/woonuxt_base/app/locales/fr-FR.json index 2e102719..2a788f28 100644 --- a/woonuxt_base/app/locales/fr-FR.json +++ b/woonuxt_base/app/locales/fr-FR.json @@ -159,6 +159,8 @@ "incorrectPassword": "Mot de passe invalide. Veuillez réessayer.", "passwordMismatch": "Les mots de passe ne correspondent pas. Veuillez réessayer.", "invalidPasswordResetLink": "Le lien de réinitialisation du mot de passe est invalide.", + "orderFailed": "Une erreur s'est produite lors du traitement de votre commande. Veuillez réessayer.", + "paymentFailed": "Le paiement a échoué. Veuillez essayer un autre mode de paiement.", "general": "Une erreur est survenue", "noOrder": "Impossible de trouver votre commande. Veuillez réessayer plus tard." } diff --git a/woonuxt_base/app/locales/it-IT.json b/woonuxt_base/app/locales/it-IT.json index d33ac07c..4744f8e7 100644 --- a/woonuxt_base/app/locales/it-IT.json +++ b/woonuxt_base/app/locales/it-IT.json @@ -159,6 +159,8 @@ "incorrectPassword": "Password errata. Riprova.", "passwordMismatch": "Le password non coincidono. Per favore riprova.", "invalidPasswordResetLink": "Il link per reimpostare la password non è valido.", + "orderFailed": "Si è verificato un errore durante l'elaborazione del tuo ordine. Per favore, riprova.", + "paymentFailed": "Pagamento fallito. Per favore, prova un altro metodo di pagamento.", "general": "Qualcosa è andato storto", "noOrder": "Non abbiamo trovato il tuo ordine. Riprova più tardi." } diff --git a/woonuxt_base/app/locales/pt-BR.json b/woonuxt_base/app/locales/pt-BR.json index 10c36c7d..d896ee45 100644 --- a/woonuxt_base/app/locales/pt-BR.json +++ b/woonuxt_base/app/locales/pt-BR.json @@ -159,6 +159,8 @@ "incorrectPassword": "Senha incorreta. Por favor, tente novamente.", "passwordMismatch": "As senhas não coincidem. Por favor, tente novamente.", "invalidPasswordResetLink": "O link de redefinição de senha é inválido.", + "orderFailed": "Houve um erro ao processar seu pedido. Por favor, tente novamente.", + "paymentFailed": "Pagamento falhou. Por favor, tente outro método de pagamento.", "general": "Algo deu errado", "noOrder": "Não conseguimos encontrar seu pedido. Por favor, tente novamente mais tarde." } diff --git a/woonuxt_base/app/pages/checkout.vue b/woonuxt_base/app/pages/checkout.vue index 73c42de7..bee69ce2 100644 --- a/woonuxt_base/app/pages/checkout.vue +++ b/woonuxt_base/app/pages/checkout.vue @@ -1,55 +1,80 @@