feat: refactor payment processing and checkout flow with Stripe integration
- Removed AcquistoProcessing component and replaced it with a new CheckoutForm component for better separation of concerns. - Added new payment checkout page to handle payment intents and display payment status. - Updated server-side logic to create payment intents and retrieve payment data for checkout. - Enhanced error handling and user feedback during payment processing. - Introduced new utility functions for fetching payment data and managing Stripe integration.
This commit is contained in:
parent
627267b86f
commit
52801bebd1
13 changed files with 625 additions and 464 deletions
|
|
@ -1,243 +0,0 @@
|
|||
"use client";
|
||||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import LoadingButton from "~/components/custom_ui/loading-button";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { Status500 } from "~/components/status-page";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { env } from "~/env";
|
||||
import { formatCurrency } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import type { PaymentsId } from "~/schemas/public/Payments";
|
||||
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type AcquistoProcessingProps = {
|
||||
paymentId: PaymentsId;
|
||||
packId: PrezziarioIdprezziario;
|
||||
stripePromise: Promise<Stripe | null>;
|
||||
};
|
||||
|
||||
export const AcquistoProcessing = ({
|
||||
paymentId,
|
||||
packId,
|
||||
stripePromise,
|
||||
}: AcquistoProcessingProps) => {
|
||||
const [stripeIntent, setStripeIntent] = useState<{
|
||||
clientSecret: string | null;
|
||||
titolo: string;
|
||||
prezzo: number;
|
||||
descrizione: string;
|
||||
} | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const [generatedIntent, setGeneratedIntent] = useState(false);
|
||||
|
||||
const { mutate, isPending } = api.stripe.createIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setStripeIntent(data);
|
||||
},
|
||||
});
|
||||
|
||||
if (generatedIntent && !isPending && !stripeIntent) {
|
||||
return <Status500 />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-4 w-full max-w-3xl space-y-8 rounded-md">
|
||||
<h3 className="mt-2 text-center font-bold text-2xl uppercase tracking-wide">
|
||||
{t.acquisto.titolo}
|
||||
</h3>
|
||||
{(() => {
|
||||
if (!generatedIntent) {
|
||||
return (
|
||||
<PricePreparation
|
||||
onProcedi={() => {
|
||||
setGeneratedIntent(true);
|
||||
mutate({
|
||||
paymentId,
|
||||
});
|
||||
}}
|
||||
packId={packId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
if (stripeIntent?.clientSecret) {
|
||||
return (
|
||||
<>
|
||||
<Elements
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
colorPrimary: "#18181b",
|
||||
colorText: "#18181b",
|
||||
},
|
||||
},
|
||||
clientSecret: stripeIntent.clientSecret,
|
||||
}}
|
||||
stripe={stripePromise}
|
||||
>
|
||||
<CheckoutForm
|
||||
descrizione={stripeIntent.descrizione}
|
||||
prezzo={stripeIntent.prezzo}
|
||||
titolo={stripeIntent.titolo}
|
||||
/>
|
||||
</Elements>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PricePreparation = ({
|
||||
packId,
|
||||
onProcedi,
|
||||
}: {
|
||||
packId: PrezziarioIdprezziario;
|
||||
onProcedi: () => void;
|
||||
}) => {
|
||||
const { data: prezziario, isLoading } =
|
||||
api.prezziario.getPrezzoPerServizio.useQuery({
|
||||
idprezziario: packId,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10">
|
||||
{isLoading || !prezziario ? (
|
||||
<>
|
||||
<h3 className="font-semibold text-2xl">{t.acquisto.preparazione}</h3>
|
||||
<LoadingPage />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
<p className="text-3xl">{prezziario.nome_it}</p>
|
||||
<p className="text-lg">
|
||||
Cod: “{prezziario.idprezziario}”
|
||||
</p>
|
||||
<p className="font-bold text-4xl">
|
||||
{formatCurrency(prezziario.prezzo_cent / 100)}
|
||||
</p>
|
||||
<p>{prezziario.desc_it}</p>
|
||||
</div>
|
||||
|
||||
{prezziario.testo_condizioni && (
|
||||
<Link
|
||||
aria-label="Leggi le condizioni"
|
||||
className="block w-fit"
|
||||
href={`/servizio/condizioni/${prezziario.testo_condizioni}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="w-full text-sm">
|
||||
<span>{t.acquisto.leggi_condizioni}</span>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full text-lg"
|
||||
disabled={isLoading || !prezziario}
|
||||
onClick={onProcedi}
|
||||
size="xl"
|
||||
variant="info"
|
||||
>
|
||||
<span>{t.acquisto.procedi_pagamento}</span> <ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckoutForm = ({
|
||||
titolo,
|
||||
prezzo,
|
||||
descrizione,
|
||||
}: {
|
||||
titolo: string;
|
||||
prezzo: number;
|
||||
descrizione: string;
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
console.log("submitting payment");
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
confirmParams: {
|
||||
return_url: `${env.NEXT_PUBLIC_BASE_URL}/servizio/acquisto-processing`,
|
||||
},
|
||||
elements,
|
||||
});
|
||||
if (error.type === "card_error" || error.type === "validation_error") {
|
||||
setMessage(error.message || "An unexpected error occurred.");
|
||||
} else {
|
||||
setMessage("An unexpected error occurred.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex w-full flex-col justify-between text-left">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<p className="text-2xl">{titolo}</p>
|
||||
</CardTitle>
|
||||
<p>{descrizione}</p>
|
||||
<span>
|
||||
<span className="font-bold text-3xl">
|
||||
{formatCurrency(prezzo / 100)}
|
||||
</span>{" "}
|
||||
<span className="font-normal text-base sm:text-sm">
|
||||
{t.pricing_cmp.risultati_iva}
|
||||
</span>
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-5" id="payment-form" onSubmit={handleSubmit}>
|
||||
<PaymentElement
|
||||
id="payment-element"
|
||||
options={{ layout: "accordion" }}
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
aria-label="Procedi al pagamento"
|
||||
className="w-full"
|
||||
disabled={isLoading || !stripe || !elements}
|
||||
loading={isLoading || !stripe || !elements}
|
||||
type="submit"
|
||||
>
|
||||
{t.procedi}
|
||||
</LoadingButton>
|
||||
{message && <div id="payment-message">{message}</div>}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
187
apps/infoalloggi/src/components/checkout.tsx
Normal file
187
apps/infoalloggi/src/components/checkout.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import {
|
||||
Elements,
|
||||
PaymentElement,
|
||||
useElements,
|
||||
useStripe,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { env } from "~/env";
|
||||
import { formatCurrency } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import LoadingButton from "./custom_ui/loading-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
|
||||
const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
|
||||
|
||||
export default function CheckoutForm({
|
||||
clientSecret,
|
||||
titolo,
|
||||
prezzo,
|
||||
descrizione,
|
||||
}: {
|
||||
clientSecret: string;
|
||||
titolo: string;
|
||||
prezzo: number;
|
||||
descrizione: string;
|
||||
}) {
|
||||
return (
|
||||
<Elements
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
variables: {
|
||||
colorPrimary: "#18181b",
|
||||
colorText: "#18181b",
|
||||
},
|
||||
},
|
||||
clientSecret,
|
||||
}}
|
||||
stripe={stripePromise}
|
||||
>
|
||||
<PaymentForm descrizione={descrizione} prezzo={prezzo} titolo={titolo} />
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
const PaymentForm = ({
|
||||
titolo,
|
||||
prezzo,
|
||||
descrizione,
|
||||
}: {
|
||||
titolo: string;
|
||||
prezzo: number;
|
||||
descrizione: string;
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
console.log("submitting payment");
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
confirmParams: {
|
||||
return_url: `${env.NEXT_PUBLIC_BASE_URL}/servizio/pagamento-status`,
|
||||
},
|
||||
elements,
|
||||
});
|
||||
if (error.type === "card_error" || error.type === "validation_error") {
|
||||
setMessage(error.message || "An unexpected error occurred.");
|
||||
} else {
|
||||
setMessage("An unexpected error occurred.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex w-full flex-col justify-between text-left">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<p className="text-2xl">{titolo}</p>
|
||||
</CardTitle>
|
||||
<p>{descrizione}</p>
|
||||
<span>
|
||||
<span className="font-bold text-3xl">
|
||||
{formatCurrency(prezzo / 100)}
|
||||
</span>{" "}
|
||||
<span className="font-normal text-base sm:text-sm">
|
||||
{t.pricing_cmp.risultati_iva}
|
||||
</span>
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-5" id="payment-form" onSubmit={handleSubmit}>
|
||||
<PaymentElement
|
||||
id="payment-element"
|
||||
options={{ layout: "accordion" }}
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
aria-label="Procedi al pagamento"
|
||||
className="w-full"
|
||||
disabled={isLoading || !stripe || !elements}
|
||||
loading={isLoading || !stripe || !elements}
|
||||
type="submit"
|
||||
>
|
||||
{t.procedi}
|
||||
</LoadingButton>
|
||||
{message && <div id="payment-message">{message}</div>}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
/*
|
||||
function PaymentForm2() {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
// Stripe.js hasn't yet loaded.
|
||||
// Make sure to disable form submission until Stripe.js has loaded.
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
// Make sure to change this to your payment completion page
|
||||
return_url: "http://localhost:3000/success",
|
||||
},
|
||||
});
|
||||
|
||||
// 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") {
|
||||
setMessage(error.message || "Validation error occurred.");
|
||||
} else {
|
||||
setMessage("An unexpected error occurred.");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<form id="payment-form" onSubmit={handleSubmit}>
|
||||
<PaymentElement
|
||||
id="payment-element"
|
||||
options={{
|
||||
layout: "accordion",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={isLoading || !stripe || !elements}
|
||||
id="submit"
|
||||
type="submit"
|
||||
>
|
||||
<span id="button-text">
|
||||
{isLoading ? <div className="spinner" id="spinner"></div> : "Pay now"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{message && <div id="payment-message">{message}</div>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
|
@ -153,6 +153,7 @@ export const PaymentsTable = ({
|
|||
{
|
||||
cell: ({ row }) => {
|
||||
const data = row.original;
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
|
|||
12
apps/infoalloggi/src/lib/stripe.ts
Normal file
12
apps/infoalloggi/src/lib/stripe.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// lib/stripe.js
|
||||
import Stripe from "stripe";
|
||||
import { env } from "~/env";
|
||||
|
||||
// This check ensures we don't accidentally run this on the client
|
||||
if (typeof window !== "undefined") {
|
||||
throw new Error("Stripe Secret Key should not be loaded on the client!");
|
||||
}
|
||||
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
|
||||
|
||||
export default stripe;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { env } from "~/env";
|
||||
import stripe from "~/lib/stripe";
|
||||
import type { PaymentsId } from "~/schemas/public/Payments";
|
||||
import { appRouter } from "~/server/api/root";
|
||||
import { createTRPCContext, t } from "~/server/api/trpc";
|
||||
|
|
@ -9,8 +10,6 @@ const handler = async (
|
|||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
): Promise<void> => {
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
|
||||
|
||||
if (req.method === "POST") {
|
||||
const ctx = await createTRPCContext({ req: req, res: res });
|
||||
const createCaller = t.createCallerFactory(appRouter);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ const StripeSection = () => {
|
|||
<h4 className="mb-2 font-semibold text-lg">
|
||||
Stripe Status: {data?.success ? "Sano" : "Non sano"}
|
||||
</h4>
|
||||
<p>Keys:</p>
|
||||
<p>
|
||||
Secret Key: <span className="wrap-anywhere">{data?.secretKey}</span>
|
||||
</p>
|
||||
<p>
|
||||
Public Key: <span className="wrap-anywhere">{data?.publicKey}</span>
|
||||
</p>
|
||||
<hr />
|
||||
<p>Endpoint Webhook:</p>
|
||||
{data?.endpoints?.data.map((endpoint) => (
|
||||
<div className="mb-2" key={endpoint.id}>
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
import { Elements, useStripe } from "@stripe/react-stripe-js";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import PaymentStatus from "~/components/payment_status";
|
||||
import { ProgressRedirect } from "~/components/progress_redirect";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import { env } from "~/env";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type AcquistoProcessingProps = {
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
if (
|
||||
!context.query.payment_intent_client_secret ||
|
||||
typeof context.query.payment_intent_client_secret !== "string"
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
statusCode: 500,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
client_secret: context.query.payment_intent_client_secret,
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<AcquistoProcessingProps>;
|
||||
|
||||
export default function AcquistoProcessing({
|
||||
client_secret,
|
||||
}: AcquistoProcessingProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-5 w-full max-w-lg space-y-8">
|
||||
<h3 className="my-8 text-center font-bold text-neutral-700 text-xl uppercase tracking-wide dark:text-white">
|
||||
{t.acquisto_elaborazione.titolo}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Elements
|
||||
options={{
|
||||
appearance: {
|
||||
theme: "stripe",
|
||||
},
|
||||
clientSecret: client_secret,
|
||||
}}
|
||||
stripe={stripePromise}
|
||||
>
|
||||
<CompletePage secret={client_secret} />
|
||||
</Elements>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CompletePage = ({ secret }: { secret: string }) => {
|
||||
const stripe = useStripe();
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<
|
||||
"success" | "processing" | "failed" | "internal_error" | "not_found"
|
||||
>("processing");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPaymentIntent = async () => {
|
||||
if (!stripe) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { paymentIntent } = await stripe.retrievePaymentIntent(secret);
|
||||
if (!paymentIntent) {
|
||||
setStatus("internal_error");
|
||||
return;
|
||||
}
|
||||
const payment = await utils.pagamenti.getPaymentFromIntent.fetch({
|
||||
pIntentId: paymentIntent.id,
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
setStatus("not_found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (paymentIntent.status === "succeeded") {
|
||||
if (payment.paymentstatus !== PaymentStatusEnum.success) {
|
||||
setStatus("internal_error");
|
||||
}
|
||||
setStatus("success");
|
||||
} else if (paymentIntent.status === "processing") {
|
||||
if (payment.paymentstatus !== PaymentStatusEnum.processing) {
|
||||
setStatus("internal_error");
|
||||
}
|
||||
setStatus("processing");
|
||||
} else {
|
||||
setStatus("failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error retrieving payment intent:", error);
|
||||
}
|
||||
};
|
||||
void fetchPaymentIntent();
|
||||
}, [stripe, secret]);
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{status === "success" && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.success_desc}
|
||||
status="success"
|
||||
title={t.acquisto_elaborazione.success_title}
|
||||
/>
|
||||
|
||||
<ProgressRedirect href="/area-riservata/dashboard" />
|
||||
</>
|
||||
)}
|
||||
{status === "processing" && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.processing_desc}
|
||||
status="processing"
|
||||
title={t.acquisto_elaborazione.processing_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "info" }))}
|
||||
href="/area-riservata/dashboard"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_dashboard}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === "failed" && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.failed_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.failed_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === "internal_error" && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.internal_error_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.internal_error_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === "not_found" && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.not_found_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.not_found_title}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.back()}
|
||||
variant="secondary"
|
||||
>
|
||||
<ArrowLeft />
|
||||
{t.indietro}
|
||||
</Button>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import type { GetServerSideProps } from "next";
|
||||
import CheckoutForm from "~/components/checkout";
|
||||
import { AreaRiservataLayout } from "~/components/Layout";
|
||||
import stripe from "~/lib/stripe";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
||||
import { zPagamentoId } from "~/server/utils/zod_types";
|
||||
|
||||
type PagamentoPageProps = {
|
||||
clientSecret: string | null;
|
||||
titolo: string;
|
||||
prezzo: number;
|
||||
descrizione: string;
|
||||
};
|
||||
|
||||
const PagamentoPage: NextPageWithLayout<PagamentoPageProps> = ({
|
||||
clientSecret,
|
||||
titolo,
|
||||
prezzo,
|
||||
descrizione,
|
||||
}: PagamentoPageProps) => {
|
||||
if (!clientSecret) {
|
||||
return <div>Errore nella creazione del pagamento. Riprova più tardi.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="mx-auto my-5 w-full max-w-lg space-y-8" id="checkout">
|
||||
<CheckoutForm
|
||||
clientSecret={clientSecret}
|
||||
descrizione={descrizione}
|
||||
prezzo={prezzo}
|
||||
titolo={titolo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PagamentoPage.getLayout = function getLayout(page) {
|
||||
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
||||
};
|
||||
|
||||
export default PagamentoPage;
|
||||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
const id = context.params?.paymentId;
|
||||
console.log("Received paymentId:", id);
|
||||
if (typeof id !== "string") {
|
||||
console.error("Error: paymentId is not a string");
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = zPagamentoId.safeParse(id);
|
||||
if (!parsed.success) {
|
||||
console.error("Error parsing paymentId", parsed.error);
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const access_token = context.req.cookies.access_token;
|
||||
|
||||
const helper = await TrpcAuthedFetchingIstance({ access_token });
|
||||
if (helper) {
|
||||
const paymentData =
|
||||
await helper.trpc.pagamenti.getPagamentoDataForCheckout.fetch({
|
||||
paymentId: parsed.data,
|
||||
});
|
||||
|
||||
const { client_secret: clientSecret } = await stripe.paymentIntents.create({
|
||||
amount: paymentData.amount_cent,
|
||||
|
||||
automatic_payment_methods: {
|
||||
enabled: true,
|
||||
},
|
||||
currency: "eur",
|
||||
description: paymentData.nome_it,
|
||||
metadata: {
|
||||
paymentId: paymentData.id,
|
||||
servizioId: paymentData.servizio_id,
|
||||
userId: paymentData.userid,
|
||||
},
|
||||
receipt_email: paymentData.email,
|
||||
});
|
||||
console.log("Created payment intent with client secret:", clientSecret);
|
||||
|
||||
if (!clientSecret) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
clientSecret,
|
||||
titolo: paymentData.nome_it,
|
||||
prezzo: paymentData.amount_cent,
|
||||
descrizione: paymentData.desc_it,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<PagamentoPageProps>;
|
||||
185
apps/infoalloggi/src/pages/servizio/pagamento-status.tsx
Normal file
185
apps/infoalloggi/src/pages/servizio/pagamento-status.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { ArrowLeft } from "lucide-react";
|
||||
import type { GetServerSideProps } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import PaymentStatus from "~/components/payment_status";
|
||||
import { ProgressRedirect } from "~/components/progress_redirect";
|
||||
import { Button, buttonVariants } from "~/components/ui/button";
|
||||
import stripe from "~/lib/stripe";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
|
||||
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
||||
|
||||
enum AcquistoStatusEnum {
|
||||
success = "success",
|
||||
processing = "processing",
|
||||
failed = "failed",
|
||||
internal_error = "internal_error",
|
||||
not_found = "not_found",
|
||||
}
|
||||
|
||||
type AcquistoProcessingProps = {
|
||||
status: AcquistoStatusEnum;
|
||||
};
|
||||
|
||||
export const getServerSideProps = (async (context) => {
|
||||
const paymentIntentId = context.query?.payment_intent;
|
||||
if (!paymentIntentId || typeof paymentIntentId !== "string") {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
if (!paymentIntent) {
|
||||
return {
|
||||
props: {
|
||||
status: AcquistoStatusEnum.internal_error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const access_token = context.req.cookies.access_token;
|
||||
|
||||
const helper = await TrpcAuthedFetchingIstance({ access_token });
|
||||
if (helper) {
|
||||
const payment = await helper.trpc.pagamenti.getPaymentFromIntent.fetch({
|
||||
pIntentId: paymentIntent.id,
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
return {
|
||||
props: {
|
||||
status: AcquistoStatusEnum.not_found,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let resultStatus: AcquistoStatusEnum;
|
||||
if (paymentIntent.status === "succeeded") {
|
||||
if (payment.paymentstatus !== PaymentStatusEnum.success) {
|
||||
resultStatus = AcquistoStatusEnum.internal_error;
|
||||
}
|
||||
resultStatus = AcquistoStatusEnum.success;
|
||||
} else if (paymentIntent.status === "processing") {
|
||||
if (payment.paymentstatus !== PaymentStatusEnum.processing) {
|
||||
resultStatus = AcquistoStatusEnum.internal_error;
|
||||
}
|
||||
resultStatus = AcquistoStatusEnum.processing;
|
||||
} else {
|
||||
resultStatus = AcquistoStatusEnum.failed;
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
status: resultStatus,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/500",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}) satisfies GetServerSideProps<AcquistoProcessingProps>;
|
||||
|
||||
export default function AcquistoProcessing({
|
||||
status,
|
||||
}: AcquistoProcessingProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="mx-auto my-5 w-full max-w-lg space-y-8">
|
||||
<h3 className="my-8 text-center font-bold text-neutral-700 text-xl uppercase tracking-wide dark:text-white">
|
||||
{t.acquisto_elaborazione.titolo}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 p-4">
|
||||
{status === AcquistoStatusEnum.success && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.success_desc}
|
||||
status="success"
|
||||
title={t.acquisto_elaborazione.success_title}
|
||||
/>
|
||||
|
||||
<ProgressRedirect href="/area-riservata/dashboard" />
|
||||
</>
|
||||
)}
|
||||
{status === AcquistoStatusEnum.processing && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.processing_desc}
|
||||
status="processing"
|
||||
title={t.acquisto_elaborazione.processing_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "info" }))}
|
||||
href="/area-riservata/dashboard"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_dashboard}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === AcquistoStatusEnum.failed && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.failed_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.failed_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === AcquistoStatusEnum.internal_error && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.internal_error_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.internal_error_title}
|
||||
/>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === AcquistoStatusEnum.not_found && (
|
||||
<>
|
||||
<PaymentStatus
|
||||
message={t.acquisto_elaborazione.not_found_desc}
|
||||
status="error"
|
||||
title={t.acquisto_elaborazione.not_found_title}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.back()}
|
||||
variant="secondary"
|
||||
>
|
||||
<ArrowLeft />
|
||||
{t.indietro}
|
||||
</Button>
|
||||
<Link
|
||||
className={cn("w-full", buttonVariants({ variant: "default" }))}
|
||||
href="/area-riservata/chat"
|
||||
>
|
||||
{t.acquisto_elaborazione.to_chat}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
import type { GetServerSideProps } from "next";
|
||||
import { AcquistoProcessing } from "~/components/acquisto_processing";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { AreaRiservataLayout } from "~/components/Layout";
|
||||
import { LoadingPage } from "~/components/loading";
|
||||
import { Status500 } from "~/components/status-page";
|
||||
import { env } from "~/env";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { formatCurrency } from "~/lib/utils";
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { useTranslation } from "~/providers/I18nProvider";
|
||||
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
|
||||
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
|
||||
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
||||
import { zOrdineId } from "~/server/utils/zod_types";
|
||||
import { api } from "~/utils/api";
|
||||
|
|
@ -18,12 +22,11 @@ type ServizioPurchaseProps = {
|
|||
const ServicePurchasePage: NextPageWithLayout<ServizioPurchaseProps> = ({
|
||||
ordineId,
|
||||
}: ServizioPurchaseProps) => {
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = api.servizio.getServizioPaymentData.useQuery({
|
||||
ordineId,
|
||||
});
|
||||
|
||||
const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
|
@ -32,15 +35,77 @@ const ServicePurchasePage: NextPageWithLayout<ServizioPurchaseProps> = ({
|
|||
}
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl grow space-y-4 p-2 sm:px-8 sm:py-4">
|
||||
<AcquistoProcessing
|
||||
<PricePreparation
|
||||
onProcedi={async () =>
|
||||
await router.push(`/servizio/pagamento-checkout/${data.payment.id}`)
|
||||
}
|
||||
packId={data.ordine.packid}
|
||||
paymentId={data.payment.id}
|
||||
stripePromise={stripePromise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PricePreparation = ({
|
||||
packId,
|
||||
onProcedi,
|
||||
}: {
|
||||
packId: PrezziarioIdprezziario;
|
||||
onProcedi: () => void;
|
||||
}) => {
|
||||
const { data: prezziario, isLoading } =
|
||||
api.prezziario.getPrezzoPerServizio.useQuery({
|
||||
idprezziario: packId,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10">
|
||||
{isLoading || !prezziario ? (
|
||||
<>
|
||||
<h3 className="font-semibold text-2xl">{t.acquisto.preparazione}</h3>
|
||||
<LoadingPage />
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-6">
|
||||
<p className="text-3xl">{prezziario.nome_it}</p>
|
||||
<p className="text-lg">
|
||||
Cod: “{prezziario.idprezziario}”
|
||||
</p>
|
||||
<p className="font-bold text-4xl">
|
||||
{formatCurrency(prezziario.prezzo_cent / 100)}
|
||||
</p>
|
||||
<p>{prezziario.desc_it}</p>
|
||||
</div>
|
||||
|
||||
{prezziario.testo_condizioni && (
|
||||
<Link
|
||||
aria-label="Leggi le condizioni"
|
||||
className="block w-fit"
|
||||
href={`/servizio/condizioni/${prezziario.testo_condizioni}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="w-full text-sm">
|
||||
<span>{t.acquisto.leggi_condizioni}</span>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full text-lg"
|
||||
disabled={isLoading || !prezziario}
|
||||
onClick={onProcedi}
|
||||
size="xl"
|
||||
variant="info"
|
||||
>
|
||||
<span>{t.acquisto.procedi_pagamento}</span> <ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ServicePurchasePage.getLayout = function getLayout(page) {
|
||||
return <AreaRiservataLayout noSidebar>{page}</AreaRiservataLayout>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import { getPagamentoDataForCheckout } from "~/server/controllers/servizio.controller";
|
||||
import { db } from "~/server/db";
|
||||
import { zPagamentoId } from "~/server/utils/zod_types";
|
||||
// Create a new ratelimiter, that allows 10 requests per 10 seconds
|
||||
/*
|
||||
const ratelimit = new RateLimiterHandler({
|
||||
|
|
@ -30,4 +32,10 @@ export const pagamentiRouter = createTRPCRouter({
|
|||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getPagamentoDataForCheckout: protectedProcedure
|
||||
.input(z.object({ paymentId: zPagamentoId }))
|
||||
.query(async ({ input }) => {
|
||||
return await getPagamentoDataForCheckout(input.paymentId);
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1535,6 +1535,26 @@ export const getServizioPagamentoData = async (ordineId: OrdiniOrdineId) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getPagamentoDataForCheckout = async (paymentId: PaymentsId) => {
|
||||
try {
|
||||
return await db
|
||||
.selectFrom("payments")
|
||||
.selectAll("payments")
|
||||
.innerJoin("users", "users.id", "payments.userid")
|
||||
.select("users.email")
|
||||
.innerJoin("ordini", "ordini.ordine_id", "payments.ordine_id")
|
||||
.innerJoin("prezziario", "prezziario.idprezziario", "ordini.packid")
|
||||
.select(["prezziario.nome_it", "prezziario.desc_it"])
|
||||
.where("payments.id", "=", paymentId)
|
||||
.executeTakeFirstOrThrow();
|
||||
} catch (e) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Payment not found: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type Pagamento = Payments & Pick<Users, "username">;
|
||||
export const getAllPagamenti = async (): Promise<Pagamento[]> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -14,10 +14,20 @@ const stripeApi = new Stripe(env.STRIPE_SECRET_KEY);
|
|||
export const stripeHealthCheck = async () => {
|
||||
try {
|
||||
const endpoints = await stripeApi.webhookEndpoints.list();
|
||||
return { success: true, endpoints };
|
||||
return {
|
||||
success: true,
|
||||
endpoints,
|
||||
secretKey: env.STRIPE_SECRET_KEY,
|
||||
publicKey: env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Stripe health check failed:", e);
|
||||
return { success: false, endpoints: null };
|
||||
return {
|
||||
success: false,
|
||||
endpoints: null,
|
||||
secretKey: env.STRIPE_SECRET_KEY,
|
||||
publicKey: env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue