From 70fa0eb135bb4f540cf830f13998734f5737e3e7 Mon Sep 17 00:00:00 2001 From: xingyy <64720302+Concur-max@users.noreply.github.com> Date: Fri, 28 Feb 2025 11:41:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E6=B7=BB=E5=8A=A0=20Stripe=20?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Stripe 支付页面和相关资源 - 实现支付流程和状态展示 - 添加错误处理和重试功能 --- app/pages/externallinks/index.vue | 7 +- public/stripe/checkout.css | 242 ++++++++++++++++++++++++++++++ public/stripe/checkout.html | 25 +++ public/stripe/checkout.js | 89 +++++++++++ public/stripe/complete.html | 36 +++++ public/stripe/complete.js | 85 +++++++++++ 6 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 public/stripe/checkout.css create mode 100644 public/stripe/checkout.html create mode 100644 public/stripe/checkout.js create mode 100644 public/stripe/complete.html create mode 100644 public/stripe/complete.js diff --git a/app/pages/externallinks/index.vue b/app/pages/externallinks/index.vue index f50a4cf..54d8af6 100644 --- a/app/pages/externallinks/index.vue +++ b/app/pages/externallinks/index.vue @@ -1,19 +1,18 @@ diff --git a/public/stripe/checkout.css b/public/stripe/checkout.css new file mode 100644 index 0000000..2147f77 --- /dev/null +++ b/public/stripe/checkout.css @@ -0,0 +1,242 @@ +/* Variables */ +* { + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 16px; + -webkit-font-smoothing: antialiased; + display: flex; + flex-direction: column; + justify-content: center; + align-content: center; + height: 100vh; + width: 100vw; +} + +form { + width: 30vw; + min-width: 500px; + align-self: center; + box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1), + 0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07); + border-radius: 7px; + padding: 40px; + margin-top: auto; + margin-bottom: auto; +} + +.hidden { + display: none; +} + +#payment-message { + color: rgb(105, 115, 134); + font-size: 16px; + line-height: 20px; + padding-top: 12px; + text-align: center; +} + +#payment-element { + margin-bottom: 24px; +} + +/* Buttons and links */ +button { + background: #0055DE; + font-family: Arial, sans-serif; + color: #ffffff; + border-radius: 4px; + border: 0; + padding: 12px 16px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: block; + transition: all 0.2s ease; + box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07); + width: 100%; +} +button:hover { + filter: contrast(115%); +} +button:disabled { + opacity: 0.5; + cursor: default; +} + +/* spinner/processing state, errors */ +.spinner, +.spinner:before, +.spinner:after { + border-radius: 50%; +} +.spinner { + color: #ffffff; + font-size: 22px; + text-indent: -99999px; + margin: 0px auto; + position: relative; + width: 20px; + height: 20px; + box-shadow: inset 0 0 0 2px; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); +} +.spinner:before, +.spinner:after { + position: absolute; + content: ""; +} +.spinner:before { + width: 10.4px; + height: 20.4px; + background: #0055DE; + border-radius: 20.4px 0 0 20.4px; + top: -0.2px; + left: -0.2px; + -webkit-transform-origin: 10.4px 10.2px; + transform-origin: 10.4px 10.2px; + -webkit-animation: loading 2s infinite ease 1.5s; + animation: loading 2s infinite ease 1.5s; +} +.spinner:after { + width: 10.4px; + height: 10.2px; + background: #0055DE; + border-radius: 0 10.2px 10.2px 0; + top: -0.1px; + left: 10.2px; + -webkit-transform-origin: 0px 10.2px; + transform-origin: 0px 10.2px; + -webkit-animation: loading 2s infinite ease; + animation: loading 2s infinite ease; +} + +/* Payment status page */ +#payment-status { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + row-gap: 30px; + width: 30vw; + min-width: 500px; + min-height: 380px; + align-self: center; + box-shadow: 0px 0px 0px 0.5px rgba(50, 50, 93, 0.1), + 0px 2px 5px 0px rgba(50, 50, 93, 0.1), 0px 1px 1.5px 0px rgba(0, 0, 0, 0.07); + border-radius: 7px; + padding: 40px; + opacity: 0; + animation: fadeInAnimation 1s ease forwards; +} + +#status-icon { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + width: 40px; + border-radius: 50%; +} + +h2 { + margin: 0; + color: #30313D; + text-align: center; +} + +a { + text-decoration: none; + font-size: 16px; + font-weight: 600; + font-family: Arial, sans-serif; + display: block; +} +a:hover { + filter: contrast(120%); +} + +#details-table { + overflow-x: auto; + width: 100%; +} + +table { + width: 100%; + font-size: 14px; + border-collapse: collapse; +} +table tbody tr:first-child td { + border-top: 1px solid #E6E6E6; /* Top border */ + padding-top: 10px; +} +table tbody tr:last-child td { + border-bottom: 1px solid #E6E6E6; /* Bottom border */ +} +td { + padding-bottom: 10px; +} + +.TableContent { + text-align: right; + color: #6D6E78; +} + +.TableLabel { + font-weight: 600; + color: #30313D; +} + +#view-details { + color: #0055DE; +} + +#retry-button { + text-align: center; + background: #0055DE; + color: #ffffff; + border-radius: 4px; + border: 0; + padding: 12px 16px; + transition: all 0.2s ease; + box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07); + width: 100%; +} + +@-webkit-keyframes loading { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes loading { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes fadeInAnimation { + to { + opacity: 1; + } +} + +@media only screen and (max-width: 600px) { + form, #payment-status{ + width: 80vw; + min-width: initial; + } +} \ No newline at end of file diff --git a/public/stripe/checkout.html b/public/stripe/checkout.html new file mode 100644 index 0000000..5f8edca --- /dev/null +++ b/public/stripe/checkout.html @@ -0,0 +1,25 @@ + + + + + Accept a payment + + + + + + + + +
+
+ +
+ + +
+ + \ No newline at end of file diff --git a/public/stripe/checkout.js b/public/stripe/checkout.js new file mode 100644 index 0000000..7186169 --- /dev/null +++ b/public/stripe/checkout.js @@ -0,0 +1,89 @@ +// This is your test publishable API key. +const stripe = Stripe("pk_test_51QfbSAAB1Vm8VfJq3AWsR4k2mZjnlF7XFrmlbc6XVXrtwXquAUfwzZmOFDbxMIAwqJBgqao8KLt2wmPc4vNOCTeo00WB78KtfV"); + +// The items the customer wants to buy +const items = [{ id: "xl-tshirt", amount: 1000 }]; + +let elements; + +initialize(); + +document + .querySelector("#payment-form") + .addEventListener("submit", handleSubmit); + +// Fetches a payment intent and captures the client secret +async function initialize() { + // const response = await fetch("/create-payment-intent", { + // method: "POST", + // headers: { "Content-Type": "application/json" }, + // body: JSON.stringify({ items }), + // }); + // const { clientSecret } = await response.json(); +const clientSecret='pi_3QxII1AB1Vm8VfJq1OyR3bkz_secret_d8fgL53X6T3MQpYfi2lRH3V1F' + const appearance = { + theme: 'stripe', + }; + elements = stripe.elements({ appearance, clientSecret }); + + const paymentElementOptions = { + layout: "accordion", + }; + + const paymentElement = elements.create("payment", paymentElementOptions); + paymentElement.mount("#payment-element"); +} + +async function handleSubmit(e) { + e.preventDefault(); + setLoading(true); + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + // Make sure to change this to your payment completion page + return_url: "http://localhost:4242/complete.html", + }, + }); + + // This point will only be reached if there is an immediate error when + // confirming the payment. Otherwise, your customer will be redirected to + // your `return_url`. For some payment methods like iDEAL, your customer will + // be redirected to an intermediate site first to authorize the payment, then + // redirected to the `return_url`. + if (error.type === "card_error" || error.type === "validation_error") { + showMessage(error.message); + } else { + showMessage("An unexpected error occurred."); + } + + setLoading(false); +} + +// ------- UI helpers ------- + +function showMessage(messageText) { + const messageContainer = document.querySelector("#payment-message"); + + messageContainer.classList.remove("hidden"); + messageContainer.textContent = messageText; + + setTimeout(function () { + messageContainer.classList.add("hidden"); + messageContainer.textContent = ""; + }, 4000); +} + +// Show a spinner on payment submission +function setLoading(isLoading) { + if (isLoading) { + // Disable the button and show a spinner + document.querySelector("#submit").disabled = true; + document.querySelector("#spinner").classList.remove("hidden"); + document.querySelector("#button-text").classList.add("hidden"); + } else { + document.querySelector("#submit").disabled = false; + document.querySelector("#spinner").classList.add("hidden"); + document.querySelector("#button-text").classList.remove("hidden"); + } +} \ No newline at end of file diff --git a/public/stripe/complete.html b/public/stripe/complete.html new file mode 100644 index 0000000..c563264 --- /dev/null +++ b/public/stripe/complete.html @@ -0,0 +1,36 @@ + + + + + Order Status + + + + + + + +
+
+

+
+ + + + + + + + + + + +
id
status
+
+ View details + + + Test another +
+ + \ No newline at end of file diff --git a/public/stripe/complete.js b/public/stripe/complete.js new file mode 100644 index 0000000..1470ead --- /dev/null +++ b/public/stripe/complete.js @@ -0,0 +1,85 @@ +// ------- UI Resources ------- +const SuccessIcon = +` + +`; + +const ErrorIcon = +` + +`; + +const InfoIcon = +` + + + +`; + +// ------- UI helpers ------- +function setPaymentDetails(intent) { + let statusText = "Something went wrong, please try again."; + let iconColor = "#DF1B41"; + let icon = ErrorIcon; + + + if (!intent) { + setErrorState(); + return; + } + + switch (intent.status) { + case "succeeded": + statusText = "Payment succeeded"; + iconColor = "#30B130"; + icon = SuccessIcon; + break; + case "processing": + statusText = "Your payment is processing."; + iconColor = "#6D6E78"; + icon = InfoIcon; + break; + case "requires_payment_method": + statusText = "Your payment was not successful, please try again."; + break; + default: + break; + } + + document.querySelector("#status-icon").style.backgroundColor = iconColor; + document.querySelector("#status-icon").innerHTML = icon; + document.querySelector("#status-text").textContent= statusText; + document.querySelector("#intent-id").textContent = intent.id; + document.querySelector("#intent-status").textContent = intent.status; + document.querySelector("#view-details").href = `https://dashboard.stripe.com/payments/${intent.id}`; +} + +function setErrorState() { + document.querySelector("#status-icon").style.backgroundColor = "#DF1B41"; + document.querySelector("#status-icon").innerHTML = ErrorIcon; + document.querySelector("#status-text").textContent= "Something went wrong, please try again."; + document.querySelector("#details-table").classList.add("hidden"); + document.querySelector("#view-details").classList.add("hidden"); +} + +// Stripe.js instance +const stripe = Stripe("pk_test_51QfbSAAB1Vm8VfJq3AWsR4k2mZjnlF7XFrmlbc6XVXrtwXquAUfwzZmOFDbxMIAwqJBgqao8KLt2wmPc4vNOCTeo00WB78KtfV"); + +checkStatus(); + +// Fetches the payment intent status after payment submission +async function checkStatus() { + const clientSecret = new URLSearchParams(window.location.search).get( + "payment_intent_client_secret" + ); + + if (!clientSecret) { + setErrorState(); + return; + } + + const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret); + + setPaymentDetails(paymentIntent); +} +