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:
Marco Pedone 2026-02-20 11:36:47 +01:00
parent 627267b86f
commit 52801bebd1
13 changed files with 625 additions and 464 deletions

View file

@ -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: &ldquo;{prezziario.idprezziario}&rdquo;
</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>
);
};

View 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>
);
}
*/

View file

@ -153,6 +153,7 @@ export const PaymentsTable = ({
{
cell: ({ row }) => {
const data = row.original;
if (!isAdmin) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View 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;

View file

@ -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);

View file

@ -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}>

View file

@ -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>
);
};

View file

@ -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>;

View 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>
);
}

View file

@ -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: &ldquo;{prezziario.idprezziario}&rdquo;
</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>;
};

View file

@ -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);
}),
});

View file

@ -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 {

View file

@ -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,
};
}
};